diff --git a/README.md b/README.md index 4453a565..d0a987a8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ You can also open the plugin page directly: { + const response = await client.request("thread/fork", { + threadId: sourceThreadId, + cwd, + ephemeral: true, + sandbox: "read-only", + approvalPolicy: "never", + excludeTurns: true, + }); + return { + activation: threadActivationSnapshotFromAppServerResponse(response), + sourceThreadId, + }; +} + export async function compactThread(client: ThreadCompactionClient, threadId: string): Promise { await client.request("thread/compact/start", { threadId }); } diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 86c82070..f10cf833 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -66,10 +66,14 @@ function runtimeEventsPlan( localItemId: LocalItemIdProvider, ): ChatNotificationPlan { const plan = planTurnRuntimeEvents(state, turnRuntimeEventsFromNotification(notification, localItemId)); - return { actions: plan.actions, effects: plan.outcomes.flatMap(chatNotificationEffectsFromTurnRuntimeOutcome) }; + return { + actions: plan.actions, + effects: plan.outcomes.flatMap((outcome) => chatNotificationEffectsFromTurnRuntimeOutcome(state, outcome)), + }; } -function chatNotificationEffectsFromTurnRuntimeOutcome(outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] { +function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] { + if (state.activeThread.lifetime?.kind === "ephemeral") return []; switch (outcome.type) { case "turn-started": return [ @@ -162,12 +166,14 @@ function threadStartedPlan( state: ChatState, notification: Extract, ): ChatNotificationPlan { - const effects: ChatNotificationEffect[] = [ - { - type: "apply-thread-catalog-event", - event: { type: "thread-started", thread: threadFromAppServerRecord(notification.params.thread) }, - }, - ]; + const effects: ChatNotificationEffect[] = notification.params.thread.ephemeral + ? [] + : [ + { + type: "apply-thread-catalog-event", + event: { type: "thread-started", thread: threadFromAppServerRecord(notification.params.thread) }, + }, + ]; if (!state.activeThread.id || state.activeThread.id === notification.params.thread.id) { return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects }; } diff --git a/src/features/chat/app-server/transports/session-transports.ts b/src/features/chat/app-server/transports/session-transports.ts index 64afe99a..50ffdcbb 100644 --- a/src/features/chat/app-server/transports/session-transports.ts +++ b/src/features/chat/app-server/transports/session-transports.ts @@ -3,6 +3,8 @@ import type { AppServerRequestClient } from "../../../../app-server/services/req import { clearThreadGoal, compactThread, + deleteThread, + forkEphemeralThread, forkThread, listThreadTurns, readThreadGoal, @@ -18,6 +20,7 @@ import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/serv import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; import type { ThreadTurnsPage } from "../../../../domain/threads/history"; import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport"; +import type { EphemeralThreadTransport } from "../../application/threads/ephemeral-thread-transport"; import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport"; import type { ThreadHistoryPage, @@ -57,6 +60,7 @@ export interface ChatSessionTransports { readonly threadHistory: ThreadHistoryTransport; readonly threadResume: ThreadResumeTransport; readonly threadMutation: ThreadMutationTransport; + readonly threadEphemeral: EphemeralThreadTransport; readonly threadGoalRead: ThreadGoalReadTransport; readonly threadGoal: ThreadGoalTransport; } @@ -69,6 +73,7 @@ export function createChatSessionTransports(host: ChatAppServerTransportHost): C threadHistory: createChatThreadHistoryTransport(host), threadResume: createChatThreadResumeTransport(host), threadMutation: createChatThreadMutationTransport(host), + threadEphemeral: createChatEphemeralThreadTransport(host), threadGoalRead: createChatThreadGoalReadTransport(host), threadGoal: createChatThreadGoalTransport(host), }; @@ -167,6 +172,20 @@ function createChatThreadMutationTransport(host: ChatAppServerTransportHost): Th }; } +function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): EphemeralThreadTransport { + return { + forkEphemeralThread: (sourceThreadId) => + withConnectedChatAppServerClient(host, (client) => forkEphemeralThread(client, sourceThreadId, host.vaultPath)), + deleteEphemeralThread: async (threadId) => { + const result = await withCurrentChatAppServerClient(host, async (client) => { + await deleteThread(client, threadId, { timeoutMs: 5_000 }); + return true; + }); + return result ?? false; + }, + }; +} + function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport { return { readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 7010a569..bb5e5502 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -73,6 +73,7 @@ export const SLASH_COMMANDS = [ detail: "Clip a URL into a vault note and send a wikilink reference with an optional message.", }, { command: "/fork", usage: "/fork", argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread." }, + { command: "/btw", usage: "/btw", argsKind: "none", surface: "panelAction", detail: "Open a temporary side chat." }, { command: "/rollback", usage: "/rollback", diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index 7daf2a02..9d825296 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -140,15 +140,23 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo } async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise { + if (permissionChangesBlocked(host)) return false; dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile }); return applyPendingThreadSettings(host); } async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise { + if (permissionChangesBlocked(host)) return false; dispatch(host, { type: "runtime/permission-profile-reset-to-config" }); return applyPendingThreadSettings(host); } +function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean { + if (state(host).activeThread.lifetime?.kind !== "ephemeral") return false; + host.addSystemMessage("Permission changes are unavailable in side chats."); + return true; +} + async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise { const { snapshot, config } = runtimeProjection(host); await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled"); diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index 9755b39b..e7a76309 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -55,6 +55,9 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti status?: string; listedThreads?: readonly Thread[]; preserveRequestedRuntimeSettings?: boolean; + lifetime?: + | { readonly kind: "persistent" } + | { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null }; } export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState, RuntimePermissionKnownState { @@ -161,12 +164,32 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh sandboxPolicyKnown: response.sandboxPolicyKnown, permissionProfileKnown: response.permissionProfileKnown, ...permissions, + lifetime: { kind: "persistent" }, ...(params.items ? { items: params.items } : {}), ...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}), ...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}), }; } +export function ephemeralThreadActivatedAction( + params: ResumedThreadActionParams & { sourceThreadId: string; sourceThreadTitle: string | null }, +): ActiveThreadResumedAction { + const action = resumedThreadAction({ + response: params.response, + ...(params.items ? { items: params.items } : {}), + ...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}), + ...(params.serviceTierKnown === undefined ? {} : { serviceTierKnown: params.serviceTierKnown }), + }); + return { + ...action, + lifetime: { + kind: "ephemeral", + sourceThreadId: params.sourceThreadId, + sourceThreadTitle: params.sourceThreadTitle, + }, + }; +} + export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettingsAppliedActionSettings): ActiveThreadSettingsAppliedAction { const permissions = runtimePermissionStateOrDefault(settings); return { diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index db418f61..376b025e 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -109,8 +109,13 @@ export interface ChatActiveThreadState { readonly cwd: string | null; readonly goal: ThreadGoal | null; readonly tokenUsage: ThreadTokenUsage | null; + readonly lifetime: ActiveThreadLifetime | null; } +type ActiveThreadLifetime = + | { readonly kind: "persistent" } + | { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null }; + interface ChatComposerState { readonly draft: string; readonly suggestSelected: number; @@ -325,6 +330,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr cwd: action.cwd, goal: null, tokenUsage: null, + lifetime: action.lifetime ?? { kind: "persistent" }, }, runtime: { ...runtimeBase, @@ -649,6 +655,7 @@ function initialActiveThreadState(): ChatActiveThreadState { cwd: null, goal: null, tokenUsage: null, + lifetime: null, }; } diff --git a/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts b/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts new file mode 100644 index 00000000..bd62e1e2 --- /dev/null +++ b/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts @@ -0,0 +1,103 @@ +import { ephemeralThreadActivatedAction } from "../state/actions"; +import type { ChatStateStore } from "../state/store"; +import { activeTurnId, chatTurnBusy } from "../turns/turn-state"; +import type { EphemeralThreadTransport } from "./ephemeral-thread-transport"; + +interface OpenEphemeralThreadInput { + sourceThreadId: string; + sourceThreadTitle: string | null; +} + +export interface EphemeralThreadLifecycle { + open(input: OpenEphemeralThreadInput): Promise; + prepareForPersistentNavigation(): Promise; + dispose(): Promise; +} + +interface EphemeralThreadLifecycleHost { + stateStore: ChatStateStore; + transport: EphemeralThreadTransport; + ensureConnected(): Promise; + addSystemMessage(text: string): void; + notifyActiveThreadIdentityChanged(): void; + interruptTurn(threadId: string, turnId: string): Promise; +} + +export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHost): EphemeralThreadLifecycle { + let disposed = false; + let openGeneration = 0; + const deleteActiveEphemeralThread = async (): Promise => { + const active = host.stateStore.getState().activeThread; + if (active.id && active.lifetime?.kind === "ephemeral") { + return host.transport.deleteEphemeralThread(active.id); + } + return true; + }; + + return { + async open(input): Promise { + const generation = ++openGeneration; + if (!(await host.ensureConnected())) return false; + const snapshot = await host.transport.forkEphemeralThread(input.sourceThreadId); + if (!snapshot) return false; + if (disposed || generation !== openGeneration) { + try { + await host.transport.deleteEphemeralThread(snapshot.activation.thread.id); + } catch { + // A late fork must never become active, even when best-effort cleanup fails. + } + return false; + } + host.stateStore.dispatch( + ephemeralThreadActivatedAction({ + response: snapshot.activation, + sourceThreadId: input.sourceThreadId, + sourceThreadTitle: input.sourceThreadTitle, + }), + ); + host.notifyActiveThreadIdentityChanged(); + return true; + }, + + async prepareForPersistentNavigation(): Promise { + const state = host.stateStore.getState(); + if (state.activeThread.lifetime?.kind !== "ephemeral") return true; + if (chatTurnBusy(state)) { + host.addSystemMessage("Finish or interrupt the current turn before switching threads."); + return false; + } + try { + if (!(await deleteActiveEphemeralThread())) { + host.addSystemMessage("Could not discard the side chat. Try again before switching threads."); + return false; + } + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + return false; + } + host.stateStore.dispatch({ type: "active-thread/cleared" }); + host.notifyActiveThreadIdentityChanged(); + return true; + }, + + async dispose(): Promise { + disposed = true; + openGeneration += 1; + const state = host.stateStore.getState(); + const threadId = state.activeThread.lifetime?.kind === "ephemeral" ? state.activeThread.id : null; + const turnId = activeTurnId(state); + if (threadId && turnId) { + try { + await host.interruptTurn(threadId, turnId); + } catch { + // Continue with deletion when interruption fails. + } + } + try { + await deleteActiveEphemeralThread(); + } catch { + // Ephemeral cleanup must not prevent the panel from closing. + } + }, + }; +} diff --git a/src/features/chat/application/threads/ephemeral-thread-transport.ts b/src/features/chat/application/threads/ephemeral-thread-transport.ts new file mode 100644 index 00000000..19fa4411 --- /dev/null +++ b/src/features/chat/application/threads/ephemeral-thread-transport.ts @@ -0,0 +1,6 @@ +import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; + +export interface EphemeralThreadTransport { + forkEphemeralThread(sourceThreadId: string): Promise<{ activation: ThreadActivationSnapshot; sourceThreadId: string } | null>; + deleteEphemeralThread(threadId: string): Promise; +} diff --git a/src/features/chat/application/threads/thread-navigation-actions.ts b/src/features/chat/application/threads/thread-navigation-actions.ts index 03adf18f..43b090e4 100644 --- a/src/features/chat/application/threads/thread-navigation-actions.ts +++ b/src/features/chat/application/threads/thread-navigation-actions.ts @@ -11,6 +11,7 @@ export interface ThreadNavigationActionsHost { resumeThread: (threadId: string) => Promise; addSystemMessage: (text: string) => void; focusComposer: () => void; + prepareForPersistentNavigation?: () => Promise; } export interface ThreadNavigationActions { @@ -26,6 +27,7 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost) return; } + if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return; host.closeForThreadSelection(); if (await host.focusThreadInOpenView(threadId)) return; await host.resumeThread(threadId); @@ -34,6 +36,7 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost) return { async startNewThread(): Promise { if (chatTurnBusy(host.stateStore.getState())) return; + if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return; host.identity.clearActiveThreadIdentity(); host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); diff --git a/src/features/chat/application/turns/composition.ts b/src/features/chat/application/turns/composition.ts index 446bf451..d53c43d5 100644 --- a/src/features/chat/application/turns/composition.ts +++ b/src/features/chat/application/turns/composition.ts @@ -40,6 +40,7 @@ export interface TurnWorkflowContext { selectThread: (threadId: string) => Promise; notifyIdentityChanged: () => void; resetTurnPresence: (hadTurns: boolean) => void; + openSideChat?: (threadId: string) => Promise; }; composer: { prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput }; @@ -100,6 +101,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu resumeThread: thread.selectThread, threadActions: refs.threadActions, reconnect: refs.reconnectPanel, + ...(thread.openSideChat ? { openSideChat: thread.openSideChat } : {}), runtimeSettings: refs.runtimeSettings, goals: refs.goals, addSystemMessage: status.addSystemMessage, diff --git a/src/features/chat/application/turns/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts index d6e39690..81326a84 100644 --- a/src/features/chat/application/turns/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -36,6 +36,7 @@ export interface SlashCommandExecutionPorts { renameThread: ThreadManagementActions["renameThread"]; }; reconnect: () => Promise; + openSideChat?: (threadId: string) => Promise; addSystemMessage: (text: string) => void; addStructuredSystemMessage: (text: string, details: ThreadStreamNoticeSection[]) => void; runtimeSettings: { @@ -65,6 +66,7 @@ export interface SlashCommandExecutionPorts { export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts { activeThreadId: string | null; + activeThreadEphemeral: boolean; listedThreads: readonly Thread[]; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; @@ -164,6 +166,21 @@ export async function executeSlashCommand( } await context.threadActions.forkThread(context.activeThreadId); return; + case "btw": + if (!context.activeThreadId) { + context.addSystemMessage("No active thread for a side chat."); + return; + } + if (context.activeThreadEphemeral) { + context.addSystemMessage("Side chats cannot be started from another side chat."); + return; + } + if (!context.openSideChat) { + context.addSystemMessage("Side chat is not available."); + return; + } + await context.openSideChat(context.activeThreadId); + return; case "rollback": if (!context.activeThreadId) { context.addSystemMessage("No active thread to roll back."); @@ -219,6 +236,10 @@ export async function executeSlashCommand( case "permissions": { const requested = parsePermissionProfileOverride(args); if (requested !== undefined) { + if (context.activeThreadEphemeral) { + context.addSystemMessage("Permission changes are unavailable in side chats."); + return; + } const applied = await applyPermissionProfileOverride(context, requested); if (applied === false) return; context.addSystemMessage(permissionProfileOverrideMessage(requested)); diff --git a/src/features/chat/application/turns/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts index ad419acd..2e1895fd 100644 --- a/src/features/chat/application/turns/slash-command-executor.ts +++ b/src/features/chat/application/turns/slash-command-executor.ts @@ -35,6 +35,7 @@ export async function executeSlashCommandWithState( return runSlashCommand(command, args, { ...host, activeThreadId: state.activeThreadId, + activeThreadEphemeral: state.activeThreadEphemeral, listedThreads: state.listedThreads, referThread: host.referThread, clipUrl: host.clipUrl, diff --git a/src/features/chat/application/turns/submission-state.ts b/src/features/chat/application/turns/submission-state.ts index 4afcac3e..1b249522 100644 --- a/src/features/chat/application/turns/submission-state.ts +++ b/src/features/chat/application/turns/submission-state.ts @@ -6,6 +6,7 @@ import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } f export interface SubmissionStateSnapshot { activeThreadId: string | null; + activeThreadEphemeral: boolean; activeTurnId: string | null; busy: boolean; listedThreads: readonly Thread[]; @@ -16,6 +17,7 @@ export interface SubmissionStateSnapshot { export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapshot { return { activeThreadId: state.activeThread.id, + activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral", activeTurnId: activeTurnId(state), busy: chatTurnBusy(state), listedThreads: state.threadList.listedThreads, diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index c2e5dae4..21404a75 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -67,6 +67,12 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan toolbarPanel: toolbarPanelActions, rename, navigation, + openSideChat: () => { + const activeThreadId = stateStore.getState().activeThread.id; + if (!activeThreadId) return; + const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === activeThreadId); + void environment.plugin.workspace.openSideChat(activeThreadId, thread?.name ?? thread?.preview ?? null); + }, }); const toolbarSurface: ChatPanelToolbarSurface = { connection: { diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 5a2e6083..855ff1cf 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -99,6 +99,7 @@ interface ChatPanelThreadActionInput { lifecycle: ChatPanelThreadLifecycleBundle; refreshActiveThreads: () => Promise; notifyActiveThreadIdentityChanged: () => void; + prepareForPersistentNavigation: () => Promise; } interface ChatPanelThreadActionBundle { @@ -285,6 +286,7 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP focusComposer: () => { composerController.focusComposer(); }, + prepareForPersistentNavigation: input.prepareForPersistentNavigation, }); return { actions, toolbarPanelActions, navigation }; } diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index 7a979186..39da675a 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -134,6 +134,10 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn resetTurnPresence: (hadTurns) => { autoTitleCoordinator.resetThreadTurnPresence(hadTurns); }, + openSideChat: async (threadId) => { + const source = host.stateStore.getState().threadList.listedThreads.find((thread) => thread.id === threadId); + await host.environment.plugin.workspace.openSideChat(threadId, source?.name ?? source?.preview ?? null); + }, }, composer: { prepareInput: (text, snapshot) => composerController.preparedInput(text, snapshot), diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index 961a898d..275187f1 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -45,6 +45,7 @@ interface WorkspacePanels { focusThreadInOpenView(threadId: string): Promise; openTurnDiff(state: TurnDiffViewState): Promise; refreshThreadsViewLiveState(): void; + openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise; } type ChatThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink; @@ -83,7 +84,7 @@ export interface ChatViewLifecycleSurface { persistedState(): Record; applyViewState(state: unknown): void; open(): void; - close(): void; + close(): Promise; refreshSettings(): void; } @@ -106,6 +107,7 @@ export interface ChatWorkspacePanelSurface { focusComposer(): void; connect(): Promise; startNewThread(): Promise; + openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise; } export interface ChatSharedThreadSurface { diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 284dfe62..a1206012 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -8,6 +8,7 @@ import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import type { ChatAction, ChatConnectionPhase } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ActiveThreadIdentitySync } from "../application/threads/active-thread-identity-sync"; +import { createEphemeralThreadLifecycle, type EphemeralThreadLifecycle } from "../application/threads/ephemeral-thread-lifecycle"; import type { RestorationController } from "../application/threads/restoration-controller"; import type { ResumeActions } from "../application/threads/resume-actions"; import type { ChatResumeWorkTracker } from "../application/threads/resume-work"; @@ -36,6 +37,7 @@ export interface ChatPanelSessionGraph { resume: ResumeActions; restoration: RestorationController; identity: ActiveThreadIdentitySync; + ephemeral: EphemeralThreadLifecycle; }; composer: { controller: ChatComposerController; @@ -162,6 +164,17 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch const composerController = createChatComposerController(host, { runtimeSettings: runtime.settings, }); + const ephemeral = createEphemeralThreadLifecycle({ + stateStore, + transport: appServer.threadEphemeral, + ensureConnected: async () => { + await ensureConnected(); + return connection.isConnected(); + }, + addSystemMessage: status.addSystemMessage, + notifyActiveThreadIdentityChanged, + interruptTurn: (threadId, turnId) => appServer.turn.interruptTurn(threadId, turnId), + }); const threadActions = createThreadActionBundle(host, { appServer, status, @@ -170,6 +183,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch lifecycle: threadLifecycle, refreshActiveThreads, notifyActiveThreadIdentityChanged, + prepareForPersistentNavigation: () => ephemeral.prepareForPersistentNavigation(), }); const reconnect = () => reconnectPanel({ @@ -254,6 +268,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch resume: threadLifecycle.resume, restoration: threadLifecycle.restoration, identity: threadLifecycle.identity, + ephemeral, }, composer: { controller: composerController, diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 5bf45fd6..e6d38d79 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -20,6 +20,7 @@ export class ChatPanelSession implements ChatPanelHandle { private readonly resumeWork = new ChatResumeWorkTracker(); private readonly threadStreamScrollBinding: ChatThreadStreamScrollBinding = createChatThreadStreamScrollBinding(); private observedAppServerContext: AppServerQueryContext; + private ephemeralSourcePlaceholder: { threadId: string; title: string | null } | null = null; private opened = false; private closing = false; @@ -30,10 +31,23 @@ export class ChatPanelSession implements ChatPanelHandle { } displayTitle(): string { + if (this.state.activeThread.lifetime?.kind === "ephemeral" || (!this.state.activeThread.id && this.ephemeralSourcePlaceholder)) { + return "Side chat"; + } return threadWindowTitle(this.panelThreadId(), this.state.threadList.listedThreads, this.restoredThreadTitle()); } persistedState(): Record { + const lifetime = this.state.activeThread.lifetime; + if (lifetime?.kind === "ephemeral") { + return { + version: 2, + ephemeralSource: { threadId: lifetime.sourceThreadId, title: lifetime.sourceThreadTitle }, + }; + } + if (!this.state.activeThread.id && this.ephemeralSourcePlaceholder) { + return { version: 2, ephemeralSource: { ...this.ephemeralSourcePlaceholder } }; + } const threadId = this.panelThreadId(); if (!threadId) return { version: 1 }; @@ -46,6 +60,23 @@ export class ChatPanelSession implements ChatPanelHandle { } applyViewState(state: unknown): void { + const ephemeralSource = parseEphemeralSourceState(state); + if (ephemeralSource) { + this.ephemeralSourcePlaceholder = ephemeralSource; + this.graph.actions.invalidateThreadWork(); + this.graph.thread.restoration.clear(); + this.stateStore.dispatch({ + type: "thread-stream/system-item-added", + item: { + id: "restored-side-chat-unavailable", + kind: "system", + role: "system", + text: "This side conversation is no longer available.", + }, + }); + return; + } + this.ephemeralSourcePlaceholder = null; const restoredThread = parseRestoredThreadState(state); if (restoredThread) { this.graph.thread.restoration.restore(restoredThread); @@ -108,6 +139,8 @@ export class ChatPanelSession implements ChatPanelHandle { } async openThread(threadId: string): Promise { + if (!(await this.graph.thread.ephemeral.prepareForPersistentNavigation())) return; + this.ephemeralSourcePlaceholder = null; await this.graph.thread.resume.resumeThread(threadId); this.focusComposer(); } @@ -151,13 +184,14 @@ export class ChatPanelSession implements ChatPanelHandle { this.scheduleWarmup(); } - close(): void { + async close(): Promise { this.opened = false; this.closing = true; this.graph.connection.actions.invalidate(); this.graph.actions.invalidateThreadWork(); this.deferredTasks.clearAll(); this.graph.runtime.sharedState.unsubscribe(); + await this.graph.thread.ephemeral.dispose(); const panelRoot = this.environment.view.panelRoot(); this.graph.actions.dispose(); unmountChatPanelShell(panelRoot); @@ -176,6 +210,15 @@ export class ChatPanelSession implements ChatPanelHandle { async startNewThread(): Promise { await this.graph.actions.startNewThread(); + if (!this.state.activeThread.id) this.ephemeralSourcePlaceholder = null; + } + + async openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise { + const opened = await this.graph.thread.ephemeral.open(input); + if (!opened) return false; + this.ephemeralSourcePlaceholder = null; + this.focusComposer(); + return true; } private get state(): ChatState { @@ -253,6 +296,16 @@ export class ChatPanelSession implements ChatPanelHandle { } } +function parseEphemeralSourceState(state: unknown): { threadId: string; title: string | null } | null { + if (!state || typeof state !== "object") return null; + const source = (state as { ephemeralSource?: unknown }).ephemeralSource; + if (!source || typeof source !== "object") return null; + const threadId = (source as { threadId?: unknown }).threadId; + const title = (source as { title?: unknown }).title; + if (typeof threadId !== "string" || threadId.length === 0) return null; + return { threadId, title: typeof title === "string" ? title : null }; +} + function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): ChatWorkspacePanelTurnLifecycle { if (state.kind === "running") return { kind: "running", turnId: state.turnId }; if (state.kind === "starting") return { kind: "starting" }; diff --git a/src/features/chat/host/view.obsidian.ts b/src/features/chat/host/view.obsidian.ts index 117ee076..ce68b6de 100644 --- a/src/features/chat/host/view.obsidian.ts +++ b/src/features/chat/host/view.obsidian.ts @@ -65,7 +65,7 @@ export class CodexChatView extends ItemView { } override async onClose(): Promise { - this.surface.close(); + await this.surface.close(); } private refreshTabHeader(): void { diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index c2c7b59b..bc9b330c 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -132,6 +132,8 @@ export interface ChatPanelComposerReadModel { readonly availableModels: ReadonlySignal; }; readonly activeListedThreadName: ReadonlySignal; + readonly sideChatActive: ReadonlySignal; + readonly sideChatSourceTitle: ReadonlySignal; readonly draft: ReadonlySignal; readonly suggestions: ReadonlySignal; readonly selectedSuggestionIndex: ReadonlySignal; @@ -177,7 +179,9 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)), threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)), threadStreamRollbackCandidate: computed(() => (turnBusy.value ? null : threadStreamRollbackCandidateFromItems(streamItems.value))), - threadStreamForkCandidates: computed(() => (turnBusy.value ? [] : forkCandidatesFromItems(streamItems.value))), + threadStreamForkCandidates: computed(() => + turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" ? [] : forkCandidatesFromItems(streamItems.value), + ), threadStreamImplementPlanTarget: computed(() => implementPlanTargetFromState({ activeThread: { id: activeThreadIdSignal.value }, @@ -317,6 +321,11 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel availableModels: computed(() => signals.connection.value.availableModels), }, activeListedThreadName: computed(() => activeListedThreadName(signals)), + sideChatActive: computed(() => signals.activeThread.value.lifetime?.kind === "ephemeral"), + sideChatSourceTitle: computed(() => { + const lifetime = signals.activeThread.value.lifetime; + return lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null; + }), draft: computed(() => signals.composer.value.draft), suggestions: computed(() => signals.composer.value.suggestions), selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected), diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index e8bed7f4..a2053bfd 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -40,7 +40,8 @@ interface RuntimeComposerChoicesInput { requestReasoningEffort: (effort: ReasoningEffort) => void; } -function composerPlaceholder(threadName: string | null): string { +function composerPlaceholder(threadName: string | null, sideChatActive: boolean, sideChatSourceTitle: string | null): string { + if (sideChatActive) return sideChatSourceTitle ? `Ask in side chat for “${sideChatSourceTitle}”...` : "Ask in side chat..."; return threadName ? `Ask Codex in “${threadName}”...` : "Ask Codex..."; } @@ -62,7 +63,11 @@ export function chatPanelComposerProjection( ): ChatPanelComposerProjection { const snapshot = readModel.runtimeSnapshot.value; return { - placeholder: composerPlaceholder(activeComposerThreadName(readModel)), + placeholder: composerPlaceholder( + activeComposerThreadName(readModel), + readModel.sideChatActive.value, + readModel.sideChatSourceTitle.value, + ), meta: { ...composerMetaViewModel(readModel, snapshot), ...runtimeComposerChoices({ diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index b2c2dfca..63aa9c6c 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -33,6 +33,7 @@ export interface ToolbarUiActionDependencies { toolbarPanel: ToolbarPanelActions; rename: ThreadRenameEditorActions; navigation: ThreadNavigationActions; + openSideChat?: () => void; } export interface ToolbarOutsidePointerHit { @@ -134,6 +135,7 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb startNewThread: () => { void deps.navigation.startNewThread(); }, + ...(deps.openSideChat ? { startSideChat: deps.openSideChat } : {}), compactContext: () => { void deps.threadActions.compactActiveThread(); }, diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index 8746dc41..e7a80420 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -55,6 +55,7 @@ interface ToolbarPrimaryActions { interface ToolbarChatActions { startNewThread: () => void; + startSideChat?: () => void; compactContext: () => void; setGoal: () => void; } @@ -102,7 +103,6 @@ export function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: icon="messages-square" label={model.chatActionsOpen ? "Hide chat actions" : "Show chat actions"} className={["codex-panel__new-chat", model.chatActionsOpen ? "is-active" : ""].filter(Boolean).join(" ")} - disabled={model.newChatDisabled} onClick={actions.primary.toggleChatActions} /> @@ -169,6 +169,14 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions className="codex-panel__chat-actions-panel-item" disabled={model.newChatDisabled} /> + { + actions.startSideChat?.(); + }} + className="codex-panel__chat-actions-panel-item" + disabled={model.threads.every((thread) => !thread.selected)} + /> diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 6d48ad34..4dc4d648 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -121,6 +121,7 @@ export class CodexPanelRuntime implements AppServerClientAccess { openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId), focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId), openTurnDiff: (state) => this.openTurnDiff(state), + openSideChat: (sourceThreadId, sourceThreadTitle) => this.panels.openSideChat(sourceThreadId, sourceThreadTitle), refreshThreadsViewLiveState: () => { this.refreshThreadsViewLiveState(); }, diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index ec7a37d2..4432ead7 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -77,11 +77,11 @@ export class WorkspacePanelCoordinator { return view; } - async activateNewView(options: { connect?: boolean } = {}): Promise { + async activateNewView(options: { connect?: boolean; state?: Record } = {}): Promise { const leaf = this.createRightSidebarTab(); if (!leaf) throw new Error("Could not create a right sidebar leaf."); - await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true }); + await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true, ...(options.state ? { state: options.state } : {}) }); await this.options.app.workspace.revealLeaf(leaf); const view = leaf.view as CodexChatView; const surface = workspacePanelSurface(view); @@ -95,6 +95,14 @@ export class WorkspacePanelCoordinator { await workspacePanelSurface(view).openThread(threadId); } + async openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise { + const view = await this.activateNewView({ + connect: false, + state: { version: 2, ephemeralSource: { threadId: sourceThreadId, title: sourceThreadTitle } }, + }); + await workspacePanelSurface(view).openSideChat({ sourceThreadId, sourceThreadTitle }); + } + async openNewPanel(): Promise { await this.activateNewView(); } diff --git a/tests/app-server/threads.test.ts b/tests/app-server/threads.test.ts index 292f38f8..ed5932a1 100644 --- a/tests/app-server/threads.test.ts +++ b/tests/app-server/threads.test.ts @@ -1,9 +1,37 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerRequestClient } from "../../src/app-server/services/request-client"; -import { listThreads, startEphemeralThread, startThread } from "../../src/app-server/services/threads"; +import { forkEphemeralThread, listThreads, startEphemeralThread, startThread } from "../../src/app-server/services/threads"; describe("app-server thread response adapters", () => { + it("forks read-only ephemeral side-chat threads", async () => { + const client = { + request: vi.fn().mockResolvedValue({ + thread: { id: "side", preview: "", name: null, createdAt: 1, updatedAt: 1 }, + cwd: "/vault", + model: "gpt-5.5", + serviceTier: null, + approvalsReviewer: null, + reasoningEffort: null, + approvalPolicy: "never", + sandbox: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, + }), + } as unknown as AppServerRequestClient; + + const result = await forkEphemeralThread(client, "source", "/vault"); + + expect(client.request).toHaveBeenCalledWith("thread/fork", { + threadId: "source", + cwd: "/vault", + ephemeral: true, + sandbox: "read-only", + approvalPolicy: "never", + excludeTurns: true, + }); + expect(result).toMatchObject({ sourceThreadId: "source", activation: { thread: { id: "side" }, cwd: "/vault" } }); + }); + it("starts panel-owned threads with the codex-panel service name", async () => { const client = { request: vi.fn().mockResolvedValue({ thread: { id: "thread-new" } }), diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index 2abbaa72..f5261cf8 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -1420,6 +1420,19 @@ describe("ChatInboundHandler", () => { }); }); + it("keeps ephemeral thread-started notifications out of the shared catalog", () => { + const applyThreadCatalogEvent = vi.fn(); + const handler = handlerForState(chatStateFixture(), { applyThreadCatalogEvent }); + + handler.handleNotification({ + method: "thread/started", + params: { thread: { ...appServerThread("side", "/workspace/active"), ephemeral: true } }, + } satisfies Extract); + + expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); + expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); + }); + it("replaces optimistic user echoes when completed turns are reconciled", () => { let state = activeRunningState(); state = withChatStateThreadStreamItems(state, [ diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 8695b91d..9535b3d9 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -93,6 +93,27 @@ describe("createChatRuntimeSettingsActions", () => { expect(messages).toEqual([]); }); + it("blocks permission profile changes for ephemeral side chats", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + activeThread: { + id: "side", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + const store = createChatStateStore(state); + const transport = settingsTransportFixture(); + const messages: string[] = []; + const actions = runtimeActionsFixture(store, transport, messages); + + await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(false); + await expect(actions.resetPermissionProfileToConfig()).resolves.toBe(false); + + expect(transport.updateThreadSettings).not.toHaveBeenCalled(); + expect(store.getState().runtime.pending.permissionProfile).toEqual({ kind: "unchanged" }); + expect(messages).toEqual(["Permission changes are unavailable in side chats.", "Permission changes are unavailable in side chats."]); + }); + it("reserves thread runtime settings when no thread is active", async () => { const store = createChatStateStore(chatStateFixture()); const transport = settingsTransportFixture(); diff --git a/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts b/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts new file mode 100644 index 00000000..e40ece82 --- /dev/null +++ b/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation"; +import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { createEphemeralThreadLifecycle } from "../../../../../src/features/chat/application/threads/ephemeral-thread-lifecycle"; +import type { EphemeralThreadTransport } from "../../../../../src/features/chat/application/threads/ephemeral-thread-transport"; + +describe("ephemeral thread lifecycle", () => { + it("activates an ephemeral fork without adding it to the thread list", async () => { + const store = createChatStateStore(); + store.dispatch({ + type: "thread-list/applied", + threads: [{ id: "source", preview: "Source", name: null, archived: false, createdAt: 1, updatedAt: 1 }], + }); + const transport = transportMock(); + const lifecycle = createEphemeralThreadLifecycle({ + stateStore: store, + transport, + ensureConnected: vi.fn().mockResolvedValue(true), + addSystemMessage: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + interruptTurn: vi.fn().mockResolvedValue(true), + }); + + await expect(lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: "Source" })).resolves.toBe(true); + + expect(transport.forkEphemeralThread).toHaveBeenCalledWith("source"); + expect(store.getState().activeThread).toMatchObject({ + id: "side", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }); + expect(store.getState().threadList.listedThreads.map((thread) => thread.id)).toEqual(["source"]); + }); + + it("deletes an idle ephemeral thread before persistent navigation", async () => { + const store = createChatStateStore(); + const transport = transportMock(); + const lifecycle = createEphemeralThreadLifecycle({ + stateStore: store, + transport, + ensureConnected: vi.fn().mockResolvedValue(true), + addSystemMessage: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + interruptTurn: vi.fn().mockResolvedValue(true), + }); + await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null }); + + await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(true); + + expect(transport.deleteEphemeralThread).toHaveBeenCalledWith("side"); + expect(store.getState().activeThread.id).toBeNull(); + }); + + it("interrupts a running side turn before close cleanup", async () => { + const store = createChatStateStore(); + const transport = transportMock(); + const interruptTurn = vi.fn().mockResolvedValue(true); + const lifecycle = createEphemeralThreadLifecycle({ + stateStore: store, + transport, + ensureConnected: vi.fn().mockResolvedValue(true), + addSystemMessage: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + interruptTurn, + }); + await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null }); + store.dispatch({ type: "turn/started", threadId: "side", turnId: "turn" }); + + await lifecycle.dispose(); + + expect(interruptTurn).toHaveBeenCalledWith("side", "turn"); + expect(transport.deleteEphemeralThread).toHaveBeenCalledWith("side"); + }); + + it("deletes a fork that resolves after the lifecycle is disposed without activating it", async () => { + const store = createChatStateStore(); + let resolveFork!: (value: Awaited>) => void; + const transport = transportMock(); + transport.forkEphemeralThread = vi.fn( + () => + new Promise<{ activation: ThreadActivationSnapshot; sourceThreadId: string } | null>((resolve) => { + resolveFork = resolve; + }), + ); + const lifecycle = createEphemeralThreadLifecycle({ + stateStore: store, + transport, + ensureConnected: vi.fn().mockResolvedValue(true), + addSystemMessage: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + interruptTurn: vi.fn().mockResolvedValue(true), + }); + + const opening = lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: "Source" }); + await Promise.resolve(); + await lifecycle.dispose(); + resolveFork({ sourceThreadId: "source", activation: activationFixture() }); + + await expect(opening).resolves.toBe(false); + expect(transport.deleteEphemeralThread).toHaveBeenCalledWith("side"); + expect(store.getState().activeThread.id).toBeNull(); + }); + + it("still deletes the side chat when interrupting its running turn fails", async () => { + const store = createChatStateStore(); + const transport = transportMock(); + const lifecycle = createEphemeralThreadLifecycle({ + stateStore: store, + transport, + ensureConnected: vi.fn().mockResolvedValue(true), + addSystemMessage: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + interruptTurn: vi.fn().mockRejectedValue(new Error("interrupt failed")), + }); + await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null }); + store.dispatch({ type: "turn/started", threadId: "side", turnId: "turn" }); + + await lifecycle.dispose(); + + expect(transport.deleteEphemeralThread).toHaveBeenCalledWith("side"); + }); + + it("keeps the side chat active when deletion fails before navigation", async () => { + const store = createChatStateStore(); + const transport = transportMock(); + transport.deleteEphemeralThread = vi.fn().mockResolvedValue(false); + const addSystemMessage = vi.fn(); + const lifecycle = createEphemeralThreadLifecycle({ + stateStore: store, + transport, + ensureConnected: vi.fn().mockResolvedValue(true), + addSystemMessage, + notifyActiveThreadIdentityChanged: vi.fn(), + interruptTurn: vi.fn().mockResolvedValue(true), + }); + await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null }); + + await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(false); + + expect(store.getState().activeThread.id).toBe("side"); + expect(addSystemMessage).toHaveBeenCalledWith("Could not discard the side chat. Try again before switching threads."); + }); +}); + +function transportMock(): EphemeralThreadTransport { + return { + forkEphemeralThread: vi.fn().mockResolvedValue({ sourceThreadId: "source", activation: activationFixture() }), + deleteEphemeralThread: vi.fn().mockResolvedValue(true), + }; +} + +function activationFixture(): ThreadActivationSnapshot { + return { + thread: { id: "side", preview: "", name: null, archived: false, createdAt: 1, updatedAt: 1 }, + cwd: "/vault", + model: "gpt-5.5", + serviceTier: null, + approvalsReviewer: null, + reasoningEffort: null, + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, + }; +} diff --git a/tests/features/chat/application/turns/slash-command-execution.test.ts b/tests/features/chat/application/turns/slash-command-execution.test.ts index b5965cbb..4f0eabec 100644 --- a/tests/features/chat/application/turns/slash-command-execution.test.ts +++ b/tests/features/chat/application/turns/slash-command-execution.test.ts @@ -11,6 +11,7 @@ import { function context(overrides: Partial = {}): SlashCommandExecutionContext { return { activeThreadId: "thread-1", + activeThreadEphemeral: false, listedThreads: [thread({ id: "thread-1", name: "Current" })], startNewThread: vi.fn().mockResolvedValue(undefined), startThreadForGoal: vi.fn().mockResolvedValue("thread-new"), @@ -35,6 +36,7 @@ function context(overrides: Partial = {}): SlashCo renameThread: vi.fn().mockResolvedValue(true), }, reconnect: vi.fn().mockResolvedValue(undefined), + openSideChat: vi.fn().mockResolvedValue(undefined), addSystemMessage: vi.fn(), addStructuredSystemMessage: vi.fn(), runtimeSettings: { @@ -297,6 +299,25 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fork does not take arguments. Usage: /fork"); }); + it("opens a side chat from the active thread", async () => { + const openSideChat = vi.fn().mockResolvedValue(undefined); + const ctx = context({ activeThreadId: "active-thread", openSideChat }); + + await executeSlashCommand("btw", "", ctx); + + expect(openSideChat).toHaveBeenCalledWith("active-thread"); + }); + + it("does not open a nested side chat", async () => { + const openSideChat = vi.fn().mockResolvedValue(undefined); + const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true, openSideChat }); + + await executeSlashCommand("btw", "", ctx); + + expect(openSideChat).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be started from another side chat."); + }); + it("rolls back the active thread for /rollback", async () => { const ctx = context({ activeThreadId: "active-thread" }); @@ -679,6 +700,18 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile reset to default for subsequent turns."); }); + it("blocks permission profile changes in side chats while preserving permission status", async () => { + const ctx = context({ activeThreadEphemeral: true }); + + await executeSlashCommand("permissions", ":workspace", ctx); + await executeSlashCommand("permissions", "", ctx); + + expect(ctx.runtimeSettings.requestPermissionProfile).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission changes are unavailable in side chats."); + expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", ctx.permissionDetails()); + }); + it.each(["reset", "clear", "off"])("treats permission profile alias-like value %s as a profile id", async (profile) => { const ctx = context(); diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 76e5e99a..9c8d52e0 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -331,6 +331,7 @@ describe("createChatPanelSessionGraph actions", () => { openTurnDiff: vi.fn().mockResolvedValue(undefined), refreshThreadsViewLiveState: vi.fn(), ...overrides.plugin?.workspace, + openSideChat: overrides.plugin?.workspace?.openSideChat ?? vi.fn().mockResolvedValue(undefined), }, appServerQueries, threadCatalog, diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index bf7f831b..cdbd4d4c 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -563,6 +563,17 @@ describe("CodexChatView connection lifecycle", () => { expect(requestSaveLayout).toHaveBeenCalledTimes(2); }); + it("turns a restored unavailable side-chat tab into a normal empty chat", async () => { + const view = await chatView(); + await view.setState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } }, {} as never); + + expect(view.getDisplayText()).toBe("Side chat"); + await view.surface.startNewThread(); + + expect(view.getState()).toEqual({ version: 1 }); + expect(view.getDisplayText()).not.toBe("Side chat"); + }); + it("focuses the composer after panel thread actions", async () => { const client = connectedClient(); connectionMock.state.client = client; @@ -1011,6 +1022,7 @@ interface ChatHostFixtureOverrides { focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"]; openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"]; refreshThreadsViewLiveState?: CodexChatHost["workspace"]["refreshThreadsViewLiveState"]; + openSideChat?: CodexChatHost["workspace"]["openSideChat"]; applyThreadCatalogEvent?: CodexChatHost["threadCatalog"]["apply"]; updateAppServerMetadata?: CodexChatHost["appServerQueries"]["updateAppServerMetadata"]; refreshActive?: CodexChatHost["threadCatalog"]["refreshActive"]; @@ -1137,6 +1149,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false), openTurnDiff: overrides.openTurnDiff ?? vi.fn(), refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(), + openSideChat: overrides.openSideChat ?? vi.fn().mockResolvedValue(undefined), }, appServerQueries: { updateAppServerMetadata: diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index ad289179..216b629f 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -50,7 +50,7 @@ describe("chat panel surface projections", () => { expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull(); expect(parent.querySelector(".codex-panel__new-chat")?.tagName).toBe("DIV"); - expect(parent.querySelector(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(true); + expect(parent.querySelector(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(false); expect(parent.querySelector(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe( "Active", ); @@ -401,6 +401,20 @@ describe("chat panel surface projections", () => { activeState = chatStateWith(activeState, { threadList: { listedThreads: [threadFixture("thread-1", "Active")] } }); expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe("Ask Codex in “Active”..."); + activeState = chatStateWith(activeState, { + activeThread: { + lifetime: { kind: "ephemeral", sourceThreadId: "thread-source", sourceThreadTitle: "Source title" }, + }, + }); + expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe( + "Ask in side chat for “Source title”...", + ); + activeState = chatStateWith(activeState, { + activeThread: { + lifetime: { kind: "ephemeral", sourceThreadId: "thread-source", sourceThreadTitle: null }, + }, + }); + expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe("Ask in side chat..."); expect(composerProjectionFromState(composerProjectionActionsFixture(), chatStateFixture()).placeholder).toBe("Ask Codex..."); }); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index be211eba..e2b29851 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -59,7 +59,7 @@ describe("Toolbar decisions", () => { parent.empty(); mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions()); expect(parent.querySelector(".codex-panel__new-chat")?.tagName).toBe("DIV"); - expect(parent.querySelector(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(true); + expect(parent.querySelector(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(false); parent.empty(); mountToolbar(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions()); @@ -76,23 +76,26 @@ describe("Toolbar decisions", () => { const startNewThread = vi.fn(); const compactContext = vi.fn(); const setGoal = vi.fn(); + const startSideChat = vi.fn(); mountToolbar( parent, toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions" }), - toolbarActions({ startNewThread, compactContext, setGoal }), + toolbarActions({ startNewThread, startSideChat, compactContext, setGoal }), ); const items = [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")]; expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.tagName).toBe("DIV"); expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.getAttribute("aria-label")).toBeNull(); - expect(items).toHaveLength(3); - expect(items.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV"]); - expect(items.map((item) => item.getAttribute("role"))).toEqual([null, null, null]); + expect(items).toHaveLength(4); + expect(items.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV", "DIV"]); + expect(items.map((item) => item.getAttribute("role"))).toEqual([null, null, null, null]); items[0]?.click(); items[1]?.click(); items[2]?.click(); + items[3]?.click(); expect(startNewThread).toHaveBeenCalledOnce(); + expect(startSideChat).toHaveBeenCalledOnce(); expect(compactContext).toHaveBeenCalledOnce(); expect(setGoal).toHaveBeenCalledOnce(); }); @@ -420,6 +423,7 @@ function toolbarModel(overrides: Partial = {}): ToolbarViewMod interface ToolbarActionOverrides { toggleHistory?: () => void; startNewThread?: () => void; + startSideChat?: () => void; toggleChatActions?: () => void; compactContext?: () => void; setGoal?: () => void; @@ -446,6 +450,7 @@ function toolbarActions(overrides: ToolbarActionOverrides = {}): ToolbarActions }, chat: { startNewThread: overrides.startNewThread ?? vi.fn(), + startSideChat: overrides.startSideChat ?? vi.fn(), compactContext: overrides.compactContext ?? vi.fn(), setGoal: overrides.setGoal ?? vi.fn(), }, diff --git a/tests/main.test.ts b/tests/main.test.ts index 475fbe9b..83531929 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -466,6 +466,27 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { expect(openThread).not.toHaveBeenCalled(); }); + it("opens side chats as regular Codex panels with ephemeral source metadata", async () => { + const newLeaf = leaf(); + const plugin = await pluginWithLeaves([]); + (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); + const view = chatView(CodexChatView, newLeaf); + newLeaf.setViewState.mockImplementation(async () => { + newLeaf.view = view; + }); + const openSideChat = vi.spyOn(view.surface, "openSideChat").mockResolvedValue(true); + + await panels(plugin).openSideChat("source", "Source thread"); + + expect(newLeaf.setViewState).toHaveBeenCalledWith({ + type: VIEW_TYPE_CODEX_PANEL, + active: true, + state: { version: 2, ephemeralSource: { threadId: "source", title: "Source thread" } }, + }); + expect(openSideChat).toHaveBeenCalledWith({ sourceThreadId: "source", sourceThreadTitle: "Source thread" }); + }); + it("does not refresh shared thread lists after known archive mutations", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); @@ -826,6 +847,7 @@ function chatHostFixture(): CodexChatHost { focusThreadInOpenView: vi.fn(), openTurnDiff: vi.fn(), refreshThreadsViewLiveState: vi.fn(), + openSideChat: vi.fn(), }, appServerQueries: { updateAppServerMetadata: vi.fn(() => null),