mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Model subagent threads as read-only panels
This commit is contained in:
parent
697b748aaf
commit
160c344008
70 changed files with 650 additions and 62 deletions
|
|
@ -52,6 +52,8 @@ Imperative DOM bridges are allowed when an external API, host lifecycle, hit-tes
|
|||
|
||||
Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests.
|
||||
|
||||
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior.
|
||||
|
||||
Thread history, archived state, forks, catalog snapshots, and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views of Codex state, not replacements for Codex history.
|
||||
|
||||
Routine panel and Threads view refreshes should load only the first recency-ordered thread page. Older active threads are appended through an explicit load-more action; workflows that explicitly need a complete inventory, such as opening the thread picker, may finish pagination on demand.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { Thread, ThreadProvenance } from "../../domain/threads/model";
|
||||
|
||||
export interface ThreadRecord {
|
||||
id: string;
|
||||
|
|
@ -20,10 +20,53 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
|
|||
archived: options.archived ?? false,
|
||||
createdAt: finiteTimestamp(thread.createdAt),
|
||||
updatedAt: finiteTimestamp(thread.updatedAt),
|
||||
provenance: threadProvenance(thread),
|
||||
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function threadProvenance(thread: ThreadRecord): ThreadProvenance {
|
||||
const source = recordOrNull(thread["source"]);
|
||||
const subagent = source?.["subAgent"];
|
||||
const threadSource = stringOrNull(thread["threadSource"]);
|
||||
if (subagent === undefined && stringOrNull(thread["parentThreadId"]) === null && !threadSource?.startsWith("subAgent")) {
|
||||
return { kind: "interactive" };
|
||||
}
|
||||
|
||||
const spawn = recordOrNull(recordOrNull(subagent)?.["thread_spawn"]);
|
||||
return {
|
||||
kind: "subagent",
|
||||
subagentKind: subagentKind(subagent, threadSource),
|
||||
parentThreadId: stringOrNull(thread["parentThreadId"]) ?? stringOrNull(spawn?.["parent_thread_id"]),
|
||||
sessionId: stringOrNull(thread["sessionId"]),
|
||||
depth: finiteNumberOrNull(spawn?.["depth"]),
|
||||
agentNickname: stringOrNull(thread["agentNickname"]) ?? stringOrNull(spawn?.["agent_nickname"]),
|
||||
agentRole: stringOrNull(thread["agentRole"]) ?? stringOrNull(spawn?.["agent_role"]),
|
||||
};
|
||||
}
|
||||
|
||||
function subagentKind(value: unknown, threadSource: string | null): Extract<ThreadProvenance, { kind: "subagent" }>["subagentKind"] {
|
||||
if (value === "review" || value === "compact" || value === "memory_consolidation") {
|
||||
return value === "memory_consolidation" ? "memory-consolidation" : value;
|
||||
}
|
||||
if (recordOrNull(value)?.["thread_spawn"] || threadSource === "subAgentThreadSpawn") return "thread-spawn";
|
||||
if (threadSource === "subAgentReview") return "review";
|
||||
if (threadSource === "subAgentCompact") return "compact";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function recordOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function finiteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function threadsFromThreadRecords(threads: readonly ThreadRecord[], options: { archived?: boolean } = {}): Thread[] {
|
||||
return threads.map((thread) => threadFromThreadRecord(thread, options));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,23 @@ export interface Thread {
|
|||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly recencyAt?: number | null;
|
||||
readonly provenance: ThreadProvenance;
|
||||
}
|
||||
|
||||
export type ThreadProvenance =
|
||||
| { readonly kind: "interactive" }
|
||||
| {
|
||||
readonly kind: "subagent";
|
||||
readonly subagentKind: "thread-spawn" | "review" | "compact" | "memory-consolidation" | "other";
|
||||
readonly parentThreadId: string | null;
|
||||
readonly sessionId: string | null;
|
||||
readonly depth: number | null;
|
||||
readonly agentNickname: string | null;
|
||||
readonly agentRole: string | null;
|
||||
};
|
||||
|
||||
export function isSubagentThread(thread: Thread): boolean {
|
||||
return thread.provenance.kind === "subagent";
|
||||
}
|
||||
|
||||
export function explicitThreadName(thread: Thread): string | null {
|
||||
|
|
|
|||
|
|
@ -166,14 +166,16 @@ function threadStartedPlan(
|
|||
state: ChatState,
|
||||
notification: Extract<ThreadLifecycleNotification, { method: "thread/started" }>,
|
||||
): ChatNotificationPlan {
|
||||
const effects: ChatNotificationEffect[] = notification.params.thread.ephemeral
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "apply-thread-catalog-event",
|
||||
event: { type: "thread-started", thread: threadFromAppServerRecord(notification.params.thread) },
|
||||
},
|
||||
];
|
||||
const thread = threadFromAppServerRecord(notification.params.thread);
|
||||
const effects: ChatNotificationEffect[] =
|
||||
notification.params.thread.ephemeral || thread.provenance.kind === "subagent"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "apply-thread-catalog-event",
|
||||
event: { type: "thread-started", 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface ComposerSuggestion {
|
|||
export interface ComposerSuggestionOptions {
|
||||
activeThreadId?: string | null;
|
||||
activeThreadEphemeral?: boolean;
|
||||
activeThreadSubagent?: boolean;
|
||||
contextReferences?: ComposerContextReferences;
|
||||
dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]);
|
||||
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
||||
|
|
@ -114,16 +115,19 @@ export function activeComposerSuggestions(
|
|||
currentModel: string | null = null,
|
||||
options: ComposerSuggestionOptions = {},
|
||||
): ComposerSuggestion[] {
|
||||
const slashSuggestions = options.activeThreadSubagent
|
||||
? null
|
||||
: (activeSlashSubcommandSuggestions(beforeCursor) ??
|
||||
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
|
||||
modelOverrideSuggestions(beforeCursor, models) ??
|
||||
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
|
||||
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
|
||||
activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false, false));
|
||||
return (
|
||||
activeWikiLinkSuggestions(beforeCursor, notes) ??
|
||||
activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ??
|
||||
activeTagSuggestions(beforeCursor, options.tagCandidates ?? []) ??
|
||||
activeSlashSubcommandSuggestions(beforeCursor) ??
|
||||
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
|
||||
modelOverrideSuggestions(beforeCursor, models) ??
|
||||
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
|
||||
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
|
||||
activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false) ??
|
||||
slashSuggestions ??
|
||||
activeSkillSuggestions(beforeCursor, skills) ??
|
||||
[]
|
||||
);
|
||||
|
|
@ -395,13 +399,18 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand
|
|||
|
||||
const SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS = new Set(["/btw", "/fork", "/rollback"]);
|
||||
|
||||
function activeSlashCommandSuggestions(beforeCursor: string, activeThreadEphemeral: boolean): ComposerSuggestion[] | null {
|
||||
function activeSlashCommandSuggestions(
|
||||
beforeCursor: string,
|
||||
activeThreadEphemeral: boolean,
|
||||
activeThreadSubagent: boolean,
|
||||
): ComposerSuggestion[] | null {
|
||||
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
|
||||
if (match?.index === undefined) return null;
|
||||
|
||||
const rawQuery = match[1];
|
||||
if (rawQuery === undefined) return null;
|
||||
const query = rawQuery.toLowerCase();
|
||||
if (activeThreadSubagent) return [];
|
||||
if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null;
|
||||
const start = match.index + match[0].lastIndexOf("/");
|
||||
return SLASH_COMMANDS.filter(
|
||||
|
|
|
|||
|
|
@ -108,11 +108,13 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr
|
|||
}
|
||||
|
||||
async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/model-requested", model });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/model-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
|
@ -122,11 +124,13 @@ async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: strin
|
|||
}
|
||||
|
||||
async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/reasoning-effort-requested", effort });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
|
@ -140,12 +144,14 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo
|
|||
}
|
||||
|
||||
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (permissionChangesBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (permissionChangesBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
|
|
@ -157,12 +163,19 @@ function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean {
|
||||
if (state(host).activeThread.provenance?.kind !== "subagent") return false;
|
||||
host.addSystemMessage("Thread settings are unavailable in agent threads.");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
||||
const { snapshot, config } = runtimeProjection(host);
|
||||
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
|
||||
}
|
||||
|
||||
async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise<void> {
|
||||
if (agentThreadSettingsBlocked(host)) return;
|
||||
const fastMode: RequestedFastMode = mode;
|
||||
await runRuntimeUiCommand(
|
||||
host,
|
||||
|
|
@ -181,6 +194,7 @@ async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promis
|
|||
}
|
||||
|
||||
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
|
||||
const result = await commitPendingThreadSettings(host);
|
||||
if (result.ok) closeRuntimePanel(host);
|
||||
|
|
@ -191,6 +205,7 @@ async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborat
|
|||
}
|
||||
|
||||
function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void {
|
||||
if (agentThreadSettingsBlocked(host)) return;
|
||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +216,7 @@ async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void>
|
|||
}
|
||||
|
||||
async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise<void> {
|
||||
if (agentThreadSettingsBlocked(host)) return;
|
||||
await runRuntimeUiCommand(
|
||||
host,
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy";
|
||||
import type { ServerInitialization } from "../../../../domain/server/initialization";
|
||||
import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation";
|
||||
import { type Thread, upsertThread } from "../../../../domain/threads/model";
|
||||
import { isSubagentThread, type Thread, upsertThread } from "../../../../domain/threads/model";
|
||||
import type { CollaborationModeSelection } from "../../domain/runtime/intent";
|
||||
import type { ActiveThreadRuntimeState } from "../../domain/runtime/state";
|
||||
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
|
|
@ -166,7 +166,9 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
|
|||
...permissions,
|
||||
lifetime: { kind: "persistent" },
|
||||
...(params.items ? { items: params.items } : {}),
|
||||
...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}),
|
||||
...(params.listedThreads
|
||||
? { listedThreads: isSubagentThread(response.thread) ? params.listedThreads : upsertThread(params.listedThreads, response.thread) }
|
||||
: {}),
|
||||
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,10 +106,12 @@ interface ChatThreadListState {
|
|||
|
||||
export interface ChatActiveThreadState {
|
||||
readonly id: string | null;
|
||||
readonly title?: string | null;
|
||||
readonly cwd: string | null;
|
||||
readonly goal: ThreadGoal | null;
|
||||
readonly tokenUsage: ThreadTokenUsage | null;
|
||||
readonly lifetime: ActiveThreadLifetime | null;
|
||||
readonly provenance: Thread["provenance"] | null;
|
||||
}
|
||||
|
||||
type ActiveThreadLifetime =
|
||||
|
|
@ -327,10 +329,12 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
|
|||
},
|
||||
activeThread: {
|
||||
id: action.thread.id,
|
||||
title: (action.thread.name ?? action.thread.preview) || null,
|
||||
cwd: action.cwd,
|
||||
goal: null,
|
||||
tokenUsage: null,
|
||||
lifetime: action.lifetime ?? { kind: "persistent" },
|
||||
provenance: action.thread.provenance,
|
||||
},
|
||||
runtime: {
|
||||
...runtimeBase,
|
||||
|
|
@ -656,6 +660,7 @@ function initialActiveThreadState(): ChatActiveThreadState {
|
|||
goal: null,
|
||||
tokenUsage: null,
|
||||
lifetime: null,
|
||||
provenance: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@ async function forkThreadFromTurn(
|
|||
host.addSystemMessage("Side chats cannot be forked.");
|
||||
return;
|
||||
}
|
||||
if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") {
|
||||
host.addSystemMessage("Agent threads cannot be forked.");
|
||||
return;
|
||||
}
|
||||
if (chatTurnBusy(threadManagementState(host))) {
|
||||
host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
|
||||
return;
|
||||
|
|
@ -177,6 +181,10 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
host.addSystemMessage("Side chats cannot be rolled back.");
|
||||
return;
|
||||
}
|
||||
if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") {
|
||||
host.addSystemMessage("Agent threads cannot be rolled back.");
|
||||
return;
|
||||
}
|
||||
if (chatTurnBusy(threadManagementState(host))) {
|
||||
host.addSystemMessage("Interrupt the current turn before rolling back.");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost)
|
|||
|
||||
return {
|
||||
async startNewThread(): Promise<void> {
|
||||
if (chatTurnBusy(host.stateStore.getState())) return;
|
||||
const state = host.stateStore.getState();
|
||||
if (chatTurnBusy(state) && state.activeThread.provenance?.kind !== "subagent") return;
|
||||
if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return;
|
||||
|
||||
host.identity.clearActiveThreadIdentity();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const STATUS_INTERRUPT_REQUESTED = "Interrupt requested.";
|
|||
|
||||
export interface ComposerSubmitActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
ensureRestoredThreadLoaded?: () => Promise<boolean>;
|
||||
composer: {
|
||||
readonly trimmedDraft: string;
|
||||
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean; preserveContext?: boolean }): void;
|
||||
|
|
@ -45,16 +46,20 @@ export interface ComposerSubmitActions {
|
|||
|
||||
export async function submitComposer(host: ComposerSubmitActionsHost): Promise<void> {
|
||||
const draft = host.composer.trimmedDraft;
|
||||
if (host.ensureRestoredThreadLoaded && !(await host.ensureRestoredThreadLoaded())) return;
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
if (state.activeThreadSubagent) {
|
||||
host.status.addSystemMessage("Messages and slash commands are unavailable in agent threads. Start a new chat to continue.");
|
||||
return;
|
||||
}
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
|
||||
await interruptTurn(host);
|
||||
return;
|
||||
}
|
||||
await sendMessage(host);
|
||||
await sendMessage(host, draft);
|
||||
}
|
||||
|
||||
async function sendMessage(host: ComposerSubmitActionsHost): Promise<void> {
|
||||
const text = host.composer.trimmedDraft;
|
||||
async function sendMessage(host: ComposerSubmitActionsHost, text: string): Promise<void> {
|
||||
if (!text) return;
|
||||
const inputSnapshot = host.composer.captureInputSnapshot();
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
|
|||
};
|
||||
const composerSubmitHost: ComposerSubmitActionsHost = {
|
||||
stateStore,
|
||||
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
|
||||
composer: {
|
||||
get trimmedDraft() {
|
||||
return composer.trimmedDraft();
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ export interface PlanImplementationHost {
|
|||
}
|
||||
|
||||
export function implementPlanTargetFromState(state: {
|
||||
activeThread: Pick<ChatActiveThreadState, "id">;
|
||||
activeThread: Pick<ChatActiveThreadState, "id"> & { provenance?: ChatActiveThreadState["provenance"] };
|
||||
turn: ChatTurnState;
|
||||
runtime: { pending: Pick<ChatRuntimeState["pending"], "collaborationMode"> };
|
||||
threadStream: Pick<ChatThreadStreamState, "stableItems" | "activeSegment">;
|
||||
}): PlanImplementationTarget | null {
|
||||
if (
|
||||
!state.activeThread.id ||
|
||||
state.activeThread.provenance?.kind === "subagent" ||
|
||||
chatTurnBusy(state) ||
|
||||
state.runtime.pending.collaborationMode.kind !== "set" ||
|
||||
state.runtime.pending.collaborationMode.value !== "plan"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export async function executeSlashCommandWithState(
|
|||
inputSnapshot?: ComposerInputSnapshot,
|
||||
): Promise<SlashCommandExecutionResult | undefined> {
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
if (state.activeThreadSubagent) {
|
||||
throw new Error("Slash commands are unavailable in agent threads. Start a new chat to continue.");
|
||||
}
|
||||
if (!host.connectionAvailable() && command !== "reconnect" && command !== "compact") return;
|
||||
return runSlashCommand(command, args, {
|
||||
...host,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } f
|
|||
export interface SubmissionStateSnapshot {
|
||||
activeThreadId: string | null;
|
||||
activeThreadEphemeral: boolean;
|
||||
activeThreadSubagent: boolean;
|
||||
activeTurnId: string | null;
|
||||
busy: boolean;
|
||||
listedThreads: readonly Thread[];
|
||||
|
|
@ -18,6 +19,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
|
|||
return {
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: state.activeThread.provenance?.kind === "subagent",
|
||||
activeTurnId: activeTurnId(state),
|
||||
busy: chatTurnBusy(state),
|
||||
listedThreads: state.threadList.listedThreads,
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ async function sendTurnText(
|
|||
}
|
||||
|
||||
function planTurnSubmission(state: TurnSubmissionSnapshot): TurnSubmissionPlan {
|
||||
if (state.activeThreadSubagent) {
|
||||
return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." };
|
||||
}
|
||||
if (state.busy) {
|
||||
return state.activeThreadId && state.activeTurnId
|
||||
? { kind: "steer", threadId: state.activeThreadId, turnId: state.activeTurnId }
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === activeThreadId);
|
||||
void environment.plugin.workspace.openSideChat(activeThreadId, thread?.name ?? thread?.preview ?? null);
|
||||
},
|
||||
activeThreadChatActionsDisabled: () => stateStore.getState().activeThread.provenance?.kind === "subagent",
|
||||
});
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
connection: {
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
const threadId = this.state.activeThread.id;
|
||||
if (!threadId) return null;
|
||||
const thread = this.state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
return thread ? threadMeaningfulTitle(thread) : null;
|
||||
return thread ? threadMeaningfulTitle(thread) : (this.state.activeThread.title ?? null);
|
||||
}
|
||||
|
||||
private restoredThreadTitle(): string | null {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export class ChatComposerController {
|
|||
draft: model.draft.value,
|
||||
busy: model.turnBusy.value,
|
||||
canInterrupt: this.options.canInterrupt(model),
|
||||
submissionDisabled: model.activeThreadSubagent.value,
|
||||
normalPlaceholder: projection.placeholder,
|
||||
suggestions: model.suggestions.value,
|
||||
selectedSuggestionIndex: model.selectedSuggestionIndex.value,
|
||||
|
|
@ -249,6 +250,7 @@ export class ChatComposerController {
|
|||
{
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: state.activeThread.provenance?.kind === "subagent",
|
||||
contextReferences: this.contextReferences(),
|
||||
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ interface ChatPanelToolbarDebugState {
|
|||
export interface ChatPanelToolbarReadModel {
|
||||
readonly threads: ReadonlySignal<ChatState["threadList"]["listedThreads"]>;
|
||||
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
|
||||
readonly activeThreadSubagent: ReadonlySignal<boolean>;
|
||||
readonly turnBusy: ReadonlySignal<boolean>;
|
||||
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
|
||||
readonly toolbarPanel: ReadonlySignal<ChatState["ui"]["toolbarPanel"]>;
|
||||
|
|
@ -138,6 +139,7 @@ export interface ChatPanelComposerReadModel {
|
|||
readonly suggestions: ReadonlySignal<ChatState["composer"]["suggestions"]>;
|
||||
readonly selectedSuggestionIndex: ReadonlySignal<ChatState["composer"]["suggestSelected"]>;
|
||||
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
|
||||
readonly activeThreadSubagent: ReadonlySignal<boolean>;
|
||||
readonly turnBusy: ReadonlySignal<boolean>;
|
||||
readonly activeTurnId: ReadonlySignal<string | null>;
|
||||
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
|
||||
|
|
@ -179,16 +181,18 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
|
|||
threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)),
|
||||
threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)),
|
||||
threadStreamRollbackCandidate: computed(() =>
|
||||
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral"
|
||||
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
|
||||
? null
|
||||
: threadStreamRollbackCandidateFromItems(streamItems.value),
|
||||
),
|
||||
threadStreamForkCandidates: computed(() =>
|
||||
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" ? [] : forkCandidatesFromItems(streamItems.value),
|
||||
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
|
||||
? []
|
||||
: forkCandidatesFromItems(streamItems.value),
|
||||
),
|
||||
threadStreamImplementPlanTarget: computed(() =>
|
||||
implementPlanTargetFromState({
|
||||
activeThread: { id: activeThreadIdSignal.value },
|
||||
activeThread: { id: activeThreadIdSignal.value, provenance: activeThread.value.provenance },
|
||||
turn: turn.value,
|
||||
runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } },
|
||||
threadStream: threadStream.value,
|
||||
|
|
@ -256,6 +260,7 @@ function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelT
|
|||
return {
|
||||
threads: computed(() => signals.threadList.value.listedThreads),
|
||||
activeThreadId: signals.activeThreadId,
|
||||
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
|
||||
turnBusy: signals.turnBusy,
|
||||
runtimeSnapshot: signals.toolbarRuntimeSnapshot,
|
||||
toolbarPanel: computed(() => signals.ui.value.toolbarPanel),
|
||||
|
|
@ -334,6 +339,7 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel
|
|||
suggestions: computed(() => signals.composer.value.suggestions),
|
||||
selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected),
|
||||
activeThreadId: signals.activeThreadId,
|
||||
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
|
||||
turnBusy: signals.turnBusy,
|
||||
activeTurnId: signals.activeTurnId,
|
||||
runtimeSnapshot: signals.composerRuntimeSnapshot,
|
||||
|
|
|
|||
|
|
@ -63,11 +63,9 @@ export function chatPanelComposerProjection(
|
|||
): ChatPanelComposerProjection {
|
||||
const snapshot = readModel.runtimeSnapshot.value;
|
||||
return {
|
||||
placeholder: composerPlaceholder(
|
||||
activeComposerThreadName(readModel),
|
||||
readModel.sideChatActive.value,
|
||||
readModel.sideChatSourceTitle.value,
|
||||
),
|
||||
placeholder: readModel.activeThreadSubagent.value
|
||||
? "Agent thread is read-only."
|
||||
: composerPlaceholder(activeComposerThreadName(readModel), readModel.sideChatActive.value, readModel.sideChatSourceTitle.value),
|
||||
meta: {
|
||||
...composerMetaViewModel(readModel, snapshot),
|
||||
...runtimeComposerChoices({
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface ToolbarViewModelInput {
|
|||
|
||||
interface ToolbarStateProjection {
|
||||
newChatDisabled: boolean;
|
||||
activeThreadChatActionsDisabled: boolean;
|
||||
chatActionsOpen: boolean;
|
||||
historyOpen: boolean;
|
||||
statusPanelOpen: boolean;
|
||||
|
|
@ -82,6 +83,7 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
|
|||
});
|
||||
return {
|
||||
newChatDisabled: projection.newChatDisabled,
|
||||
activeThreadChatActionsDisabled: projection.activeThreadChatActionsDisabled,
|
||||
chatActionsOpen: projection.chatActionsOpen,
|
||||
historyOpen: projection.historyOpen,
|
||||
statusPanelOpen: projection.statusPanelOpen,
|
||||
|
|
@ -140,7 +142,8 @@ function toolbarStateProjection(input: {
|
|||
const chatActionsOpen = toolbarPanel === "chat-actions";
|
||||
const statusPanelOpen = toolbarPanel === "status-panel";
|
||||
return {
|
||||
newChatDisabled: input.turnBusy,
|
||||
newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent.value,
|
||||
activeThreadChatActionsDisabled: input.model.activeThreadSubagent.value,
|
||||
chatActionsOpen,
|
||||
historyOpen,
|
||||
statusPanelOpen,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface ToolbarUiActionDependencies {
|
|||
rename: ThreadRenameEditorActions;
|
||||
navigation: ThreadNavigationActions;
|
||||
openSideChat?: () => void;
|
||||
activeThreadChatActionsDisabled: () => boolean;
|
||||
}
|
||||
|
||||
export interface ToolbarOutsidePointerHit {
|
||||
|
|
@ -135,11 +136,20 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb
|
|||
startNewThread: () => {
|
||||
void deps.navigation.startNewThread();
|
||||
},
|
||||
...(deps.openSideChat ? { startSideChat: deps.openSideChat } : {}),
|
||||
...(deps.openSideChat
|
||||
? {
|
||||
startSideChat: () => {
|
||||
if (deps.activeThreadChatActionsDisabled()) return;
|
||||
deps.openSideChat?.();
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
compactContext: () => {
|
||||
if (deps.activeThreadChatActionsDisabled()) return;
|
||||
void deps.threadActions.compactActiveThread();
|
||||
},
|
||||
setGoal: () => {
|
||||
if (deps.activeThreadChatActionsDisabled()) return;
|
||||
deps.goals.startEditingCurrent();
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export interface ComposerShellProps {
|
|||
draft: string;
|
||||
busy: boolean;
|
||||
canInterrupt: boolean;
|
||||
submissionDisabled: boolean;
|
||||
normalPlaceholder: string;
|
||||
meta: ComposerMetaViewModel;
|
||||
suggestions: readonly ComposerSuggestion[];
|
||||
|
|
@ -103,6 +104,7 @@ export function ComposerShell({
|
|||
draft,
|
||||
busy,
|
||||
canInterrupt,
|
||||
submissionDisabled,
|
||||
normalPlaceholder,
|
||||
meta,
|
||||
suggestions,
|
||||
|
|
@ -147,7 +149,7 @@ export function ComposerShell({
|
|||
if (pendingSelection.value === draft) restoreComposerCursor(composerRef.current, pendingSelection.cursor);
|
||||
onPendingSelectionApplied?.();
|
||||
}, [draft, pendingSelection, onPendingSelectionApplied]);
|
||||
const sendMode = composerSendMode(busy, canInterrupt, draft);
|
||||
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled);
|
||||
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
|
||||
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
|
||||
|
||||
|
|
@ -164,6 +166,7 @@ export function ComposerShell({
|
|||
aria-controls={composerSuggestionsListId(viewId)}
|
||||
aria-activedescendant={selectedSuggestionId}
|
||||
value={draft}
|
||||
readOnly={submissionDisabled}
|
||||
onInput={(event) => {
|
||||
if (syncComposerHeight(event.currentTarget)) callbacks.onHeightChange();
|
||||
callbacks.onInput(event.currentTarget.value);
|
||||
|
|
@ -184,7 +187,7 @@ export function ComposerShell({
|
|||
callbacks.onDragOver?.(event);
|
||||
}}
|
||||
/>
|
||||
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} />
|
||||
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={submissionDisabled} />
|
||||
</div>
|
||||
<ComposerSuggestions
|
||||
containerRef={suggestionsRef}
|
||||
|
|
@ -202,10 +205,12 @@ function ComposerMeta({
|
|||
meta,
|
||||
sendMode,
|
||||
callbacks,
|
||||
disabled,
|
||||
}: {
|
||||
meta: ComposerMetaViewModel;
|
||||
sendMode: ComposerSendMode;
|
||||
callbacks: ComposerCallbacks;
|
||||
disabled: boolean;
|
||||
}): UiNode {
|
||||
const metaRef = useRef<HTMLDivElement | null>(null);
|
||||
const statusRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
|
@ -226,6 +231,7 @@ function ComposerMeta({
|
|||
});
|
||||
}, [picker]);
|
||||
const openPicker = (kind: ComposerMetaPickerKind) => {
|
||||
if (disabled) return;
|
||||
const nextPicker = composerMetaPickerState(
|
||||
kind,
|
||||
kind === "model" ? modelTriggerRef.current : effortTriggerRef.current,
|
||||
|
|
@ -253,6 +259,7 @@ function ComposerMeta({
|
|||
<ComposerMetaModeButton
|
||||
icon="list-todo"
|
||||
active={meta.planActive}
|
||||
disabled={disabled}
|
||||
onMouseDown={() => {
|
||||
callbacks.onTogglePlan?.();
|
||||
}}
|
||||
|
|
@ -260,6 +267,7 @@ function ComposerMeta({
|
|||
<ComposerMetaModeButton
|
||||
icon="shield"
|
||||
active={meta.autoReviewActive}
|
||||
disabled={disabled}
|
||||
onMouseDown={() => {
|
||||
callbacks.onToggleAutoReview?.();
|
||||
}}
|
||||
|
|
@ -267,6 +275,7 @@ function ComposerMeta({
|
|||
<ComposerMetaModeButton
|
||||
icon="zap"
|
||||
active={meta.fastActive}
|
||||
disabled={disabled}
|
||||
onMouseDown={() => {
|
||||
callbacks.onToggleFast?.();
|
||||
}}
|
||||
|
|
@ -280,6 +289,7 @@ function ComposerMeta({
|
|||
triggerRef={modelTriggerRef}
|
||||
kind="model"
|
||||
value={meta.model}
|
||||
disabled={disabled}
|
||||
onMouseDown={() => {
|
||||
openPicker("model");
|
||||
}}
|
||||
|
|
@ -292,6 +302,7 @@ function ComposerMeta({
|
|||
triggerRef={effortTriggerRef}
|
||||
kind="effort"
|
||||
value={meta.effort}
|
||||
disabled={disabled}
|
||||
onMouseDown={() => {
|
||||
openPicker("effort");
|
||||
}}
|
||||
|
|
@ -337,7 +348,17 @@ function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["con
|
|||
);
|
||||
}
|
||||
|
||||
function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; active: boolean; onMouseDown: () => void }): UiNode {
|
||||
function ComposerMetaModeButton({
|
||||
icon,
|
||||
active,
|
||||
disabled,
|
||||
onMouseDown,
|
||||
}: {
|
||||
icon: string;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
onMouseDown: () => void;
|
||||
}): UiNode {
|
||||
const iconRef = useRef<HTMLSpanElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const element = iconRef.current;
|
||||
|
|
@ -348,12 +369,17 @@ function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; a
|
|||
<span
|
||||
ref={iconRef}
|
||||
aria-hidden="true"
|
||||
className={["codex-panel__composer-meta-trigger", "codex-panel__composer-meta-icon", active ? "is-active" : ""]
|
||||
className={[
|
||||
"codex-panel__composer-meta-trigger",
|
||||
"codex-panel__composer-meta-icon",
|
||||
active ? "is-active" : "",
|
||||
disabled ? "is-disabled" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onMouseDown();
|
||||
if (!disabled) onMouseDown();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -363,21 +389,30 @@ function ComposerMetaPickerButton({
|
|||
triggerRef,
|
||||
kind,
|
||||
value,
|
||||
disabled,
|
||||
onMouseDown,
|
||||
}: {
|
||||
triggerRef: Ref<HTMLSpanElement>;
|
||||
kind: ComposerMetaPickerKind;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onMouseDown: () => void;
|
||||
}): UiNode {
|
||||
return (
|
||||
// biome-ignore lint/a11y: Composer meta triggers are visual pointer shortcuts; screen readers get the status summary and full runtime controls remain available through the toolbar and slash commands.
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={`codex-panel__composer-meta-trigger codex-panel__composer-meta-value codex-panel__composer-meta-${kind}`}
|
||||
className={[
|
||||
"codex-panel__composer-meta-trigger",
|
||||
"codex-panel__composer-meta-value",
|
||||
`codex-panel__composer-meta-${kind}`,
|
||||
disabled ? "is-disabled" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onMouseDown();
|
||||
if (!disabled) onMouseDown();
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
|
|
@ -436,7 +471,7 @@ interface ComposerSendMode {
|
|||
canInterrupt: boolean;
|
||||
}
|
||||
|
||||
function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string): ComposerSendMode {
|
||||
function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string, submissionDisabled: boolean): ComposerSendMode {
|
||||
const hasDraft = Boolean(draft.trim());
|
||||
const canSteer = canInterrupt && hasDraft;
|
||||
const interruptMode = canInterrupt && !hasDraft;
|
||||
|
|
@ -444,7 +479,7 @@ function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string):
|
|||
icon: interruptMode ? "square" : canSteer ? "corner-down-right" : "send",
|
||||
label: interruptMode ? "Interrupt" : canSteer ? "Steer" : "Send",
|
||||
className: interruptMode ? "is-interrupt" : canSteer ? "is-steer" : "",
|
||||
disabled: busy && !canInterrupt,
|
||||
disabled: submissionDisabled || (busy && !canInterrupt),
|
||||
canInterrupt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface ToolbarStatusSection {
|
|||
|
||||
export interface ToolbarViewModel {
|
||||
newChatDisabled: boolean;
|
||||
activeThreadChatActionsDisabled: boolean;
|
||||
chatActionsOpen: boolean;
|
||||
historyOpen: boolean;
|
||||
statusPanelOpen: boolean;
|
||||
|
|
@ -175,10 +176,20 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions
|
|||
actions.startSideChat?.();
|
||||
}}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.threads.every((thread) => !thread.selected)}
|
||||
disabled={model.activeThreadChatActionsDisabled || model.threads.every((thread) => !thread.selected)}
|
||||
/>
|
||||
<ToolbarPanelItem
|
||||
label="Compact context"
|
||||
onClick={actions.compactContext}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.activeThreadChatActionsDisabled}
|
||||
/>
|
||||
<ToolbarPanelItem
|
||||
label="Set goal..."
|
||||
onClick={actions.setGoal}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.activeThreadChatActionsDisabled}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -599,6 +599,7 @@ function thread(id: string, archived = false) {
|
|||
archived,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
provenance: { kind: "interactive" as const },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ function thread(id: string): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,50 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerRequestClient } from "../../src/app-server/services/request-client";
|
||||
import { forkEphemeralThread, listThreads, startEphemeralThread, startThread } from "../../src/app-server/services/threads";
|
||||
import {
|
||||
forkEphemeralThread,
|
||||
listThreads,
|
||||
startEphemeralThread,
|
||||
startThread,
|
||||
threadFromAppServerRecord,
|
||||
} from "../../src/app-server/services/threads";
|
||||
|
||||
describe("app-server thread response adapters", () => {
|
||||
it("preserves spawned subagent provenance in the domain thread", () => {
|
||||
const thread = threadFromAppServerRecord({
|
||||
id: "child",
|
||||
preview: "Inspect",
|
||||
name: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sessionId: "session",
|
||||
parentThreadId: "parent",
|
||||
source: {
|
||||
subAgent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: "parent",
|
||||
depth: 2,
|
||||
agent_path: null,
|
||||
agent_nickname: "Scout",
|
||||
agent_role: "explorer",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
});
|
||||
|
||||
expect(thread.provenance).toEqual({
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 2,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
});
|
||||
});
|
||||
|
||||
it("forks read-only ephemeral side-chat threads", async () => {
|
||||
const client = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
|
|
@ -90,7 +131,15 @@ describe("app-server thread response adapters", () => {
|
|||
} as unknown as AppServerRequestClient;
|
||||
|
||||
await expect(listThreads(client, "/vault", { archived: true })).resolves.toEqual([
|
||||
{ id: "thread-1", preview: "Preview", name: null, archived: true, createdAt: 10, updatedAt: 20 },
|
||||
{
|
||||
id: "thread-1",
|
||||
preview: "Preview",
|
||||
name: null,
|
||||
archived: true,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
]);
|
||||
expect(clientListThreads).toHaveBeenCalledWith("thread/list", {
|
||||
cwd: "/vault",
|
||||
|
|
@ -109,7 +158,16 @@ describe("app-server thread response adapters", () => {
|
|||
} as unknown as AppServerRequestClient;
|
||||
|
||||
await expect(listThreads(client, "/vault")).resolves.toEqual([
|
||||
{ id: "thread-1", preview: "Preview", name: null, archived: false, createdAt: 10, updatedAt: 20, recencyAt: 30 },
|
||||
{
|
||||
id: "thread-1",
|
||||
preview: "Preview",
|
||||
name: null,
|
||||
archived: false,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
recencyAt: 30,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -121,7 +179,16 @@ describe("app-server thread response adapters", () => {
|
|||
} as unknown as AppServerRequestClient;
|
||||
|
||||
await expect(listThreads(client, "/vault")).resolves.toEqual([
|
||||
{ id: "thread-1", preview: "Preview", name: null, archived: false, createdAt: 10, updatedAt: 20, recencyAt: null },
|
||||
{
|
||||
id: "thread-1",
|
||||
preview: "Preview",
|
||||
name: null,
|
||||
archived: false,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
recencyAt: null,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -141,8 +208,24 @@ describe("app-server thread response adapters", () => {
|
|||
} as unknown as AppServerRequestClient;
|
||||
|
||||
await expect(listThreads(client, "/vault")).resolves.toEqual([
|
||||
{ id: "thread-1", preview: "First", name: null, archived: false, createdAt: 10, updatedAt: 20 },
|
||||
{ id: "thread-2", preview: "Second", name: null, archived: false, createdAt: 30, updatedAt: 40 },
|
||||
{
|
||||
id: "thread-1",
|
||||
preview: "First",
|
||||
name: null,
|
||||
archived: false,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
{
|
||||
id: "thread-2",
|
||||
preview: "Second",
|
||||
name: null,
|
||||
archived: false,
|
||||
createdAt: 30,
|
||||
updatedAt: 40,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
]);
|
||||
expect(clientListThreads).toHaveBeenNthCalledWith(1, "thread/list", {
|
||||
cwd: "/vault",
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ function thread(
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
transcriptEntries: [],
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: "参照元",
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,5 +71,6 @@ function thread(options: Partial<Thread> & { id: string }): Thread {
|
|||
...(options.recencyAt === undefined ? {} : { recencyAt: options.recencyAt }),
|
||||
name: options.name ?? null,
|
||||
archived: false,
|
||||
provenance: options.provenance ?? { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1433,6 +1433,37 @@ describe("ChatInboundHandler", () => {
|
|||
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps subagent thread-started notifications out of the shared catalog", () => {
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const handler = handlerForState(chatStateFixture(), { applyThreadCatalogEvent });
|
||||
const child = appServerThread("child", "/workspace/active");
|
||||
|
||||
handler.handleNotification({
|
||||
method: "thread/started",
|
||||
params: {
|
||||
thread: {
|
||||
...child,
|
||||
parentThreadId: "parent",
|
||||
source: {
|
||||
subAgent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: "parent",
|
||||
depth: 1,
|
||||
agent_path: null,
|
||||
agent_nickname: "Scout",
|
||||
agent_role: "explorer",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
},
|
||||
},
|
||||
} satisfies Extract<ServerNotification, { method: "thread/started" }>);
|
||||
|
||||
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces optimistic user echoes when completed turns are reconciled", () => {
|
||||
let state = activeRunningState();
|
||||
state = withChatStateThreadStreamItems(state, [
|
||||
|
|
@ -2219,6 +2250,7 @@ function panelThread(id: string): PanelThread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,15 @@ describe("turn item conversion preserves app-server semantics", () => {
|
|||
|
||||
it("hides persisted /refer context in displayed user messages", () => {
|
||||
const { prompt: text } = referencedThreadPromptBundle(
|
||||
{ id: "thread-reference", name: "参照元", preview: "", archived: false, createdAt: 1, updatedAt: 1 } satisfies Thread,
|
||||
{
|
||||
id: "thread-reference",
|
||||
name: "参照元",
|
||||
preview: "",
|
||||
archived: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
provenance: { kind: "interactive" },
|
||||
} satisfies Thread,
|
||||
[
|
||||
{ userText: "元の依頼", assistantText: "元の回答" },
|
||||
{ userText: "次の依頼", assistantText: "次の回答" },
|
||||
|
|
|
|||
|
|
@ -446,7 +446,15 @@ describe("chat app-server transports", () => {
|
|||
});
|
||||
|
||||
const result = await resolver.referThread(
|
||||
{ id: "019abcde-0000-7000-8000-000000000001", preview: "", name: "Other", createdAt: 1, updatedAt: 1, archived: false },
|
||||
{
|
||||
id: "019abcde-0000-7000-8000-000000000001",
|
||||
preview: "",
|
||||
name: "Other",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
"summarize",
|
||||
inputSnapshot,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -291,6 +292,14 @@ describe("composer suggestions", () => {
|
|||
expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).toContain("/goal");
|
||||
});
|
||||
|
||||
it("omits slash suggestions from subagent threads", () => {
|
||||
const options = { activeThreadSubagent: true };
|
||||
expect(activeComposerSuggestions("/", notes, [], [], [], null, options)).toEqual([]);
|
||||
expect(activeComposerSuggestions("/permissions ", notes, [], [], [], null, options)).toEqual([]);
|
||||
expect(activeComposerSuggestions("/resume ", notes, [], [], [], null, options)).toEqual([]);
|
||||
expect(activeComposerSuggestions("/model ", notes, [], [], [], null, options)).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds selection preview text before rendering it in the suggestion list", () => {
|
||||
const suggestion = activeComposerSuggestions("@sel", notes, [], [], [], null, {
|
||||
contextReferences: {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,39 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
expect(messages).toEqual(["Permission changes are unavailable in side chats.", "Permission changes are unavailable in side chats."]);
|
||||
});
|
||||
|
||||
it("blocks all thread setting changes for subagent threads", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
activeThread: {
|
||||
id: "child",
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const store = createChatStateStore(state);
|
||||
const transport = settingsTransportFixture();
|
||||
const messages: string[] = [];
|
||||
const actions = runtimeActionsFixture(store, transport, messages);
|
||||
|
||||
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false);
|
||||
await expect(actions.requestReasoningEffort("high")).resolves.toBe(false);
|
||||
await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(false);
|
||||
await expect(actions.setCollaborationMode("plan")).resolves.toBe(false);
|
||||
await actions.enableFastMode();
|
||||
await actions.enableAutoReview();
|
||||
|
||||
expect(transport.updateThreadSettings).not.toHaveBeenCalled();
|
||||
expect(messages).toHaveLength(6);
|
||||
expect(new Set(messages)).toEqual(new Set(["Thread settings are unavailable in agent threads."]));
|
||||
});
|
||||
|
||||
it("reserves thread runtime settings when no thread is active", async () => {
|
||||
const store = createChatStateStore(chatStateFixture());
|
||||
const transport = settingsTransportFixture();
|
||||
|
|
|
|||
|
|
@ -88,6 +88,27 @@ describe("chat thread resume helpers", () => {
|
|||
});
|
||||
expect(action.listedThreads).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps resumed subagent threads out of the ordinary thread list", () => {
|
||||
const existing = threadFixture("existing", "Existing");
|
||||
const subagent = {
|
||||
...threadFixture("child", "Child"),
|
||||
provenance: {
|
||||
kind: "subagent" as const,
|
||||
subagentKind: "thread-spawn" as const,
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
},
|
||||
};
|
||||
|
||||
const action = resumedThreadAction({ response: responseFixture(subagent), listedThreads: [existing] });
|
||||
|
||||
expect(action.thread.provenance).toEqual(subagent.provenance);
|
||||
expect(action.listedThreads).toEqual([existing]);
|
||||
});
|
||||
});
|
||||
|
||||
function responseFixture(thread: Thread): ThreadActivationSnapshot {
|
||||
|
|
@ -119,6 +140,7 @@ function threadFixture(id: string, name: string): Thread {
|
|||
preview: "",
|
||||
name,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -884,5 +884,6 @@ function thread(id: string): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function thread(id: string, name: string | null = null): Thread {
|
|||
updatedAt: 0,
|
||||
name,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ function threadFixture(id: string): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,17 @@ describe("ephemeral thread lifecycle", () => {
|
|||
const store = createChatStateStore();
|
||||
store.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: [{ id: "source", preview: "Source", name: null, archived: false, createdAt: 1, updatedAt: 1 }],
|
||||
threads: [
|
||||
{
|
||||
id: "source",
|
||||
preview: "Source",
|
||||
name: null,
|
||||
archived: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const transport = transportMock();
|
||||
const lifecycle = createEphemeralThreadLifecycle({
|
||||
|
|
@ -151,7 +161,15 @@ function transportMock(): EphemeralThreadTransport {
|
|||
|
||||
function activationFixture(): ThreadActivationSnapshot {
|
||||
return {
|
||||
thread: { id: "side", preview: "", name: null, archived: false, createdAt: 1, updatedAt: 1 },
|
||||
thread: {
|
||||
id: "side",
|
||||
preview: "",
|
||||
name: null,
|
||||
archived: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
cwd: "/vault",
|
||||
model: "gpt-5.5",
|
||||
serviceTier: null,
|
||||
|
|
|
|||
|
|
@ -193,7 +193,15 @@ describe("createGoalActions", () => {
|
|||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: { id: "thread-new", name: null, preview: "Plan release", archived: false, createdAt: 1, updatedAt: 1 },
|
||||
thread: {
|
||||
id: "thread-new",
|
||||
name: null,
|
||||
preview: "Plan release",
|
||||
archived: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ function threadFixture(id: string): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ function panelThread(id: string): PanelThread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -599,6 +599,7 @@ function panelThread(id: string, overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type ThreadNavigationActionsHost,
|
||||
} from "../../../../../src/features/chat/application/threads/thread-navigation-actions";
|
||||
|
||||
function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
|
||||
function resumeThreadState(stateStore: ChatStateStore, threadId: string, subagent = false): void {
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
|
|
@ -17,7 +17,21 @@ function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
|
|||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: { id: threadId, cliVersion: "test" } as never,
|
||||
thread: {
|
||||
id: threadId,
|
||||
cliVersion: "test",
|
||||
provenance: subagent
|
||||
? {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
}
|
||||
: { kind: "interactive" },
|
||||
} as never,
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
|
|
@ -69,6 +83,17 @@ describe("ThreadNavigationActions", () => {
|
|||
expect(host.focusComposer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a blank chat while a subagent turn continues", async () => {
|
||||
const { actions, host, stateStore } = createActionsHarness();
|
||||
resumeThreadState(stateStore, "child", true);
|
||||
stateStore.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
|
||||
|
||||
await actions.startNewThread();
|
||||
|
||||
expect(host.identity.clearActiveThreadIdentity).toHaveBeenCalledOnce();
|
||||
expect(host.focusComposer).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("focuses an already open thread without resuming it", async () => {
|
||||
const { actions, host } = createActionsHarness({
|
||||
focusThreadInOpenView: vi.fn().mockResolvedValue(true),
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ function threadFixture(id: string, overrides: Partial<Thread> = {}): Thread {
|
|||
preview: "",
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
recencyAt: null,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,32 @@ function thread(id: string): Thread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
function createHost(draft: string) {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
function createHost(draft: string, options: { subagent?: boolean } = {}) {
|
||||
const initialState = createChatState();
|
||||
const stateStore = createChatStateStore(
|
||||
options.subagent
|
||||
? {
|
||||
...initialState,
|
||||
activeThread: {
|
||||
...initialState.activeThread,
|
||||
id: "child",
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
},
|
||||
},
|
||||
}
|
||||
: initialState,
|
||||
);
|
||||
const interruptTurn = vi.fn().mockResolvedValue({});
|
||||
const setDraft = vi.fn();
|
||||
const sendTurnText = vi.fn().mockResolvedValue(true);
|
||||
|
|
@ -62,6 +83,18 @@ function createHost(draft: string) {
|
|||
}
|
||||
|
||||
describe("submitComposer", () => {
|
||||
it.each(["hello", "/status"])("blocks composer submission from subagent threads for %s", async (draft) => {
|
||||
const { host, execute, sendTurnText } = createHost(draft, { subagent: true });
|
||||
|
||||
await submitComposer(host);
|
||||
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(sendTurnText).not.toHaveBeenCalled();
|
||||
expect(host.status.addSystemMessage).toHaveBeenCalledWith(
|
||||
"Messages and slash commands are unavailable in agent threads. Start a new chat to continue.",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends plain drafts as turn text", async () => {
|
||||
const { host, ensureConnected, inputSnapshot, sendTurnText, showLatest } = createHost("hello");
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function thread(id: string): Thread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function thread(id: string, name: string | null = null): Thread {
|
|||
updatedAt: 0,
|
||||
name,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ function thread(id: string): Thread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +76,52 @@ function resumeThread(stateStore: ReturnType<typeof createChatStateStore>) {
|
|||
});
|
||||
}
|
||||
|
||||
function resumeSubagentThread(stateStore: ReturnType<typeof createChatStateStore>) {
|
||||
const child: Thread = {
|
||||
...thread("child"),
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
},
|
||||
};
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: child,
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("TurnSubmissionActions", () => {
|
||||
it("blocks direct turn submission after a restored thread resolves to a subagent", async () => {
|
||||
const { host, startTurn, stateStore } = createHost({
|
||||
ensureRestoredThreadLoaded: vi.fn().mockImplementation(async () => {
|
||||
resumeSubagentThread(stateStore);
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
const actions = createTurnSubmissionActions(host);
|
||||
|
||||
await expect(actions.sendTurnText({ text: "hello" })).resolves.toBe(false);
|
||||
|
||||
expect(startTurn).not.toHaveBeenCalled();
|
||||
expect(host.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue.");
|
||||
});
|
||||
|
||||
it("starts a thread when needed and acknowledges the optimistic turn", async () => {
|
||||
const { host, startTurn, stateStore } = createHost();
|
||||
const actions = createTurnSubmissionActions(host);
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@ describe("createChatPanelSessionGraph actions", () => {
|
|||
preview: "",
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -884,6 +884,7 @@ function threadFromRecord(record: ThreadRecord): Thread {
|
|||
preview: record.preview,
|
||||
name: record.name,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
|
|
@ -922,6 +923,7 @@ function panelThread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -443,6 +443,7 @@ function shellParts(
|
|||
draft: model.draft.value,
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
normalPlaceholder: "Ask Codex...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,39 @@ import { withChatStateThreadStreamItems } from "../../support/thread-stream";
|
|||
installObsidianDomShims();
|
||||
|
||||
describe("chat panel surface projections", () => {
|
||||
it("disables subagent chat actions except starting a new chat", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
activeThread: {
|
||||
id: "child",
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
},
|
||||
},
|
||||
turn: { lifecycle: { kind: "running", turnId: "child-turn" } },
|
||||
ui: { toolbarPanel: "chat-actions" },
|
||||
});
|
||||
const actions = toolbarActionsFixture();
|
||||
const parent = renderWithShellReadModel(state, (readModel) =>
|
||||
h(ChatPanelToolbar, { model: readModel.toolbar, surface: toolbarSurfaceFixture(), actions }),
|
||||
);
|
||||
|
||||
const items = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")];
|
||||
expect(items.map((item) => [item.textContent, item.classList.contains("is-disabled")])).toEqual([
|
||||
["Start new chat", false],
|
||||
["Start side chat", true],
|
||||
["Compact context", true],
|
||||
["Set goal...", true],
|
||||
]);
|
||||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("does not project rollback actions for side chats", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
|
|
@ -571,6 +604,7 @@ function threadFixture(id: string, name: string | null): Thread {
|
|||
updatedAt: 1,
|
||||
name,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,15 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: { id: "thread-1", preview: "", archived: false, createdAt: 1, updatedAt: 1, name: "Thread" },
|
||||
thread: {
|
||||
id: "thread-1",
|
||||
preview: "",
|
||||
archived: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: "Thread",
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
|
|||
draft: "",
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
normalPlaceholder: "Ask Codex...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
|
|
@ -215,6 +216,7 @@ function threadFixture(id: string, name: string): Thread {
|
|||
name,
|
||||
preview: "",
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function mountComposerShell(
|
|||
draft,
|
||||
busy,
|
||||
canInterrupt,
|
||||
submissionDisabled: false,
|
||||
normalPlaceholder,
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
|
|
@ -413,6 +414,7 @@ describe("ComposerShell decisions", () => {
|
|||
draft: "line one\nline two",
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
normalPlaceholder: "Ask Codex...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
|
|
@ -450,6 +452,7 @@ describe("ComposerShell decisions", () => {
|
|||
draft: "",
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
normalPlaceholder: "Ask Codex...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ describe("Toolbar decisions", () => {
|
|||
function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewModel {
|
||||
return {
|
||||
newChatDisabled: false,
|
||||
activeThreadChatActionsDisabled: false,
|
||||
chatActionsOpen: false,
|
||||
historyOpen: false,
|
||||
statusPanelOpen: false,
|
||||
|
|
|
|||
|
|
@ -152,5 +152,6 @@ function thread(options: Partial<Thread> & { id: string }): Thread {
|
|||
...(options.recencyAt === undefined ? {} : { recencyAt: options.recencyAt }),
|
||||
name: options.name ?? null,
|
||||
archived: false,
|
||||
provenance: options.provenance ?? { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function threadFixture(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
preview: "",
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -665,6 +665,7 @@ function threadFromRecord(record: Record<string, unknown>): Thread {
|
|||
preview: typeof record["preview"] === "string" ? record["preview"] : "",
|
||||
name: typeof record["name"] === "string" ? record["name"] : null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
createdAt: Number(record["createdAt"] ?? 0),
|
||||
updatedAt: Number(record["updatedAt"] ?? 0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -587,6 +587,7 @@ function thread(id: string, archived = false, overrides: Partial<Thread> = {}):
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
archived: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ function thread(overrides: Partial<ArchiveThreadInput> = {}): ArchiveThreadInput
|
|||
updatedAt: 1,
|
||||
name: "Thread",
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
transcriptEntries: [transcriptEntry("user", "Hello", 1)],
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -893,6 +893,7 @@ function thread(id: string): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -898,6 +898,7 @@ function panelThread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue