mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Block persistent thread mutations from side chats
This commit is contained in:
parent
d174f3273c
commit
697b748aaf
9 changed files with 122 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue