@volter/supercode-frontend-vscode 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,12 +73,29 @@ picker includes the official choice on supported hosts; with no choices it retur
73
73
  to setup. Model
74
74
  and reasoning are sent as empty values, leaving both at the harness's own defaults,
75
75
  even if the previous conversation used explicit settings. Cancelling the agent
76
- picker creates nothing.
76
+ picker starts no backend conversation.
77
+
78
+ The native blank chat opens before the picker appears. First send waits for the
79
+ choice and the host's new connection through Code-OSS's session-creation handler;
80
+ the composer keeps the draft until the request is accepted. There is no later
81
+ conversation swap that can hide a message sent while the agent starts. This follows
82
+ the pinned workbench's [first-send materialization](https://github.com/volter-ai/code-oss/blob/f16dc165c0dffe701a7bbf59aefb1c662b206cee/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts#L1272)
83
+ and [draft acceptance](https://github.com/volter-ai/code-oss/blob/f16dc165c0dffe701a7bbf59aefb1c662b206cee/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts#L3493)
84
+ paths. A failed startup leaves the draft available to retry.
85
+
86
+ The pending agent/model choice belongs to that blank chat's untitled resource
87
+ and is retired when another conversation opens. Cancelling or opening sign-in
88
+ does not choose an agent. Readiness polling and **Check again** still discover
89
+ agents while a draft is open, updating the welcome and model catalogue without
90
+ replacing its widget or input. First send refreshes readiness; after a native
91
+ sign-in choice it uses that agent once ready, otherwise it keeps the draft with
92
+ a visible sign-in error.
77
93
 
78
94
  The composer's native **Model settings** control offers **Choose model and reasoning
79
95
  for a new chat…** for Claude Code and Codex. This optional path retains the model
80
96
  list, exact model ID entry and reasoning choices. Only completing the choices
81
- starts a separate conversation; cancelling either leaves the current one intact.
97
+ configures the new blank chat; its backend starts on first send. Cancelling a
98
+ choice starts no backend and leaves the blank chat available.
82
99
  A conversation's harness, model and reasoning remain fixed: this integration does
83
100
  not advertise hot swapping. The built-in model picker still displays the selected
84
101
  model and provider icon; nondelegating session providers cannot add its Manage
@@ -93,7 +110,12 @@ The host publishes durable conversation IDs and their exact harness history iden
93
110
  reported native session ID to discovered history. Unknown or missing bindings fail explicitly,
94
111
  never falling back to the currently active runtime. The host may provide an `openSessionCommand`
95
112
  to reveal an exact resource through its native widget service. Cached native widgets should
96
- notify `supercode.frontend.activateSession` when focused. The frontend owns no processes.
113
+ notify `supercode.frontend.focusSession` on every native focused-resource change,
114
+ including untitled resources and leaving harness Chat (`undefined` when there is
115
+ no focused session). This retires abandoned draft choices even when returning to
116
+ the same cached conversation. Managed resources then notify
117
+ `supercode.frontend.activateSession` to restore their exact backend. The frontend
118
+ owns no processes.
97
119
 
98
120
  The permission group offers **Ask for approval** and, on an answerable interactive attachment,
99
121
  **Auto approve**. Policies are scoped by conversation and captured for each turn; automatic
package/dist/extension.js CHANGED
@@ -321,6 +321,24 @@ export async function activate(context) {
321
321
  const resourceBindings = new Map(context.workspaceState.get('supercode.sessionBindings', []));
322
322
  const boundId = (resource) => resolveSessionBinding(resource, controls?.state.sessions ?? [], resourceBindings);
323
323
  let applying = Promise.resolve();
324
+ let pendingDraft;
325
+ let focusedResource;
326
+ let focusGeneration = 0;
327
+ const activeDraft = () => pendingDraft?.resource && pendingDraft.resource.toString() === focusedResource?.toString() ? pendingDraft : undefined;
328
+ const draftSelection = () => {
329
+ const choice = activeDraft()?.choice;
330
+ return choice?.kind === 'agent' ? choice.selection : undefined;
331
+ };
332
+ const focusSession = (resource) => {
333
+ const changed = focusedResource?.toString() !== resource?.toString();
334
+ focusedResource = resource;
335
+ if (pendingDraft?.resource && pendingDraft.resource.toString() !== resource?.toString())
336
+ pendingDraft = undefined;
337
+ if (changed) {
338
+ focusGeneration++;
339
+ controlsChanged.fire();
340
+ }
341
+ };
324
342
  const frontends = new SupercodeFrontends();
325
343
  // Runtimes attached by address ("Set Runtime Token", "Attach to Runtime")
326
344
  // beside a host's managed conversation. The host's catalogue does not know
@@ -343,6 +361,9 @@ export async function activate(context) {
343
361
  if (focusedAttached)
344
362
  return runtimeIdentity(focusedAttached.session.descriptor?.source_harness, focusedAttached.session.descriptor?.model);
345
363
  const descriptor = managed()?.session.descriptor;
364
+ const selection = draftSelection();
365
+ if (selection)
366
+ return runtimeIdentity(selection.harness, selection.model);
346
367
  return runtimeIdentity(controls?.state.selection.harness ?? descriptor?.source_harness, controls?.state.selection.model || controls?.state.actualModel || descriptor?.model);
347
368
  };
348
369
  const icon = () => {
@@ -357,7 +378,9 @@ export async function activate(context) {
357
378
  provideLanguageModelChatInformation: () => {
358
379
  if (controls.state.setup && !controls.state.setup.ready)
359
380
  return [];
360
- const { selection, models } = controls.state;
381
+ const draft = draftSelection();
382
+ const selection = draft ?? controls.state.selection;
383
+ const models = draft ? controls.state.modelsByHarness?.[selection.harness] ?? [] : controls.state.models;
361
384
  // A running harness does not advertise hot model changes. Configuration
362
385
  // belongs to the explicit new-conversation flow.
363
386
  const choices = models.filter(model => model.id === selection.model);
@@ -386,11 +409,20 @@ export async function activate(context) {
386
409
  const id = resource ? boundId(resource) : controls?.state.activeSession;
387
410
  return { permissions: id ? permissions.get(id) ?? 'ask' : permission };
388
411
  };
389
- const connectManaged = async () => {
390
- const env = controls.state.connection;
391
- if (!env || !controls.state.activeSession)
412
+ const savedConnections = new Map();
413
+ const saveManagedHistory = async (connection) => {
414
+ const state = await controls.open(connection.id);
415
+ if (state.activeSession !== connection.id)
416
+ throw new Error('The conversation changed before its history could be saved.');
417
+ connection.persistedHistory = state.history;
418
+ connection.historyTruncated = state.historyTruncated;
419
+ savedConnections.set(connection.id, connection);
420
+ };
421
+ const connectManaged = async (state = controls.state) => {
422
+ const env = state.connection;
423
+ if (!env || !state.activeSession)
392
424
  throw new Error('The requested conversation has no runtime connection.');
393
- const id = controls.state.activeSession;
425
+ const id = state.activeSession;
394
426
  const existing = frontends.get(id);
395
427
  if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL && existing.session.connection !== 'closed') {
396
428
  if (existing.session.connection !== 'connected')
@@ -401,7 +433,7 @@ export async function activate(context) {
401
433
  await frontends.disconnect(connection.id);
402
434
  const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
403
435
  token: await readCredentialFile(env.SUPERCODE_FRONTEND_CREDENTIAL_FILE), clientId: env.SUPERCODE_FRONTEND_CLIENT_ID,
404
- permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: identity().name }, log);
436
+ permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: runtimeIdentity(state.selection.harness, state.selection.model || state.actualModel).name }, log);
405
437
  if (!connection)
406
438
  throw new Error('Could not connect to the requested conversation.');
407
439
  return connection;
@@ -438,23 +470,33 @@ export async function activate(context) {
438
470
  };
439
471
  let refreshingSetup;
440
472
  const refreshSetup = () => {
441
- refreshingSetup ??= (async () => {
473
+ if (refreshingSetup)
474
+ return refreshingSetup;
475
+ const pending = applying.then(async () => {
442
476
  if (!controls)
443
477
  return;
478
+ // Discover and publish readiness even while a blank chat holds a draft.
479
+ // Only the automatic reveal is suppressed; discovery must never stall.
444
480
  await controls.refresh();
445
- if (controls.state.setup?.ready) {
481
+ const state = controls.state;
482
+ controlsChanged.fire();
483
+ const preserveDraft = focusedResource?.path.startsWith('/untitled-');
484
+ if (state.setup?.ready) {
446
485
  setupRevealed = false;
447
- await connectManaged();
486
+ await connectManaged(state);
448
487
  controlsChanged.fire();
449
- if (!setupHandoffComplete())
488
+ if (!preserveDraft && !setupHandoffComplete())
450
489
  await revealManaged();
451
490
  }
452
491
  else {
453
492
  setupHandoff = undefined;
454
493
  controlsChanged.fire();
455
- await revealSetup();
494
+ if (!preserveDraft)
495
+ await revealSetup();
456
496
  }
457
- })().finally(() => { refreshingSetup = undefined; });
497
+ });
498
+ applying = pending.then(() => undefined, () => undefined);
499
+ refreshingSetup = pending.finally(() => { refreshingSetup = undefined; });
458
500
  return refreshingSetup;
459
501
  };
460
502
  const setupTerminals = new Map();
@@ -487,7 +529,8 @@ export async function activate(context) {
487
529
  setupTerminals.set(key, terminal);
488
530
  terminal.show();
489
531
  }));
490
- const connectionFor = async (resource) => {
532
+ const restoring = new Map();
533
+ const connectionFor = async (resource, activate = true) => {
491
534
  if (!controls) {
492
535
  const connection = frontends.get(resource.authority);
493
536
  if (!connection)
@@ -495,7 +538,7 @@ export async function activate(context) {
495
538
  return connection;
496
539
  }
497
540
  const attached = attachedIds.has(resource.authority) ? frontends.get(resource.authority) : undefined;
498
- if (focusedAttached !== attached) {
541
+ if (activate && focusedAttached !== attached) {
499
542
  focusedAttached = attached;
500
543
  controlsChanged.fire();
501
544
  }
@@ -504,43 +547,124 @@ export async function activate(context) {
504
547
  const id = boundId(resource);
505
548
  if (!id)
506
549
  throw new Error('This chat has no backend session binding. Start a new chat; no message was sent.');
507
- const active = managed();
508
- if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
509
- throw new Error('Finish or cancel the active turn before opening another conversation.');
550
+ // Content refresh is not a focus change. Replaying a previously connected
551
+ // conversation must not retire the runtime the person just selected.
552
+ if (!activate) {
553
+ const saved = frontends.get(id) ?? savedConnections.get(id);
554
+ if (saved)
555
+ return saved;
556
+ }
557
+ const existing = restoring.get(id);
558
+ // Coalesce adjacent requests only. A later A after a queued B must still
559
+ // activate A last, rather than borrowing an earlier A's completion.
560
+ if (existing?.completion === applying)
561
+ return existing.pending;
562
+ const pending = applying.then(async () => {
563
+ const active = managed();
564
+ if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
565
+ throw new Error('Finish or cancel the active turn before opening another conversation.');
566
+ if (active && active.id !== id) {
567
+ // Capture the exact saved transcript while its runtime still belongs
568
+ // to the host. Subsequent background content reads need no activation.
569
+ await saveManagedHistory(active);
570
+ }
571
+ let state;
572
+ try {
573
+ state = await controls.open(id);
574
+ }
575
+ catch (error) {
576
+ log.error(`Could not restore conversation ${id}: ${String(error)}`);
577
+ throw error;
578
+ }
579
+ if (state.activeSession !== id)
580
+ throw new Error('The host did not restore the requested conversation.');
581
+ const connection = await connectManaged(state);
582
+ connection.persistedHistory = state.history;
583
+ connection.historyTruncated = state.historyTruncated;
584
+ savedConnections.set(id, connection);
585
+ if (active !== connection)
586
+ controlsChanged.fire();
587
+ return connection;
588
+ });
589
+ const completion = pending.then(() => undefined, () => undefined);
590
+ restoring.set(id, { pending, completion });
591
+ applying = completion;
510
592
  try {
511
- await controls.open(id);
593
+ return await pending;
512
594
  }
513
- catch (error) {
514
- log.error(`Could not restore conversation ${id}: ${String(error)}`);
515
- throw error;
595
+ finally {
596
+ if (restoring.get(id)?.pending === pending)
597
+ restoring.delete(id);
516
598
  }
517
- const history = controls.state.history;
518
- const historyTruncated = controls.state.historyTruncated;
519
- const connection = await connectManaged();
520
- connection.persistedHistory = history;
521
- connection.historyTruncated = historyTruncated;
522
- // Resolving an already-focused session must not republish its catalogue
523
- // while the native widget is still opening that same resource.
524
- if (active !== connection)
525
- controlsChanged.fire();
526
- return connection;
527
599
  };
528
- async function applySelection(selection, openChat = true) {
600
+ async function applySelection(selection) {
529
601
  if (!controls)
530
602
  throw new Error('This host does not expose harness or model configuration.');
531
603
  const active = managed();
532
604
  if (active?.session.descriptor?.turn_state === 'busy' || active?.session.pendingRequests.size)
533
605
  throw new Error('Finish or cancel the current turn before starting a new chat.');
534
- await controls.select(selection);
535
- await connectManaged();
606
+ if (active)
607
+ await saveManagedHistory(active);
608
+ const state = await controls.select(selection);
609
+ const connection = await connectManaged(state);
536
610
  controlsChanged.fire();
537
- if (openChat)
538
- await revealManaged();
611
+ return connection;
539
612
  }
540
- const enqueueSelection = (selection, openChat = true) => {
541
- const pending = applying.then(() => applySelection(selection, openChat));
542
- applying = pending.catch(() => undefined);
543
- return pending;
613
+ const openNewChat = async () => {
614
+ if (!controls || !granted.has('chatSessionsProvider'))
615
+ throw new Error('This host does not expose native conversation creation.');
616
+ const active = managed();
617
+ if (active?.session.descriptor?.turn_state === 'busy' || active?.session.pendingRequests.size)
618
+ throw new Error('Finish or cancel the current turn before starting a new chat.');
619
+ // Code-OSS materializes this untitled session on first send, awaiting our
620
+ // newChatSessionItemHandler before accepting/clearing the draft. Never boot
621
+ // beside the old widget and reveal another resource after it accepts input.
622
+ if (!controls.state.openSessionCommand)
623
+ throw new Error('The host cannot reveal a new conversation.');
624
+ // Use Code-OSS's untitled URI format and its existing native widget bridge.
625
+ // Blank sessions resolve content lazily, so a content-provider callback
626
+ // cannot be used to discover which resource owns the pending selection.
627
+ const draft = { resource: vscode.Uri.from({ scheme: SESSION_SCHEME, path: `/untitled-${randomUUID()}` }) };
628
+ pendingDraft = draft;
629
+ focusedResource = draft.resource;
630
+ focusGeneration++;
631
+ focusedAttached = undefined;
632
+ controlsChanged.fire();
633
+ await vscode.commands.executeCommand(controls.state.openSessionCommand, draft.resource.toString());
634
+ if (pendingDraft !== draft)
635
+ throw new vscode.CancellationError();
636
+ return draft;
637
+ };
638
+ const chooseNewChat = async (pick) => {
639
+ if (pendingDraft?.choosing)
640
+ throw new Error('Finish choosing an agent before starting another chat.');
641
+ // Open before the picker: a send can beat its result across the extension-
642
+ // host boundary. Materialization awaits this exact draft's choice too.
643
+ const draft = await openNewChat();
644
+ const picking = (async () => {
645
+ const choice = await pick();
646
+ if (activeDraft() !== draft)
647
+ throw new vscode.CancellationError();
648
+ draft.choice = choice;
649
+ controlsChanged.fire();
650
+ if (choice.kind !== 'agent')
651
+ throw new vscode.CancellationError();
652
+ })();
653
+ draft.choosing = picking;
654
+ try {
655
+ await picking;
656
+ }
657
+ catch (error) {
658
+ if (!(error instanceof vscode.CancellationError))
659
+ throw error;
660
+ }
661
+ finally {
662
+ if (draft.choosing === picking)
663
+ draft.choosing = undefined;
664
+ if (draft.choice?.kind === 'signIn') {
665
+ void refreshSetup().catch(error => log.warn(`Could not refresh sign-in: ${String(error)}`)).finally(pollSetup);
666
+ }
667
+ }
544
668
  };
545
669
  // Publish capability-honest input groups only after the handoff descriptor is available.
546
670
  if (controls?.state.connection)
@@ -561,7 +685,7 @@ export async function activate(context) {
561
685
  if (selected && ['claude-code', 'codex'].includes(selected.selection.harness)) {
562
686
  // Native session-option commands remain reachable for nondelegating providers,
563
687
  // whose built-in model picker deliberately omits Manage Models. Do not advertise
564
- // hot swapping: the command opens a separate conversation after both choices.
688
+ // hot swapping: the command configures a separate native blank chat.
565
689
  // A locked selected item hides the entire native dropdown, including commands.
566
690
  const item = { id: 'settings', name: 'Model settings' };
567
691
  groups.push({ id: 'modelSettings', name: 'Model', items: [item], selected: item,
@@ -581,19 +705,50 @@ export async function activate(context) {
581
705
  create: async (inputState) => {
582
706
  if (!controls)
583
707
  throw new Error('The host does not expose conversation creation.');
584
- await enqueueSelection({ harness: controls.state.selection.harness, model: '', effort: '' }, false);
585
- const connection = frontends.get(controls.state.activeSession);
708
+ const draft = activeDraft();
709
+ await draft?.choosing;
710
+ if (draft && activeDraft() !== draft)
711
+ throw new vscode.CancellationError();
712
+ await controls.refresh();
713
+ controlsChanged.fire();
714
+ const choice = draft?.choice;
715
+ const loginHarness = choice?.kind === 'signIn' ? choice.harness : undefined;
716
+ if (loginHarness && !controls.state.harnesses.some(h => h.id === loginHarness && h.autoStart)) {
717
+ throw new Error(`Finish signing in with ${harnessDisplayName(loginHarness)} before sending. Your draft is kept.`);
718
+ }
719
+ if (choice?.kind !== 'agent' && controls.state.setup && !controls.state.setup.ready) {
720
+ throw new Error('Sign in before sending. Your draft is kept; use Check again after signing in.');
721
+ }
722
+ const selection = choice?.kind === 'agent' ? choice.selection
723
+ : { harness: loginHarness ?? controls.state.selection.harness, model: '', effort: '' };
724
+ const pending = applying.then(() => applySelection(selection));
725
+ applying = pending.then(() => undefined, () => undefined);
726
+ const connection = await pending;
586
727
  savePermission(connection.id, turnPermission(inputState.groups.find(group => group.id === 'permissions')?.selected?.id));
728
+ if (pendingDraft === draft) {
729
+ pendingDraft = undefined;
730
+ controlsChanged.fire();
731
+ }
587
732
  return connection;
588
733
  },
589
- resolve: connectionFor,
734
+ resolve: (resource) => connectionFor(resource, false),
590
735
  attached: (connection) => attachedIds.has(connection.id),
591
736
  catalogue: () => controls?.state.sessions ?? [],
592
737
  bindings: boundId,
593
738
  };
594
739
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.activateSession', async (resource) => { await connectionFor(vscode.Uri.parse(resource)); }));
740
+ // The host's native focus event is authoritative even when a cached model
741
+ // skips content resolution. Untitled/non-harness focus never starts a runtime.
742
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.focusSession', (resource) => {
743
+ focusSession(resource ? vscode.Uri.parse(resource) : undefined);
744
+ }));
595
745
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.openSession', async (id) => {
596
- await connectionFor(vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' }));
746
+ const resource = vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' });
747
+ focusSession(resource);
748
+ const generation = focusGeneration;
749
+ await connectionFor(resource);
750
+ if (generation !== focusGeneration)
751
+ return;
597
752
  await revealManaged();
598
753
  }));
599
754
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.configure', async (supplied) => {
@@ -607,7 +762,7 @@ export async function activate(context) {
607
762
  return currentOptions();
608
763
  }
609
764
  if (supplied && typeof supplied.harness === 'string') {
610
- await enqueueSelection(supplied);
765
+ await chooseNewChat(async () => ({ kind: 'agent', selection: supplied }));
611
766
  return currentOptions();
612
767
  }
613
768
  if (!controls) {
@@ -632,18 +787,20 @@ export async function activate(context) {
632
787
  }
633
788
  // A sole sign-in option still requires a deliberate click before opening its terminal.
634
789
  // The official choice must not disable the existing sole-local-agent shortcut.
635
- const harness = localChoices.length === 1 && localChoices[0].autoStart ? localChoices[0] : await vscode.window.showQuickPick(choices, { title: 'New chat · Agent', placeHolder: 'Choose your coding agent. Model and reasoning use its defaults.' });
636
- if (!harness)
637
- return;
638
- if (harness.id === CHATGPT_SIGN_IN) {
639
- await vscode.commands.executeCommand(CHATGPT_SIGN_IN);
640
- return;
641
- }
642
- if (harness.login) {
643
- await vscode.commands.executeCommand('supercode.frontend.setup', 'login', harness.id);
644
- return;
645
- }
646
- await enqueueSelection({ harness: harness.id, model: '', effort: '' });
790
+ await chooseNewChat(async () => {
791
+ const harness = localChoices.length === 1 && localChoices[0].autoStart ? localChoices[0] : await vscode.window.showQuickPick(choices, { title: 'New chat · Agent', placeHolder: 'Choose your coding agent. Model and reasoning use its defaults.' });
792
+ if (!harness)
793
+ return { kind: 'cancelled' };
794
+ if (harness.id === CHATGPT_SIGN_IN) {
795
+ await vscode.commands.executeCommand(CHATGPT_SIGN_IN);
796
+ return { kind: 'signIn' };
797
+ }
798
+ if (harness.login) {
799
+ await vscode.commands.executeCommand('supercode.frontend.setup', 'login', harness.id);
800
+ return { kind: 'signIn', harness: harness.id };
801
+ }
802
+ return { kind: 'agent', selection: { harness: harness.id, model: '', effort: '' } };
803
+ });
647
804
  }));
648
805
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.configureModel', async (resource, sessionId) => {
649
806
  if (!controls || focusedAttached)
@@ -660,25 +817,25 @@ export async function activate(context) {
660
817
  const selection = { ...selected.selection };
661
818
  if (!['claude-code', 'codex'].includes(selection.harness))
662
819
  throw new Error('This harness uses its own model defaults; configuration is not advertised.');
663
- if (['claude-code', 'codex'].includes(selection.harness)) {
820
+ await chooseNewChat(async () => {
664
821
  const choices = controls.state.modelsByHarness?.[selection.harness] ?? [{ id: '', name: 'Harness default' }];
665
822
  const picked = await vscode.window.showQuickPick([...choices.map(model => ({ label: model.name, id: model.id })), { label: 'Enter an exact model ID…', id: '__custom__' }], { title: 'New chat · Model', placeHolder: 'Model availability depends on your harness account.' });
666
823
  if (!picked)
667
- return;
824
+ return { kind: 'cancelled' };
668
825
  if (picked.id === '__custom__') {
669
826
  const model = await vscode.window.showInputBox({ title: 'New chat · Exact model', prompt: 'Model ID or CLI alias supported by this harness account.', value: selection.model });
670
827
  if (model === undefined)
671
- return;
828
+ return { kind: 'cancelled' };
672
829
  selection.model = model.trim();
673
830
  }
674
831
  else
675
832
  selection.model = picked.id;
676
833
  const effort = await vscode.window.showQuickPick([{ label: 'Harness default', id: '' }, ...['low', 'medium', 'high'].map(id => ({ label: id, id }))], { title: 'New chat · Reasoning effort' });
677
834
  if (!effort)
678
- return;
835
+ return { kind: 'cancelled' };
679
836
  selection.effort = effort.id;
680
- }
681
- await enqueueSelection(selection);
837
+ return { kind: 'agent', selection };
838
+ });
682
839
  }));
683
840
  context.subscriptions.push({ dispose: () => frontends.dispose() });
684
841
  const runTurnInto = async (connection, prompt, stream, token, approval) => {
@@ -804,8 +961,10 @@ export async function activate(context) {
804
961
  if (welcomeValue === welcome?.value)
805
962
  return;
806
963
  welcomeValue = welcome?.value;
964
+ // Metadata updates merge across RPC; undefined can leave the previous
965
+ // sign-in message behind. An explicit empty value clears it in place.
807
966
  for (const participant of participants)
808
- participant.additionalWelcomeMessage = welcome;
967
+ participant.additionalWelcomeMessage = welcome ?? new vscode.MarkdownString('');
809
968
  };
810
969
  updateWelcome();
811
970
  context.subscriptions.push(controlsChanged.event(updateWelcome), frontends.onDidChange(updateWelcome));
@@ -817,7 +976,7 @@ export async function activate(context) {
817
976
  // canDelegate is disabled to avoid a generic workbench-owned registration.
818
977
  // A participant names the harness; the native model picker names the model.
819
978
  // Keep the author stable when a model changes during request dispatch.
820
- const participantName = () => harnessDisplayName(focusedAttached?.session.descriptor?.source_harness ?? controls?.state.selection.harness ?? managed()?.session.descriptor?.source_harness);
979
+ const participantName = () => harnessDisplayName(focusedAttached?.session.descriptor?.source_harness ?? draftSelection()?.harness ?? controls?.state.selection.harness ?? managed()?.session.descriptor?.source_harness);
821
980
  const participantKey = () => `${participantName()}/${identity().provider ?? ''}`;
822
981
  const makeParticipant = () => {
823
982
  const current = { name: participantName() };
@@ -839,7 +998,28 @@ export async function activate(context) {
839
998
  p.iconPath = icon();
840
999
  };
841
1000
  context.subscriptions.push({ dispose: () => sessionParticipant.dispose() }, controlsChanged.event(updateIdentity), frontends.onDidChange(updateIdentity));
842
- registerSessions(context, frontends, sessionParticipant, granted, handler, controls ? providerControls : undefined);
1001
+ // Replayed turns outlive the active composer participant. Keep their
1002
+ // author tied to the saved harness, including passive background loads.
1003
+ const historyParticipants = new Map();
1004
+ const historyParticipant = (connection) => {
1005
+ if (!granted.has('chatParticipantPrivate'))
1006
+ return sessionParticipant.id;
1007
+ const harness = connection.session.descriptor?.source_harness
1008
+ ?? controls?.state.sessions.find(entry => entry.id === connection.id)?.selection.harness;
1009
+ if (!harness)
1010
+ return sessionParticipant.id;
1011
+ let author = historyParticipants.get(harness);
1012
+ if (!author) {
1013
+ const name = harnessDisplayName(harness);
1014
+ author = vscode.chat.createDynamicChatParticipant(`${SESSION_SCHEME}.history.${encodeURIComponent(harness)}`, { name: name.replace(/[^a-zA-Z0-9]/g, ''), fullName: name, publisherName: 'Volter' }, handler);
1015
+ const provider = runtimeIdentity(harness, '').provider;
1016
+ author.iconPath = provider ? vscode.Uri.joinPath(context.extensionUri, 'media', `${provider}.svg`) : new vscode.ThemeIcon('hubot');
1017
+ historyParticipants.set(harness, author);
1018
+ context.subscriptions.push(author);
1019
+ }
1020
+ return author.id;
1021
+ };
1022
+ registerSessions(context, frontends, sessionParticipant, granted, handler, historyParticipant, controls ? providerControls : undefined);
843
1023
  }
844
1024
  catch (error) {
845
1025
  log.warn(`Volter Harness chat sessions were not registered: ${String(error?.message ?? error)}`);
@@ -1067,9 +1247,12 @@ export async function activate(context) {
1067
1247
  let setupTimer;
1068
1248
  let setupDisposed = false;
1069
1249
  const pollSetup = () => {
1070
- if (setupDisposed || !controls?.state.setup || (controls.state.setup.ready && setupHandoffComplete()))
1250
+ const choice = activeDraft()?.choice;
1251
+ const awaitingLogin = choice?.kind === 'signIn' && choice.harness && !controls?.state.harnesses.some(h => h.id === choice.harness && h.autoStart);
1252
+ if (setupTimer || setupDisposed || !controls?.state.setup || (controls.state.setup.ready && setupHandoffComplete() && !awaitingLogin))
1071
1253
  return;
1072
1254
  setupTimer = setTimeout(() => {
1255
+ setupTimer = undefined;
1073
1256
  void refreshSetup().catch(error => log.warn(`Could not refresh sign-in: ${String(error)}`)).finally(pollSetup);
1074
1257
  }, 3000);
1075
1258
  };
@@ -1206,7 +1389,7 @@ function applyTiming(item, connection) {
1206
1389
  lastRequestEnded: connection.session.lastTurnEndedMs ?? undefined,
1207
1390
  };
1208
1391
  }
1209
- function registerSessions(context, frontends, participant, granted, handler, controls) {
1392
+ function registerSessions(context, frontends, participant, granted, handler, historyParticipant, controls) {
1210
1393
  const turns = vscode;
1211
1394
  const items = () => {
1212
1395
  return (controls?.catalogue() ?? []).filter(entry => !frontends.get(entry.id)).map(entry => {
@@ -1260,11 +1443,15 @@ function registerSessions(context, frontends, participant, granted, handler, con
1260
1443
  }));
1261
1444
  }
1262
1445
  context.subscriptions.push(vscode.chat.registerChatSessionContentProvider(SESSION_SCHEME, {
1263
- provideChatSessionContent: async (resource, _token, { inputState }) => {
1446
+ provideChatSessionContent: async (resource, token, { inputState }) => {
1447
+ if (token.isCancellationRequested)
1448
+ throw new vscode.CancellationError();
1264
1449
  const boundHandler = (request, chatContext, stream, token) => handler(request, { ...chatContext, chatSessionContext: { chatSessionItem: { resource, label: 'Conversation' } } }, stream, token);
1265
1450
  // Only a native untitled resource can create a conversation. Existing
1266
1451
  // resources must resolve their exact saved binding; there is no fallback.
1267
1452
  const connection = controls ? (resource.path.startsWith('/untitled-') && !controls.bindings(resource) ? undefined : await controls.resolve(resource)) : frontends.get(resource.authority);
1453
+ if (token.isCancellationRequested)
1454
+ throw new vscode.CancellationError();
1268
1455
  if (!connection) {
1269
1456
  return { history: [], requestHandler: boundHandler };
1270
1457
  }
@@ -1277,13 +1464,14 @@ function registerSessions(context, frontends, participant, granted, handler, con
1277
1464
  // `state.history`), so the copy taken when this window attached is
1278
1465
  // stale as soon as a turn runs. The generated client reuses the
1279
1466
  // already-open event stream, so this is a snapshot, not a reconnect.
1280
- const attached = await connection.session.attach().then(() => true).catch(() => {
1467
+ const attached = frontends.get(connection.id) === connection && await connection.session.attach().then(() => true).catch(() => {
1281
1468
  // An unreachable runtime replays what this window last saw.
1282
1469
  return false;
1283
1470
  });
1284
1471
  // The attach snapshot IS the session's history: the runtime's own
1285
1472
  // canonical transcript, replayed as turns.
1286
1473
  const history = [];
1474
+ const authorId = historyParticipant(connection);
1287
1475
  // A hosted harness runtime has no canonical ChatMessage history —
1288
1476
  // `HostedHarnessRuntime::attach` returns an empty one and puts the
1289
1477
  // conversation in `replay`. Read whichever the runtime actually
@@ -1309,13 +1497,13 @@ function registerSessions(context, frontends, participant, granted, handler, con
1309
1497
  canRespond: connection.session.descriptor?.actions.respond !== false,
1310
1498
  });
1311
1499
  if (connection.historyTruncated)
1312
- history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], participant.id, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, participant.id));
1500
+ history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], authorId, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, authorId));
1313
1501
  for (const turn of projected) {
1314
1502
  if (turn.role === 'user') {
1315
- history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], participant.id, []));
1503
+ history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], authorId, []));
1316
1504
  continue;
1317
1505
  }
1318
- history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, participant.id));
1506
+ history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, authorId));
1319
1507
  }
1320
1508
  const busy = connection.session.descriptor?.turn_state === 'busy';
1321
1509
  return {
@@ -55,6 +55,7 @@ export declare class ChatHostControls {
55
55
  readonly baseUrl: string;
56
56
  private readonly credential;
57
57
  state: HostState;
58
+ private updating;
58
59
  private constructor();
59
60
  static fromEnvironment(env?: NodeJS.ProcessEnv): Promise<ChatHostControls | undefined>;
60
61
  refresh(): Promise<void>;
@@ -67,6 +68,9 @@ export declare class ChatHostControls {
67
68
  arguments: string[];
68
69
  env?: Record<string, string>;
69
70
  }>;
71
+ /** Publish complete host replies in request order; a late refresh must not
72
+ * overwrite the connection returned by a newer lifecycle operation. */
73
+ private update;
70
74
  private request;
71
75
  }
72
76
  /** Approval policy is captured for one turn, never inferred from connection privileges. */
@@ -3,6 +3,7 @@ export class ChatHostControls {
3
3
  baseUrl;
4
4
  credential;
5
5
  state;
6
+ updating = Promise.resolve();
6
7
  constructor(baseUrl, credential, state) {
7
8
  this.baseUrl = baseUrl;
8
9
  this.credential = credential;
@@ -19,21 +20,28 @@ export class ChatHostControls {
19
20
  host.state = await host.request('/state');
20
21
  return host;
21
22
  }
22
- async refresh() { this.state = await this.request('/state'); }
23
+ async refresh() { await this.update('/state'); }
23
24
  async select(selection) {
24
- const result = await this.request('/select', selection);
25
- this.state = result;
26
- return result;
25
+ return this.update('/select', selection);
27
26
  }
28
- async remember(id, nativeId) { this.state = await this.request('/remember', { id, nativeId }); }
27
+ async remember(id, nativeId) { await this.update('/remember', { id, nativeId }); }
29
28
  async open(id) {
30
- const result = await this.request('/open', { id });
31
- this.state = result;
32
- return result;
29
+ return this.update('/open', { id });
33
30
  }
34
31
  async setup(kind, harness) {
35
32
  return this.request('/setup', { kind, harness });
36
33
  }
34
+ /** Publish complete host replies in request order; a late refresh must not
35
+ * overwrite the connection returned by a newer lifecycle operation. */
36
+ update(path, selection) {
37
+ const pending = this.updating.then(async () => {
38
+ const result = await this.request(path, selection);
39
+ this.state = result;
40
+ return result;
41
+ });
42
+ this.updating = pending.then(() => undefined, () => undefined);
43
+ return pending;
44
+ }
37
45
  async request(path, selection) {
38
46
  const response = await fetch(this.baseUrl + path, {
39
47
  method: selection ? 'POST' : 'GET', headers: { Authorization: `Bearer ${this.credential}`, 'Content-Type': 'application/json' },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volter/supercode-frontend-vscode",
3
3
  "displayName": "Volter Harness",
4
- "version": "0.1.28",
4
+ "version": "0.1.30",
5
5
  "type": "module",
6
6
  "description": "VS Code chat-view frontend for an existing Volter Harness SDK runtime",
7
7
  "publisher": "volter-ai-dev",