diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index fc2244d7..c71a094d 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -35,6 +35,7 @@ export interface ComposerSuggestion { export interface ComposerSuggestionOptions { activeThreadId?: string | null; + activeThreadEphemeral?: boolean; contextReferences?: ComposerContextReferences; dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]); permissionProfiles?: readonly RuntimePermissionProfileSummary[]; @@ -122,7 +123,7 @@ export function activeComposerSuggestions( modelOverrideSuggestions(beforeCursor, models) ?? reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ?? permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ?? - activeSlashCommandSuggestions(beforeCursor) ?? + activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false) ?? activeSkillSuggestions(beforeCursor, skills) ?? [] ); @@ -392,7 +393,9 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand return b.mtime - a.mtime || a.basename.localeCompare(b.basename) || a.path.localeCompare(b.path); } -function activeSlashCommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null { +const SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS = new Set(["/btw", "/fork", "/rollback"]); + +function activeSlashCommandSuggestions(beforeCursor: string, activeThreadEphemeral: boolean): ComposerSuggestion[] | null { const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor); if (match?.index === undefined) return null; @@ -401,7 +404,10 @@ function activeSlashCommandSuggestions(beforeCursor: string): ComposerSuggestion const query = rawQuery.toLowerCase(); if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null; const start = match.index + match[0].lastIndexOf("/"); - return SLASH_COMMANDS.filter((item) => item.command.toLowerCase().startsWith(query)) + return SLASH_COMMANDS.filter( + (item) => + item.command.toLowerCase().startsWith(query) && (!activeThreadEphemeral || !SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS.has(item.command)), + ) .slice(0, 8) .map((item) => ({ display: item.command, diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index ca3be6d6..aa4a0557 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -107,6 +107,11 @@ async function forkThreadFromTurn( turnId: string | null, archiveSource: boolean, ): Promise { + const activeThread = threadManagementState(host).activeThread; + if (activeThread.id === threadId && activeThread.lifetime?.kind === "ephemeral") { + host.addSystemMessage("Side chats cannot be forked."); + return; + } if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage("Finish or interrupt the current turn before forking threads."); return; @@ -167,6 +172,11 @@ async function renameThread(host: ThreadManagementActionsHost, threadId: string, } async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise { + const activeThread = threadManagementState(host).activeThread; + if (activeThread.id === threadId && activeThread.lifetime?.kind === "ephemeral") { + host.addSystemMessage("Side chats cannot be rolled back."); + return; + } if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage("Interrupt the current turn before rolling back."); return; diff --git a/src/features/chat/application/turns/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts index 81326a84..e2f87a44 100644 --- a/src/features/chat/application/turns/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -164,6 +164,10 @@ export async function executeSlashCommand( context.addSystemMessage("No active thread to fork."); return; } + if (context.activeThreadEphemeral) { + context.addSystemMessage("Side chats cannot be forked."); + return; + } await context.threadActions.forkThread(context.activeThreadId); return; case "btw": @@ -186,6 +190,10 @@ export async function executeSlashCommand( context.addSystemMessage("No active thread to roll back."); return; } + if (context.activeThreadEphemeral) { + context.addSystemMessage("Side chats cannot be rolled back."); + return; + } await context.threadActions.rollbackThread(context.activeThreadId); return; case "compact": diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 47f7f0d0..b4ecc4ed 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -248,6 +248,7 @@ export class ChatComposerController { this.options.currentModelForSuggestions(), { activeThreadId: state.activeThread.id, + activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral", contextReferences: this.contextReferences(), dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()), permissionProfiles: state.connection.availablePermissionProfiles, diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index bc9b330c..a0e195b2 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -178,7 +178,11 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C threadStreamItems: streamItems, threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)), threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)), - threadStreamRollbackCandidate: computed(() => (turnBusy.value ? null : threadStreamRollbackCandidateFromItems(streamItems.value))), + threadStreamRollbackCandidate: computed(() => + turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" + ? null + : threadStreamRollbackCandidateFromItems(streamItems.value), + ), threadStreamForkCandidates: computed(() => turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" ? [] : forkCandidatesFromItems(streamItems.value), ), diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index a074eb46..e760424e 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -281,6 +281,16 @@ describe("composer suggestions", () => { expect(activeComposerSuggestions("/help", notes, [])).toEqual([]); }); + it("omits unavailable thread mutations from side-chat slash suggestions", () => { + const options = { activeThreadEphemeral: true }; + + expect(suggestionReplacements(activeComposerSuggestions("/f", notes, [], [], [], null, options))).not.toContain("/fork"); + expect(suggestionReplacements(activeComposerSuggestions("/r", notes, [], [], [], null, options))).not.toContain("/rollback"); + expect(suggestionReplacements(activeComposerSuggestions("/b", notes, [], [], [], null, options))).not.toContain("/btw"); + expect(suggestionReplacements(activeComposerSuggestions("/c", notes, [], [], [], null, options))).toContain("/compact"); + expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).toContain("/goal"); + }); + it("bounds selection preview text before rendering it in the suggestion list", () => { const suggestion = activeComposerSuggestions("@sel", notes, [], [], [], null, { contextReferences: { diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index e5e8a47b..7c20c1d4 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -14,7 +14,7 @@ import type { } from "../../../../../src/features/chat/application/threads/thread-mutation-transport"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { deferred, waitForAsyncWork } from "../../../../support/async"; -import { chatStateFixture } from "../../support/state"; +import { chatStateFixture, chatStateWith } from "../../support/state"; import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; interface ThreadMutationTransportMock { @@ -54,6 +54,22 @@ type ThreadManagementActionsHostMock = Omit< }; describe("thread management actions", () => { + it("does not fork an ephemeral side chat", async () => { + const host = hostMock({ + items: [], + activeThread: { + id: "side-thread", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + const controller = threadManagementActions(host); + + await controller.forkThread("side-thread"); + + expect(host.threadTransport.forkThread).not.toHaveBeenCalled(); + expect(host.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be forked."); + }); + it("requests thread compaction and reports the shared status", async () => { const host = hostMock({ items: [] }); const controller = threadManagementActions(host); @@ -363,6 +379,22 @@ describe("thread management actions", () => { expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.refreshAfterThreadMutation)); }); + it("does not roll back an ephemeral side chat", async () => { + const host = hostMock({ + items: turnItems(), + activeThread: { + id: "side-thread", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + const controller = threadManagementActions(host); + + await controller.rollbackThread("side-thread"); + + expect(host.threadTransport.rollbackThread).not.toHaveBeenCalled(); + expect(host.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be rolled back."); + }); + it("ignores stale rollback responses after the panel switches threads", async () => { const rollback = deferred(); const host = hostMock({ items: turnItems() }); @@ -512,14 +544,17 @@ function threadManagementActions(host: ThreadManagementActionsHost): ThreadManag function hostMock({ items, + activeThread, operations: operationOverrides = {}, threadTransport: transportOverrides = {}, }: { items: ThreadStreamItem[]; + activeThread?: Partial["activeThread"]>; operations?: Partial; threadTransport?: Partial; }): ThreadManagementActionsHostMock { - const state = withChatStateThreadStreamItems(chatStateFixture(), items); + let state = withChatStateThreadStreamItems(chatStateFixture(), items); + if (activeThread) state = chatStateWith(state, { activeThread }); const stateStore = createChatStateStore(state); const threadTransport: ThreadMutationTransportMock = { compactThread: vi.fn().mockResolvedValue(true), 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 4f0eabec..36c4ef9e 100644 --- a/tests/features/chat/application/turns/slash-command-execution.test.ts +++ b/tests/features/chat/application/turns/slash-command-execution.test.ts @@ -299,6 +299,15 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fork does not take arguments. Usage: /fork"); }); + it("does not fork a side chat", async () => { + const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true }); + + await executeSlashCommand("fork", "", ctx); + + expect(ctx.threadActions.forkThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be forked."); + }); + it("opens a side chat from the active thread", async () => { const openSideChat = vi.fn().mockResolvedValue(undefined); const ctx = context({ activeThreadId: "active-thread", openSideChat }); @@ -326,6 +335,15 @@ describe("slash commands", () => { expect(ctx.threadActions.rollbackThread).toHaveBeenCalledWith("active-thread"); }); + it("does not roll back a side chat", async () => { + const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true }); + + await executeSlashCommand("rollback", "", ctx); + + expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be rolled back."); + }); + it("rejects /rollback without an active thread", async () => { const ctx = context({ activeThreadId: null }); diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 216b629f..62566c90 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -25,6 +25,30 @@ import { withChatStateThreadStreamItems } from "../../support/thread-stream"; installObsidianDomShims(); describe("chat panel surface projections", () => { + it("does not project rollback actions for side chats", () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + activeThread: { + id: "side-thread", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + state = withChatStateThreadStreamItems(state, [ + { id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "Question", turnId: "turn" }, + { + id: "assistant", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", + role: "assistant", + text: "Answer", + turnId: "turn", + }, + ]); + + expect(createChatPanelShellReadModelBinding(state).readModel.threadStream.rollbackCandidate.value).toBeNull(); + }); + it("builds toolbar rows from immutable chat state snapshots", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-1" } });