mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(lifecycle): preserve identity across context changes
This commit is contained in:
parent
d525d97f9e
commit
760bdf3016
18 changed files with 231 additions and 35 deletions
|
|
@ -117,7 +117,12 @@ export interface ChatActiveThreadState {
|
|||
|
||||
type ChatPanelThreadState =
|
||||
| { readonly kind: "empty" }
|
||||
| { readonly kind: "awaiting-resume"; readonly threadId: string; readonly fallbackTitle: string | null }
|
||||
| {
|
||||
readonly kind: "awaiting-resume";
|
||||
readonly threadId: string;
|
||||
readonly fallbackTitle: string | null;
|
||||
readonly provenance: Thread["provenance"] | null;
|
||||
}
|
||||
| { readonly kind: "active"; readonly thread: ChatActiveThreadState };
|
||||
|
||||
type ActiveThreadLifetime =
|
||||
|
|
@ -292,6 +297,11 @@ export function awaitingResumeThreadState(state: ChatState): Extract<ChatState["
|
|||
return state.panelThread.kind === "awaiting-resume" ? state.panelThread : null;
|
||||
}
|
||||
|
||||
export function panelThreadProvenance(state: ChatState): ChatActiveThreadState["provenance"] {
|
||||
if (state.panelThread.kind === "active") return state.panelThread.thread.provenance;
|
||||
return state.panelThread.kind === "awaiting-resume" ? state.panelThread.provenance : null;
|
||||
}
|
||||
|
||||
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
switch (action.type) {
|
||||
case "connection/scoped-cleared":
|
||||
|
|
@ -667,7 +677,7 @@ function clearConnectionContextState(state: ChatState): ChatState {
|
|||
function panelThreadAfterConnectionExit(panelThread: ChatPanelThreadState): ChatPanelThreadState {
|
||||
if (panelThread.kind === "awaiting-resume") return panelThread;
|
||||
if (panelThread.kind !== "active" || panelThread.thread.lifetime?.kind === "ephemeral") return initialPanelThreadState();
|
||||
return createAwaitingResumeThreadState(panelThread.thread.id, panelThread.thread.title ?? null);
|
||||
return createAwaitingResumeThreadState(panelThread.thread.id, panelThread.thread.title ?? null, panelThread.thread.provenance);
|
||||
}
|
||||
|
||||
function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState {
|
||||
|
|
@ -805,11 +815,16 @@ function initialPanelThreadState(): ChatPanelThreadState {
|
|||
return { kind: "empty" };
|
||||
}
|
||||
|
||||
function createAwaitingResumeThreadState(threadId: string, fallbackTitle: string | null): ChatPanelThreadState {
|
||||
function createAwaitingResumeThreadState(
|
||||
threadId: string,
|
||||
fallbackTitle: string | null,
|
||||
provenance: Thread["provenance"] | null = null,
|
||||
): ChatPanelThreadState {
|
||||
return {
|
||||
kind: "awaiting-resume",
|
||||
threadId,
|
||||
fallbackTitle,
|
||||
provenance,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import { activeThreadState, type ChatState } from "../state/root-reducer";
|
||||
import { activeThreadState, type ChatState, panelThreadProvenance } from "../state/root-reducer";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state";
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
|
|||
return {
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
activeTurnId: activeTurnId(state),
|
||||
busy: chatTurnBusy(state),
|
||||
listedThreads: state.threadList.listedThreads,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
|
||||
import type { PendingRequestActions } from "../../application/pending-requests/pending-request-actions";
|
||||
import { activeThreadId, activeThreadState } from "../../application/state/root-reducer";
|
||||
import { activeThreadId, panelThreadProvenance } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
import type { HistoryController } from "../../application/threads/history-controller";
|
||||
import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions";
|
||||
|
|
@ -74,7 +74,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||
void environment.plugin.workspace.openSideChat(threadId, thread?.name ?? thread?.preview ?? null);
|
||||
},
|
||||
activeThreadChatActionsDisabled: () => activeThreadState(stateStore.getState())?.provenance?.kind === "subagent",
|
||||
activeThreadChatActionsDisabled: () => panelThreadProvenance(stateStore.getState())?.kind === "subagent",
|
||||
});
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
connection: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import { appServerQueryContextIdentity, appServerQueryContextIdentityMatches } from "../../../../app-server/query/keys";
|
||||
import { recoverRolloutTokenUsage } from "../../../../app-server/services/rollout-token-usage";
|
||||
import { createThreadOperationsTransport, createThreadTitleTransport } from "../../../threads/app-server/workflow-transports";
|
||||
import { createThreadOperations, type ThreadOperations } from "../../../threads/workflows/thread-operations";
|
||||
|
|
@ -128,6 +129,19 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
|
|||
const threadOperations = createThreadOperations({
|
||||
transport: threadOperationsTransport,
|
||||
nameMutations: environment.plugin.threadNameMutations,
|
||||
resourceContext: {
|
||||
capture: () => appServerQueryContextIdentity(environment.plugin.appServerQueries.contextLease()),
|
||||
isCurrent: (context) => {
|
||||
try {
|
||||
return appServerQueryContextIdentityMatches(
|
||||
context,
|
||||
appServerQueryContextIdentity(environment.plugin.appServerQueries.contextLease()),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
archiveExport: {
|
||||
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(),
|
||||
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@ import {
|
|||
} from "../../../app-server/query/keys";
|
||||
import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate";
|
||||
import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title";
|
||||
import {
|
||||
activeThreadId,
|
||||
activeThreadState,
|
||||
awaitingResumeThreadState,
|
||||
type ChatState,
|
||||
panelThreadId,
|
||||
} from "../application/state/root-reducer";
|
||||
import { activeThreadState, awaitingResumeThreadState, type ChatState, panelThreadId } from "../application/state/root-reducer";
|
||||
import { type ChatStateStore, createChatStateStore } from "../application/state/store";
|
||||
import { ChatResumeWorkTracker } from "../application/threads/resume-work";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom";
|
||||
|
|
@ -32,7 +26,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
private observedAppServerContext: AppServerQueryContextIdentity;
|
||||
private appServerContextReconnectAttemptGeneration = 0;
|
||||
private appServerContextReplacementGeneration = 0;
|
||||
private pendingAppServerContextReplacement: { activeThreadId: string | null; generation: number } | null = null;
|
||||
private pendingAppServerContextReplacement: { panelThreadId: string | null; generation: number } | null = null;
|
||||
private opened = false;
|
||||
private closing = false;
|
||||
|
||||
|
|
@ -90,7 +84,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
const nextContext = this.currentAppServerContext();
|
||||
if (!appServerQueryContextIdentityMatches(this.observedAppServerContext, nextContext)) {
|
||||
this.observedAppServerContext = nextContext;
|
||||
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(activeThreadId(this.state));
|
||||
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(panelThreadId(this.state));
|
||||
void this.reconnectAfterAppServerContextChange(replacement);
|
||||
this.runtime.runtime.sharedState.applyCached();
|
||||
}
|
||||
|
|
@ -98,25 +92,25 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
}
|
||||
|
||||
prepareAppServerContextChange(): void {
|
||||
const threadId = this.pendingAppServerContextReplacement?.activeThreadId ?? activeThreadId(this.state);
|
||||
const threadId = this.pendingAppServerContextReplacement?.panelThreadId ?? panelThreadId(this.state);
|
||||
this.captureAppServerContextReplacement(threadId);
|
||||
this.runtime.actions.prepareAppServerContextChange();
|
||||
}
|
||||
|
||||
private captureAppServerContextReplacement(activeThreadId: string | null): {
|
||||
activeThreadId: string | null;
|
||||
private captureAppServerContextReplacement(panelThreadId: string | null): {
|
||||
panelThreadId: string | null;
|
||||
generation: number;
|
||||
} {
|
||||
const replacement = { activeThreadId, generation: ++this.appServerContextReplacementGeneration };
|
||||
const replacement = { panelThreadId, generation: ++this.appServerContextReplacementGeneration };
|
||||
this.pendingAppServerContextReplacement = replacement;
|
||||
return replacement;
|
||||
}
|
||||
|
||||
private async reconnectAfterAppServerContextChange(replacement: { activeThreadId: string | null; generation: number }): Promise<void> {
|
||||
private async reconnectAfterAppServerContextChange(replacement: { panelThreadId: string | null; generation: number }): Promise<void> {
|
||||
const attemptGeneration = ++this.appServerContextReconnectAttemptGeneration;
|
||||
try {
|
||||
const resumed = await this.runtime.actions.reconnectAfterAppServerContextChange(
|
||||
replacement.activeThreadId,
|
||||
replacement.panelThreadId,
|
||||
() =>
|
||||
this.pendingAppServerContextReplacement?.generation === replacement.generation &&
|
||||
this.appServerContextReconnectAttemptGeneration === attemptGeneration,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
type PreparedComposerInput,
|
||||
preparedUserInputWithWikiLinkMentionsSkillsAndContext,
|
||||
} from "../application/composer/wikilink-context";
|
||||
import { activeThreadState, type ChatAction, type ChatState } from "../application/state/root-reducer";
|
||||
import { activeThreadState, type ChatAction, type ChatState, panelThreadProvenance } from "../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer";
|
||||
import { syncComposerHeight } from "../ui/composer.dom";
|
||||
|
|
@ -298,7 +298,7 @@ export class ChatComposerController {
|
|||
{
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
contextReferences: this.contextReferences(),
|
||||
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { explicitThreadName } from "../../../domain/threads/model";
|
||||
import { threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot";
|
||||
import { activeThreadState, type ChatActiveThreadState, type ChatState } from "../application/state/root-reducer";
|
||||
import { activeThreadState, type ChatActiveThreadState, type ChatState, panelThreadProvenance } from "../application/state/root-reducer";
|
||||
import { threadStreamItems } from "../application/state/thread-stream";
|
||||
import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state";
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel
|
|||
return {
|
||||
threads: state.threadList.listedThreads,
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
|
||||
turnBusy: chatTurnBusy(state),
|
||||
connection: state.connection,
|
||||
|
|
@ -95,7 +95,7 @@ export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadSt
|
|||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadCwd: activeThread?.cwd ?? null,
|
||||
activeThreadLifetime: activeThread?.lifetime ?? null,
|
||||
activeThreadProvenance: activeThread?.provenance ?? null,
|
||||
activeThreadProvenance: panelThreadProvenance(state),
|
||||
turn: state.turn,
|
||||
runtimeCollaborationMode: state.runtime.pending.collaborationMode,
|
||||
threadStream: state.threadStream,
|
||||
|
|
@ -127,7 +127,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
|
|||
selectedSuggestionIndex: state.composer.suggestSelected,
|
||||
activeThreadId,
|
||||
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
|
||||
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
webSubmissionPending: state.pendingSubmission !== null,
|
||||
webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable",
|
||||
turnBusy: chatTurnBusy(state),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import { type AppServerQueryContext, appServerQueryContextRawEquals } from "../../app-server/query/keys";
|
||||
import {
|
||||
type AppServerContextLease,
|
||||
type AppServerQueryContext,
|
||||
appServerQueryContextIdentity,
|
||||
appServerQueryContextIdentityMatches,
|
||||
appServerQueryContextRawEquals,
|
||||
} from "../../app-server/query/keys";
|
||||
import type { ObservedResult } from "../../app-server/query/observed-result";
|
||||
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
|
||||
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
|
||||
|
|
@ -21,6 +27,7 @@ import { type ThreadsRenameState, type ThreadsViewPanelActivity, threadRows, tra
|
|||
export interface ThreadsViewHost {
|
||||
readonly settings: ThreadsViewSettingsAccess;
|
||||
readonly vaultPath: string;
|
||||
appServerContextLease(): AppServerContextLease;
|
||||
readonly threadCatalog: ThreadsViewThreadCatalog;
|
||||
readonly threadNameMutations: ThreadNameMutationCoordinator;
|
||||
readonly threadOperationsTransport: ThreadOperationsTransport;
|
||||
|
|
@ -84,6 +91,16 @@ export class ThreadsViewSession {
|
|||
this.operations = createThreadOperations({
|
||||
transport: this.host.threadOperationsTransport,
|
||||
nameMutations: this.host.threadNameMutations,
|
||||
resourceContext: {
|
||||
capture: () => appServerQueryContextIdentity(this.host.appServerContextLease()),
|
||||
isCurrent: (context) => {
|
||||
try {
|
||||
return appServerQueryContextIdentityMatches(context, appServerQueryContextIdentity(this.host.appServerContextLease()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
archiveExport: {
|
||||
settings: () => this.host.settings.archiveExportSettings(),
|
||||
enabled: () => this.host.settings.archiveExportEnabled(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { AppServerQueryContextIdentity } from "../../../app-server/query/keys";
|
||||
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
|
||||
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
|
||||
import type { ThreadCatalogEventSink } from "../catalog/thread-catalog";
|
||||
|
|
@ -8,6 +9,10 @@ import type { ThreadNameMutationCoordinator } from "./thread-name-mutation-coord
|
|||
export interface ThreadOperationsHost {
|
||||
transport: ThreadOperationsTransport;
|
||||
nameMutations: ThreadNameMutationCoordinator;
|
||||
resourceContext: {
|
||||
capture(): AppServerQueryContextIdentity;
|
||||
isCurrent(context: AppServerQueryContextIdentity): boolean;
|
||||
};
|
||||
archiveExport: {
|
||||
settings(): ArchiveExportSettings;
|
||||
enabled(): boolean;
|
||||
|
|
@ -53,11 +58,12 @@ async function renameThread(
|
|||
): Promise<boolean> {
|
||||
const name = normalizeExplicitThreadName(value);
|
||||
if (!name) return false;
|
||||
const context = Object.freeze({ ...host.resourceContext.capture() });
|
||||
|
||||
return host.nameMutations.run(threadId, async () => {
|
||||
if (!(options.shouldStart?.() ?? true)) return false;
|
||||
if (!host.resourceContext.isCurrent(context) || !(options.shouldStart?.() ?? true)) return false;
|
||||
await host.transport.renameThread(threadId, name);
|
||||
if (options.shouldPublish?.() ?? true) {
|
||||
if (host.resourceContext.isCurrent(context) && (options.shouldPublish?.() ?? true)) {
|
||||
host.catalog.apply({ type: "thread-renamed", threadId, name });
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
return {
|
||||
settings: this.threadsSettings(),
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
appServerContextLease: () => this.appServerResourceStore.contextLease(),
|
||||
threadCatalog: this.threadCatalog,
|
||||
threadNameMutations: this.threadNameMutations,
|
||||
threadOperationsTransport: createThreadOperationsTransport(this),
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ describe("reconnectPanel", () => {
|
|||
kind: "awaiting-resume",
|
||||
threadId: thread.id,
|
||||
fallbackTitle: thread.name,
|
||||
provenance: thread.provenance,
|
||||
});
|
||||
|
||||
await expect(reconnectPanel(host)).resolves.toBe(true);
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ describe("chatReducer", () => {
|
|||
kind: "awaiting-resume",
|
||||
threadId: "active-thread",
|
||||
fallbackTitle: "Reconnect me",
|
||||
provenance,
|
||||
});
|
||||
expect(threadStreamItems(disconnected.threadStream)).toEqual([dialogueItem("retained-item")]);
|
||||
});
|
||||
|
|
@ -234,6 +235,7 @@ describe("chatReducer", () => {
|
|||
kind: "awaiting-resume",
|
||||
threadId: "restored-thread",
|
||||
fallbackTitle: "Restored title",
|
||||
provenance: null,
|
||||
});
|
||||
expect(restored.connection.statusText).toBe("Thread ready to resume.");
|
||||
expectThreadScopeReset(restored, { items: [] });
|
||||
|
|
|
|||
|
|
@ -122,7 +122,12 @@ describe("createActiveThreadIdentitySync", () => {
|
|||
|
||||
sync.applyThreadRenameToActiveIdentity("thread", "New");
|
||||
|
||||
expect(stateStore.getState().panelThread).toEqual({ kind: "awaiting-resume", threadId: "thread", fallbackTitle: "New" });
|
||||
expect(stateStore.getState().panelThread).toEqual({
|
||||
kind: "awaiting-resume",
|
||||
threadId: "thread",
|
||||
fallbackTitle: "New",
|
||||
provenance: null,
|
||||
});
|
||||
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,10 @@ function coordinatorFixture(
|
|||
withClient: async (operation) => operation(currentClient()),
|
||||
}),
|
||||
nameMutations: createThreadNameMutationCoordinator(),
|
||||
resourceContext: {
|
||||
capture: () => ({ codexPath: "codex", vaultPath: "/vault", generation: 1 }),
|
||||
isCurrent: (context) => context.codexPath === "codex" && context.vaultPath === "/vault" && context.generation === 1,
|
||||
},
|
||||
archiveExport: {
|
||||
settings: () => DEFAULT_SETTINGS,
|
||||
enabled: () => false,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,103 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps a disconnected panel's awaiting thread across an app-server context replacement", async () => {
|
||||
connectionMock.state.client = connectedClient();
|
||||
const host = chatHost();
|
||||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.surface.connect();
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
connectionMock.state.connected = false;
|
||||
connectionMock.state.onExit?.();
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false, threadId: "thread-1" });
|
||||
|
||||
view.surface.prepareAppServerContextChange();
|
||||
const nextClient = connectedClient();
|
||||
connectionMock.state.client = nextClient;
|
||||
host.settingsSource.codexPath = "codex-next";
|
||||
view.surface.refreshSettings();
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not run an inline rename queued before an app-server context replacement", async () => {
|
||||
const oldClient = connectedClient();
|
||||
connectionMock.state.client = oldClient;
|
||||
const nameMutations = createThreadNameMutationCoordinator();
|
||||
const mutationRun = vi.spyOn(nameMutations, "run");
|
||||
const host = chatHost({ threadNameMutations: nameMutations });
|
||||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.surface.connect();
|
||||
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Original" })]);
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
const blocker = deferred<void>();
|
||||
const blockerWork = nameMutations.run("thread-1", () => blocker.promise);
|
||||
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
|
||||
await flushAsyncTicks();
|
||||
requiredButton(view.containerEl, '[aria-label="Rename thread"]').click();
|
||||
await flushAsyncTicks();
|
||||
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input");
|
||||
if (!input) throw new Error("Missing inline rename input");
|
||||
input.value = "Queued in A";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushAsyncTicks();
|
||||
expect(mutationRun).toHaveBeenCalledTimes(2);
|
||||
|
||||
view.surface.prepareAppServerContextChange();
|
||||
const nextClient = connectedClient();
|
||||
connectionMock.state.client = nextClient;
|
||||
host.settingsSource.codexPath = "codex-next";
|
||||
view.surface.refreshSettings();
|
||||
await waitForAsyncWork(() => expect(connectionMock.state.connectCalls).toBe(2));
|
||||
blocker.resolve(undefined);
|
||||
await blockerWork;
|
||||
await flushAsyncTicks();
|
||||
|
||||
expectRequestTimes(oldClient, "thread/name/set", 0);
|
||||
expectRequestTimes(nextClient, "thread/name/set", 0);
|
||||
});
|
||||
|
||||
it("does not run a slash rename queued before an app-server context replacement", async () => {
|
||||
const oldClient = connectedClient();
|
||||
connectionMock.state.client = oldClient;
|
||||
const nameMutations = createThreadNameMutationCoordinator();
|
||||
const mutationRun = vi.spyOn(nameMutations, "run");
|
||||
const host = chatHost({ threadNameMutations: nameMutations });
|
||||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.surface.connect();
|
||||
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Original" })]);
|
||||
const blocker = deferred<void>();
|
||||
const blockerWork = nameMutations.run("thread-1", () => blocker.promise);
|
||||
view.surface.setComposerText("/rename thread-1 Queued in A");
|
||||
await submitComposerByEnter(view);
|
||||
expect(mutationRun).toHaveBeenCalledTimes(2);
|
||||
|
||||
view.surface.prepareAppServerContextChange();
|
||||
const nextClient = connectedClient();
|
||||
connectionMock.state.client = nextClient;
|
||||
host.settingsSource.codexPath = "codex-next";
|
||||
view.surface.refreshSettings();
|
||||
await waitForAsyncWork(() => expect(connectionMock.state.connectCalls).toBe(2));
|
||||
blocker.resolve(undefined);
|
||||
await blockerWork;
|
||||
await flushAsyncTicks();
|
||||
|
||||
expectRequestTimes(oldClient, "thread/name/set", 0);
|
||||
expectRequestTimes(nextClient, "thread/name/set", 0);
|
||||
});
|
||||
|
||||
it("resumes each panel's captured thread after a shared app-server context replacement", async () => {
|
||||
const host = chatHost();
|
||||
const resumeRequestedThread = vi.fn((params: unknown) => {
|
||||
|
|
@ -1286,6 +1383,7 @@ interface ChatHostFixtureOverrides {
|
|||
refreshAppServerMetadata?: CodexChatHost["appServerQueries"]["refreshAppServerMetadata"];
|
||||
refreshSkills?: CodexChatHost["appServerQueries"]["refreshSkills"];
|
||||
refreshRateLimits?: CodexChatHost["appServerQueries"]["refreshRateLimits"];
|
||||
threadNameMutations?: CodexChatHost["threadNameMutations"];
|
||||
}
|
||||
|
||||
function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
|
||||
|
|
@ -1393,7 +1491,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
|
|||
activeThreads = threads;
|
||||
emitActiveThreads();
|
||||
},
|
||||
threadNameMutations: createThreadNameMutationCoordinator(),
|
||||
threadNameMutations: overrides.threadNameMutations ?? createThreadNameMutationCoordinator(),
|
||||
settingsRef: {
|
||||
settings: chatPanelSettingsAccess(settings),
|
||||
vaultPath,
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ describe("ChatComposerController", () => {
|
|||
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).toContain("/fast");
|
||||
});
|
||||
|
||||
it("omits subagent slash suggestions on input without waiting for keyup", () => {
|
||||
it("keeps a disconnected subagent composer read-only without slash suggestions", () => {
|
||||
const stateStore = createChatStateStore(
|
||||
chatStateFixture({
|
||||
activeThread: {
|
||||
|
|
@ -250,13 +250,16 @@ describe("ChatComposerController", () => {
|
|||
},
|
||||
}),
|
||||
);
|
||||
stateStore.dispatch({ type: "connection/scoped-cleared" });
|
||||
const { controller, parent } = composerControllerFixture({ stateStore });
|
||||
|
||||
renderComposerController(parent, controller, stateStore);
|
||||
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
|
||||
setTextAreaValue(composer(parent), "/");
|
||||
composer(parent).setSelectionRange(1, 1);
|
||||
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
expect(props.submissionDisabled).toBe(true);
|
||||
expect(stateStore.getState().composer.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -758,6 +758,7 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
|
|||
}),
|
||||
},
|
||||
vaultPath: "/vault",
|
||||
appServerContextLease: () => ({ context: { codexPath: "codex", vaultPath: "/vault" }, generation: 1 }),
|
||||
threadNameMutations: createThreadNameMutationCoordinator(),
|
||||
threadOperationsTransport: createThreadOperationsTransport(clientAccess),
|
||||
threadTitleTransport: createThreadTitleTransport({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { AppServerClient } from "../../../../src/app-server/connection/client";
|
||||
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
|
||||
import type { AppServerQueryContextIdentity } from "../../../../src/app-server/query/keys";
|
||||
import { createThreadOperationsTransport } from "../../../../src/features/threads/app-server/workflow-transports";
|
||||
import type { ArchiveExportDestination } from "../../../../src/features/threads/workflows/archive-export";
|
||||
import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator";
|
||||
|
|
@ -55,6 +56,28 @@ describe("ThreadOperations", () => {
|
|||
expect(client.request).toHaveBeenNthCalledWith(3, "thread/name/set", { threadId: "thread", name: "Latest manual title" });
|
||||
});
|
||||
|
||||
it("does not start a queued rename after its resource context is replaced", async () => {
|
||||
const firstSave = deferred<object>();
|
||||
const client = clientMock();
|
||||
client.request.mockImplementationOnce(async (method: string) => {
|
||||
if (method !== "thread/name/set") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return firstSave.promise;
|
||||
});
|
||||
let context: AppServerQueryContextIdentity = { codexPath: "codex-a", vaultPath: "/vault", generation: 1 };
|
||||
const { operations, catalog } = operationsFixture({ client, currentContext: () => context });
|
||||
|
||||
const started = operations.renameThread("thread", "Started in A");
|
||||
await Promise.resolve();
|
||||
const queued = operations.renameThread("thread", "Queued in A");
|
||||
context = { codexPath: "codex-b", vaultPath: "/vault", generation: 2 };
|
||||
firstSave.resolve({});
|
||||
|
||||
await expect(started).resolves.toBe(true);
|
||||
await expect(queued).resolves.toBe(false);
|
||||
expect(client.request).toHaveBeenCalledOnce();
|
||||
expect(catalog.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => {
|
||||
const { operations, catalog, notice, client, archiveDestination } = operationsFixture();
|
||||
|
||||
|
|
@ -129,7 +152,9 @@ describe("ThreadOperations", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function operationsFixture(options: { client?: MockClient | null | (() => MockClient | null) } = {}) {
|
||||
function operationsFixture(
|
||||
options: { client?: MockClient | null | (() => MockClient | null); currentContext?: () => AppServerQueryContextIdentity } = {},
|
||||
) {
|
||||
const configuredClient = options.client === undefined ? clientMock() : options.client;
|
||||
const currentClient = typeof configuredClient === "function" ? configuredClient : () => configuredClient;
|
||||
const archiveDestination = archiveDestinationMock();
|
||||
|
|
@ -142,6 +167,7 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
|
|||
const catalog = {
|
||||
apply: vi.fn(),
|
||||
};
|
||||
const currentContext = options.currentContext ?? (() => ({ codexPath: "codex", vaultPath: "/vault", generation: 1 }));
|
||||
const notice = vi.fn();
|
||||
const host: ThreadOperationsHost = {
|
||||
transport: createThreadOperationsTransport({
|
||||
|
|
@ -154,6 +180,15 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
|
|||
},
|
||||
}),
|
||||
nameMutations: createThreadNameMutationCoordinator(),
|
||||
resourceContext: {
|
||||
capture: currentContext,
|
||||
isCurrent: (context) => {
|
||||
const current = currentContext();
|
||||
return (
|
||||
context.codexPath === current.codexPath && context.vaultPath === current.vaultPath && context.generation === current.generation
|
||||
);
|
||||
},
|
||||
},
|
||||
archiveExport: {
|
||||
settings: archiveExportSettings,
|
||||
enabled: () => false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue