@@ -70,8 +70,14 @@ test('SURFACE: only makeLive() ever moves the conversation, so two surfaces cann
7070
7171test ( 'SURFACE: an already-open tab is revealed, never opened twice' , ( ) => {
7272 // Two panels would mean two DOMs, two `ready` messages, and a race for activeWebview.
73- assert . match ( fnBody ( ext , 'openChatInEditor' ) , / ^ \s * \{ \s * i f \( c h a t E d i t o r P a n e l \) \{ c h a t E d i t o r P a n e l \. r e v e a l \( \) ; r e t u r n ; \} / ,
74- 'the guard must be the first thing the command does' ) ;
73+ // The guard must still be the first STATEMENT, but it now reveals with the caller's focus
74+ // preference — a startup open that reveals an existing tab must not yank focus either.
75+ const open = fnBody ( ext , 'openChatInEditor' ) ;
76+ assert . match ( open , / i f \( c h a t E d i t o r P a n e l \) \{ c h a t E d i t o r P a n e l \. r e v e a l \( u n d e f i n e d , p r e s e r v e F o c u s \) ; r e t u r n ; \} / ,
77+ 'the already-open guard is gone or no longer honours preserveFocus' ) ;
78+ const guardAt = open . indexOf ( 'if (chatEditorPanel)' ) ;
79+ assert . ok ( guardAt >= 0 && guardAt < open . indexOf ( 'createWebviewPanel' ) ,
80+ 'the guard must run before anything can construct a second panel' ) ;
7581 assert . strictEqual ( ( ext . match ( / c r e a t e W e b v i e w P a n e l \( \s * \n ? \s * ' l e v e l c o d e \. a i \. c h a t ' / g) || [ ] ) . length , 1 ,
7682 'more than one place constructs the chat panel' ) ;
7783} ) ;
@@ -164,7 +170,8 @@ test('LABEL: a move is not rendered as "Resumed"', () => {
164170} ) ;
165171
166172test ( 'COMMAND: it is registered and discoverable in the palette' , ( ) => {
167- assert . match ( ext , / r e g i s t e r C o m m a n d \( ' l e v e l c o d e \. a i \. o p e n C h a t I n E d i t o r ' , o p e n C h a t I n E d i t o r \) / ) ;
173+ // Wrapped rather than bound by reference — see the START test on menu arguments below.
174+ assert . match ( ext , / r e g i s t e r C o m m a n d \( ' l e v e l c o d e \. a i \. o p e n C h a t I n E d i t o r ' , \( \) = > o p e n C h a t I n E d i t o r \( \) \) / ) ;
168175 const cmd = pkg . contributes . commands . find ( ( c ) => c . command === 'levelcode.ai.openChatInEditor' ) ;
169176 assert . ok ( cmd , 'not declared in package.json — it would not appear in the Command Palette' ) ;
170177 assert . match ( cmd . title , / C h a t i n E d i t o r / ) ;
@@ -218,4 +225,89 @@ test('COMMAND: it has a BUTTON on the chat header, not only the palette', () =>
218225 'navigation keeps it inline and lets VS Code overflow it into … when the sidebar is narrow' ) ;
219226} ) ;
220227
228+ test ( 'START: the chat opens centred by default, and the setting is the only place that decides' , ( ) => {
229+ const prop = pkg . contributes . configuration . properties [ 'levelcode.ai.chat.startLocation' ] ;
230+ assert . ok ( prop , 'chat.startLocation is not declared — the default would be unchangeable' ) ;
231+ assert . strictEqual ( prop . default , 'editor' , 'the chat must open in the centre by default' ) ;
232+ assert . deepStrictEqual ( prop . enum , [ 'editor' , 'secondarySidebar' , 'none' ] ,
233+ '`none` is the opt-out that replaced the old launch cap — dropping it leaves no way to turn this off' ) ;
234+ assert . strictEqual ( prop . enumDescriptions . length , prop . enum . length ,
235+ 'every value needs a description, or the settings UI shows bare identifiers' ) ;
236+
237+ // One reader, so a second caller cannot quietly disagree about what an unknown value means.
238+ const body = fnBody ( ext , 'chatStartLocation' ) ;
239+ assert . match ( body , / ' e d i t o r ' , ' s e c o n d a r y S i d e b a r ' , ' n o n e ' / , 'the reader no longer validates against the enum' ) ;
240+ assert . match ( body , / : ' e d i t o r ' / , 'an unknown value must fall back to the default, not leave the window with no chat' ) ;
241+ } ) ;
242+
243+ test ( 'START: exactly one thing opens the chat at startup' , ( ) => {
244+ // The bug this pins: the old onboarding block revealed the SIDEBAR on launch. Left in place next to
245+ // the new centred default it would open both surfaces at once on a fresh install — and because the
246+ // old one was capped at five launches, it would have "fixed itself" later, which is the worst kind.
247+ assert . ok ( ! / A U T O _ R E V E A L _ M A X _ L A U N C H E S / . test ( ext ) ,
248+ 'the old capped auto-reveal is still here — it opens the sidebar alongside the new editor tab' ) ;
249+ const startupCalls = ( ext . match ( / r e v e a l C h a t A t S t a r t u p \( \) / g) || [ ] ) . length ;
250+ assert . strictEqual ( startupCalls , 2 , 'expected one definition and one call site, found ' + startupCalls ) ;
251+
252+ const body = fnBody ( ext , 'revealChatAtStartup' ) ;
253+ assert . match ( body , / w h e r e = = = ' n o n e ' / , 'none must return before opening anything' ) ;
254+ assert . match ( body , / l e v e l c o d e A i \. c h a t \. f o c u s / , 'secondarySidebar must still reveal the contributed view' ) ;
255+ assert . match ( body , / o p e n C h a t I n E d i t o r \( \{ p r e s e r v e F o c u s : t r u e \} \) / ,
256+ 'the startup open must preserve focus — otherwise it steals the caret from a restored file' ) ;
257+ } ) ;
258+
259+ test ( 'START: the startup open cannot be triggered by a menu click' , ( ) => {
260+ // openChatInEditor now reads an options object from its first argument, and VS Code hands a command
261+ // its menu context in exactly that position. Bound by reference, a title-bar click would pass
262+ // whatever VS Code supplies — so the command is wrapped, and this is why.
263+ assert . match ( ext , / r e g i s t e r C o m m a n d \( ' l e v e l c o d e \. a i \. o p e n C h a t I n E d i t o r ' , \( \) = > o p e n C h a t I n E d i t o r \( \) \) / ,
264+ 'bind the command through a wrapper, or a menu argument can reach the options parameter' ) ;
265+ assert . match ( fnBody ( ext , 'openChatInEditor' ) , / o p t s & & o p t s \. p r e s e r v e F o c u s = = = t r u e / ,
266+ 'preserveFocus must be read strictly, so a stray truthy argument cannot enable it' ) ;
267+ } ) ;
268+
269+ test ( 'START: the fire-and-forget startup call cannot become an unhandled rejection' , ( ) => {
270+ // Nothing awaits the startup timer, so a rejection from createWebviewPanel or from the focus
271+ // command would land in the extension host attributed to nothing. Caught — but LOGGED, not
272+ // swallowed: a chat that never appears with no trace of why is the exact failure this setting is
273+ // supposed to make explicable.
274+ const call = / r e v e a l C h a t A t S t a r t u p \( \) ( [ \s \S ] { 0 , 160 } ?) \} , 6 0 0 \) / . exec ( ext ) ;
275+ assert . ok ( call , 'the startup call site moved — this guard no longer covers it' ) ;
276+ assert . match ( call [ 1 ] , / \. c a t c h \( / , 'the fire-and-forget startup call has no .catch — unhandled rejection' ) ;
277+ assert . match ( call [ 1 ] , / d b g \( / , 'the failure is swallowed silently; log the reason so it can be diagnosed' ) ;
278+ } ) ;
279+
280+ test ( 'MOVE BACK: a failed move reaches the user instead of vanishing' , ( ) => {
281+ // Deliberately the OPPOSITE of the startup path. This is an explicit click, and registerCommand
282+ // awaits what the handler returns — so returning the thenable turns a failure into a reported
283+ // command error, where swallowing it would leave the user pressing a button that does nothing.
284+ const body = fnBody ( ext , 'moveChatToSidebar' ) ;
285+ assert . match ( body , / r e t u r n v s c o d e \. c o m m a n d s \. e x e c u t e C o m m a n d \( ' l e v e l c o d e A i \. c h a t \. f o c u s ' \) / ,
286+ 'the reveal must be RETURNED, or a failure is an unhandled rejection and the click looks inert' ) ;
287+ assert . match ( ext , / r e g i s t e r C o m m a n d \( ' l e v e l c o d e \. a i \. m o v e C h a t T o S i d e b a r ' , \( \) = > m o v e C h a t T o S i d e b a r \( \) \) / ,
288+ 'the registration must return the handler result, or returning it inside buys nothing' ) ;
289+ } ) ;
290+
291+ test ( 'MOVE BACK: there is a button on the tab, and it reuses the dispose hand-over' , ( ) => {
292+ // The chat now opens centred for everyone, so the way BACK has to be visible from the centre.
293+ // Before this it existed only on the sidebar card — which you cannot see while the chat is a tab.
294+ const cmd = pkg . contributes . commands . find ( ( c ) => c . command === 'levelcode.ai.moveChatToSidebar' ) ;
295+ assert . ok ( cmd , 'no move-back command — the only way right would be closing the tab' ) ;
296+ assert . ok ( cmd . icon , 'no icon — an editor/title action with no icon renders as nothing' ) ;
297+
298+ const entry = ( pkg . contributes . menus [ 'editor/title' ] || [ ] )
299+ . find ( ( m ) => m . command === 'levelcode.ai.moveChatToSidebar' ) ;
300+ assert . ok ( entry , 'not contributed to editor/title — reachable only from the Command Palette' ) ;
301+ assert . strictEqual ( entry . when , "activeWebviewPanelId == 'levelcode.ai.chat'" ,
302+ 'scope it to the chat panel, or the button appears on every editor tab in the window' ) ;
303+
304+ // Disposing IS the move: onDidDispose already hands the slot back and replays the transcript, so
305+ // this must not grow a second copy of that path.
306+ const body = fnBody ( ext , 'moveChatToSidebar' ) ;
307+ assert . match ( body , / c h a t E d i t o r P a n e l \. d i s p o s e \( \) / , 'the move must go through dispose, not a parallel hand-over' ) ;
308+ assert . ok ( ! / m a k e L i v e | r e p l a y L i v e T r a n s c r i p t / . test ( body ) ,
309+ 'this is duplicating the hand-over instead of reusing onDidDispose — the two will drift' ) ;
310+ assert . match ( body , / l e v e l c o d e A i \. c h a t \. f o c u s / , 'with no panel open the command must still reveal the chat, not do nothing' ) ;
311+ } ) ;
312+
221313console . log ( '\nchatSurface: ' + n + ' tests passed.' ) ;
0 commit comments