Block persistent thread mutations from side chats

This commit is contained in:
murashit 2026-07-11 23:36:42 +09:00
parent d174f3273c
commit 697b748aaf
9 changed files with 122 additions and 6 deletions

View file

@ -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,

View file

@ -107,6 +107,11 @@ async function forkThreadFromTurn(
turnId: string | null,
archiveSource: boolean,
): Promise<void> {
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<void> {
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;

View file

@ -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":

View file

@ -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,

View file

@ -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),
),

View file

@ -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: {

View file

@ -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<ThreadRollbackSnapshot | null>();
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<ReturnType<typeof chatStateFixture>["activeThread"]>;
operations?: Partial<ThreadOperationsMock>;
threadTransport?: Partial<ThreadMutationTransportMock>;
}): 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<ThreadMutationTransport["compactThread"]>().mockResolvedValue(true),

View file

@ -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 });

View file

@ -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" } });