mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add ephemeral side chats in new Codex panels
This commit is contained in:
parent
bcb9759a91
commit
d174f3273c
38 changed files with 683 additions and 25 deletions
|
|
@ -42,7 +42,7 @@ You can also open the plugin page directly: <https://community.obsidian.md/plugi
|
|||
|
||||
## Using Codex Panel
|
||||
|
||||
Each panel can keep a separate conversation, so related work can stay open side by side. Start fresh from the panel, reopen recent threads from the picker or panel history, or use the Threads view to see active work across the vault.
|
||||
Each panel can keep a separate conversation, so related work can stay open side by side, including temporary read-only side chats. Start fresh from the panel, reopen recent threads from the picker or panel history, or use the Threads view to see active work across the vault.
|
||||
|
||||
The composer treats Obsidian content as prompt context. It suggests vault files and recent notes for wikilinks, keeps links readable while attaching resolved files, and opens file links from Codex replies back in Obsidian. Use `@active` or `@selection` to include the active file or current Markdown selection without pasting it manually, or enable **Reference active file on send** to reference the current active file with each composer send. When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` insert wikilinks using that daily-note folder and date format. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
|
||||
|
||||
|
|
|
|||
|
|
@ -228,6 +228,30 @@ export async function forkThread(
|
|||
return threadFromThreadRecord(response.thread);
|
||||
}
|
||||
|
||||
export interface EphemeralThreadForkSnapshot {
|
||||
readonly activation: ThreadActivationSnapshot;
|
||||
readonly sourceThreadId: string;
|
||||
}
|
||||
|
||||
export async function forkEphemeralThread(
|
||||
client: ThreadForkClient,
|
||||
sourceThreadId: string,
|
||||
cwd: string,
|
||||
): Promise<EphemeralThreadForkSnapshot> {
|
||||
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<void> {
|
||||
await client.request("thread/compact/start", { threadId });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ThreadLifecycleNotification, { method: "thread/started" }>,
|
||||
): 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -140,15 +140,23 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo
|
|||
}
|
||||
|
||||
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
|
||||
if (permissionChangesBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
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<void> {
|
||||
const { snapshot, config } = runtimeProjection(host);
|
||||
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
prepareForPersistentNavigation(): Promise<boolean>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
interface EphemeralThreadLifecycleHost {
|
||||
stateStore: ChatStateStore;
|
||||
transport: EphemeralThreadTransport;
|
||||
ensureConnected(): Promise<boolean>;
|
||||
addSystemMessage(text: string): void;
|
||||
notifyActiveThreadIdentityChanged(): void;
|
||||
interruptTurn(threadId: string, turnId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHost): EphemeralThreadLifecycle {
|
||||
let disposed = false;
|
||||
let openGeneration = 0;
|
||||
const deleteActiveEphemeralThread = async (): Promise<boolean> => {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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<boolean>;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export interface ThreadNavigationActionsHost {
|
|||
resumeThread: (threadId: string) => Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
focusComposer: () => void;
|
||||
prepareForPersistentNavigation?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface TurnWorkflowContext {
|
|||
selectThread: (threadId: string) => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
resetTurnPresence: (hadTurns: boolean) => void;
|
||||
openSideChat?: (threadId: string) => Promise<void>;
|
||||
};
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface SlashCommandExecutionPorts {
|
|||
renameThread: ThreadManagementActions["renameThread"];
|
||||
};
|
||||
reconnect: () => Promise<void>;
|
||||
openSideChat?: (threadId: string) => Promise<void>;
|
||||
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<ThreadReferenceInput | null>;
|
||||
clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ClipUrlInput | null>;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ interface ChatPanelThreadActionInput {
|
|||
lifecycle: ChatPanelThreadLifecycleBundle;
|
||||
refreshActiveThreads: () => Promise<void>;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
prepareForPersistentNavigation: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface ChatPanelThreadActionBundle {
|
||||
|
|
@ -285,6 +286,7 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
|
|||
focusComposer: () => {
|
||||
composerController.focusComposer();
|
||||
},
|
||||
prepareForPersistentNavigation: input.prepareForPersistentNavigation,
|
||||
});
|
||||
return { actions, toolbarPanelActions, navigation };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ interface WorkspacePanels {
|
|||
focusThreadInOpenView(threadId: string): Promise<boolean>;
|
||||
openTurnDiff(state: TurnDiffViewState): Promise<void>;
|
||||
refreshThreadsViewLiveState(): void;
|
||||
openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
type ChatThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink;
|
||||
|
|
@ -83,7 +84,7 @@ export interface ChatViewLifecycleSurface {
|
|||
persistedState(): Record<string, unknown>;
|
||||
applyViewState(state: unknown): void;
|
||||
open(): void;
|
||||
close(): void;
|
||||
close(): Promise<void>;
|
||||
refreshSettings(): void;
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +107,7 @@ export interface ChatWorkspacePanelSurface {
|
|||
focusComposer(): void;
|
||||
connect(): Promise<void>;
|
||||
startNewThread(): Promise<void>;
|
||||
openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ChatSharedThreadSurface {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.graph.actions.startNewThread();
|
||||
if (!this.state.activeThread.id) this.ephemeralSourcePlaceholder = null;
|
||||
}
|
||||
|
||||
async openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise<boolean> {
|
||||
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" };
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
override async onClose(): Promise<void> {
|
||||
this.surface.close();
|
||||
await this.surface.close();
|
||||
}
|
||||
|
||||
private refreshTabHeader(): void {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ export interface ChatPanelComposerReadModel {
|
|||
readonly availableModels: ReadonlySignal<ChatPanelComposerConnectionState["availableModels"]>;
|
||||
};
|
||||
readonly activeListedThreadName: ReadonlySignal<string | null>;
|
||||
readonly sideChatActive: ReadonlySignal<boolean>;
|
||||
readonly sideChatSourceTitle: ReadonlySignal<string | null>;
|
||||
readonly draft: ReadonlySignal<ChatState["composer"]["draft"]>;
|
||||
readonly suggestions: ReadonlySignal<ChatState["composer"]["suggestions"]>;
|
||||
readonly selectedSuggestionIndex: ReadonlySignal<ChatState["composer"]["suggestSelected"]>;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<StatusButton model={model} actions={actions.primary} />
|
||||
|
|
@ -169,6 +169,14 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions
|
|||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.newChatDisabled}
|
||||
/>
|
||||
<ToolbarPanelItem
|
||||
label="Start side chat"
|
||||
onClick={() => {
|
||||
actions.startSideChat?.();
|
||||
}}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.threads.every((thread) => !thread.selected)}
|
||||
/>
|
||||
<ToolbarPanelItem label="Compact context" onClick={actions.compactContext} className="codex-panel__chat-actions-panel-item" />
|
||||
<ToolbarPanelItem label="Set goal..." onClick={actions.setGoal} className="codex-panel__chat-actions-panel-item" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -77,11 +77,11 @@ export class WorkspacePanelCoordinator {
|
|||
return view;
|
||||
}
|
||||
|
||||
async activateNewView(options: { connect?: boolean } = {}): Promise<CodexChatView> {
|
||||
async activateNewView(options: { connect?: boolean; state?: Record<string, unknown> } = {}): Promise<CodexChatView> {
|
||||
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<void> {
|
||||
const view = await this.activateNewView({
|
||||
connect: false,
|
||||
state: { version: 2, ephemeralSource: { threadId: sourceThreadId, title: sourceThreadTitle } },
|
||||
});
|
||||
await workspacePanelSurface(view).openSideChat({ sourceThreadId, sourceThreadTitle });
|
||||
}
|
||||
|
||||
async openNewPanel(): Promise<void> {
|
||||
await this.activateNewView();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" } }),
|
||||
|
|
|
|||
|
|
@ -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<ServerNotification, { method: "thread/started" }>);
|
||||
|
||||
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, [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<EphemeralThreadTransport["forkEphemeralThread"]>>) => 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
function context(overrides: Partial<SlashCommandExecutionContext> = {}): 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<SlashCommandExecutionContext> = {}): 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ describe("chat panel surface projections", () => {
|
|||
|
||||
expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull();
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.tagName).toBe("DIV");
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(true);
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(false);
|
||||
expect(parent.querySelector<HTMLInputElement>(".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...");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe("Toolbar decisions", () => {
|
|||
parent.empty();
|
||||
mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.tagName).toBe("DIV");
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(true);
|
||||
expect(parent.querySelector<HTMLElement>(".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<HTMLElement>(".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<ToolbarViewModel> = {}): 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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>).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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue