mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Localize chat side effects and decisions
This commit is contained in:
parent
d3f62d8228
commit
29fa227fac
25 changed files with 427 additions and 275 deletions
|
|
@ -336,11 +336,11 @@ export class AppServerQueryCache {
|
|||
}> {
|
||||
try {
|
||||
const data = cloneModelMetadata(await this.client.fetchQuery(this.modelsQueryOptionsWithClient(context, client)));
|
||||
return { data, probe: diagnosticProbeOk("model/list", `${String(data.length)} models`) };
|
||||
return { data, probe: diagnosticProbeOk("model/list", `${String(data.length)} models`, Date.now()) };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: this.modelsSnapshot(context) ?? [],
|
||||
probe: diagnosticProbeError("model/list", error),
|
||||
probe: diagnosticProbeError("model/list", error, Date.now()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ export async function readSkillMetadataProbe(
|
|||
if (!client) return disconnectedSkillsResult();
|
||||
try {
|
||||
const catalog = await listSkillCatalog(client, vaultPath, { forceReload });
|
||||
return { data: catalog.skills, probe: diagnosticProbeOk("skills/list", `${String(catalog.totalCount)} skills`) };
|
||||
return { data: catalog.skills, probe: diagnosticProbeOk("skills/list", `${String(catalog.totalCount)} skills`, Date.now()) };
|
||||
} catch (error) {
|
||||
return { data: [], probe: diagnosticProbeError("skills/list", error) };
|
||||
return { data: [], probe: diagnosticProbeError("skills/list", error, Date.now()) };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,20 +33,20 @@ export async function readRateLimitMetadataProbe(client: AppServerClient | null)
|
|||
const response = await client.readAccountRateLimits();
|
||||
return {
|
||||
data: rateLimitSnapshotFromAccountRateLimitsResponse(response),
|
||||
probe: diagnosticProbeOk("account/rateLimits/read", accountRateLimitsSummaryFromResponse(response)),
|
||||
probe: diagnosticProbeOk("account/rateLimits/read", accountRateLimitsSummaryFromResponse(response), Date.now()),
|
||||
};
|
||||
} catch (error) {
|
||||
return { data: null, probe: diagnosticProbeError("account/rateLimits/read", error) };
|
||||
return { data: null, probe: diagnosticProbeError("account/rateLimits/read", error, Date.now()) };
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectedSkillsResult(): SkillMetadataProbeResult {
|
||||
return { data: [], probe: diagnosticProbeError("skills/list", new Error("Codex app-server is not connected.")) };
|
||||
return { data: [], probe: diagnosticProbeError("skills/list", new Error("Codex app-server is not connected."), Date.now()) };
|
||||
}
|
||||
|
||||
function disconnectedRateLimitResult(): RateLimitMetadataProbeResult {
|
||||
return {
|
||||
data: null,
|
||||
probe: diagnosticProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")),
|
||||
probe: diagnosticProbeError("account/rateLimits/read", new Error("Codex app-server is not connected."), Date.now()),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,11 +89,7 @@ function createDiagnosticProbeResult(method: DiagnosticProbeMethod): DiagnosticP
|
|||
};
|
||||
}
|
||||
|
||||
export function diagnosticProbeOk(
|
||||
method: DiagnosticProbeMethod,
|
||||
summary: string | null = null,
|
||||
checkedAt = Date.now(),
|
||||
): DiagnosticProbeResult {
|
||||
export function diagnosticProbeOk(method: DiagnosticProbeMethod, summary: string | null, checkedAt: number): DiagnosticProbeResult {
|
||||
return {
|
||||
method,
|
||||
status: "ok",
|
||||
|
|
@ -103,7 +99,7 @@ export function diagnosticProbeOk(
|
|||
};
|
||||
}
|
||||
|
||||
export function diagnosticProbeError(method: DiagnosticProbeMethod, error: unknown, checkedAt = Date.now()): DiagnosticProbeResult {
|
||||
export function diagnosticProbeError(method: DiagnosticProbeMethod, error: unknown, checkedAt: number): DiagnosticProbeResult {
|
||||
return {
|
||||
method,
|
||||
status: "failed",
|
||||
|
|
|
|||
|
|
@ -187,11 +187,11 @@ async function probeDiagnostic<T>(
|
|||
const statuses = mcpServerStatuses?.(response);
|
||||
return {
|
||||
method,
|
||||
probe: diagnosticProbeOk(method, summarize(response)),
|
||||
probe: diagnosticProbeOk(method, summarize(response), Date.now()),
|
||||
...(statuses ? { mcpServerStatuses: statuses } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return { method, probe: diagnosticProbeError(method, error) };
|
||||
return { method, probe: diagnosticProbeError(method, error, Date.now()) };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { RequestId, ServerNotification, ServerRequest } from "../../../../a
|
|||
import type { McpServerStartupStatus } from "../../../../domain/server/diagnostics";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadConversationSummary } from "../../../../domain/threads/transcript";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import type { LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import { classifyAppServerLog } from "./app-server-logs";
|
||||
import { activeTurnId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
|
|
@ -51,11 +51,10 @@ export interface ChatInboundControllerActions {
|
|||
}
|
||||
|
||||
export class ChatInboundController {
|
||||
private readonly localItemIds: LocalIdSource = createLocalIdSource();
|
||||
|
||||
constructor(
|
||||
private readonly store: ChatStateStore,
|
||||
private readonly actions: ChatInboundControllerActions,
|
||||
private readonly localItemIds: LocalIdSource,
|
||||
) {}
|
||||
|
||||
private get state(): ChatState {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import type { MessageStreamNoticeSection } from "../../domain/message-stream/items";
|
||||
import type { LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
|
||||
import { activeThreadId, canImplementPlanItemId } from "../state/selectors";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
|
|
@ -15,6 +16,7 @@ const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
|
|||
export interface ConversationTurnActionsContext {
|
||||
vaultPath: string;
|
||||
stateStore: ChatStateStore;
|
||||
localItemIds: LocalIdSource;
|
||||
client: {
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
|
|
@ -81,10 +83,11 @@ export function createConversationTurnActions(
|
|||
context: ConversationTurnActionsContext,
|
||||
refs: ConversationTurnActionsRefs,
|
||||
): ConversationTurnActions {
|
||||
const { vaultPath, stateStore, client, status, runtime, thread, composer, scroll } = context;
|
||||
const { vaultPath, stateStore, localItemIds, client, status, runtime, thread, composer, scroll } = context;
|
||||
const turnSubmission = createTurnSubmissionActions({
|
||||
stateStore,
|
||||
vaultPath,
|
||||
localItemIds,
|
||||
currentClient: client.currentClient,
|
||||
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
|
||||
startThread: (preview) => refs.threadStarter.startThread(preview),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import type { LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import { submissionStateSnapshot } from "../state/selectors";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import {
|
||||
|
|
@ -18,6 +18,7 @@ const STATUS_STEERED_CURRENT_TURN = "Steered current turn.";
|
|||
export interface TurnSubmissionActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
localItemIds: LocalIdSource;
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureRestoredThreadLoaded: () => Promise<boolean>;
|
||||
startThread: (preview?: string) => Promise<unknown>;
|
||||
|
|
@ -30,6 +31,14 @@ export interface TurnSubmissionActionsHost {
|
|||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
type TurnSubmissionSnapshot = ReturnType<typeof submissionStateSnapshot>;
|
||||
|
||||
type TurnSubmissionPlan =
|
||||
| { kind: "blocked"; message: string }
|
||||
| { kind: "steer"; threadId: string; turnId: string }
|
||||
| { kind: "start-thread-then-turn" }
|
||||
| { kind: "start-turn"; threadId: string };
|
||||
|
||||
function currentTurnNotSteerableMessage(): string {
|
||||
return "Current turn is not steerable yet.";
|
||||
}
|
||||
|
|
@ -39,11 +48,9 @@ export interface TurnSubmissionActions {
|
|||
}
|
||||
|
||||
export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions {
|
||||
const localItemIds = createLocalIdSource();
|
||||
|
||||
return {
|
||||
sendTurnText: (text, codexInputOverride, referencedThread) =>
|
||||
sendTurnText(host, localItemIds, text, codexInputOverride, referencedThread),
|
||||
sendTurnText(host, host.localItemIds, text, codexInputOverride, referencedThread),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,20 +66,24 @@ async function sendTurnText(
|
|||
if (!client) return;
|
||||
|
||||
const initialState = submissionStateSnapshot(host.stateStore.getState());
|
||||
if (initialState.busy) {
|
||||
await steerCurrentTurn(host, localItemIds, client, text, codexInputOverride, referencedThread);
|
||||
return;
|
||||
}
|
||||
const plan = planTurnSubmission(initialState);
|
||||
|
||||
let optimisticUserId: string | null = null;
|
||||
try {
|
||||
if (!initialState.activeThreadId) {
|
||||
const threadResponse = await host.startThread(text);
|
||||
if (!threadResponse) return;
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
host.resetThreadTurnPresence(false);
|
||||
switch (plan.kind) {
|
||||
case "blocked":
|
||||
host.addSystemMessage(plan.message);
|
||||
return;
|
||||
case "steer":
|
||||
await steerCurrentTurn(host, localItemIds, client, plan, text, codexInputOverride, referencedThread);
|
||||
return;
|
||||
case "start-thread-then-turn":
|
||||
if (!(await startThreadForTurn(host, text))) return;
|
||||
break;
|
||||
case "start-turn":
|
||||
break;
|
||||
}
|
||||
const activeThreadId = submissionStateSnapshot(host.stateStore.getState()).activeThreadId;
|
||||
const activeThreadId = plan.kind === "start-turn" ? plan.threadId : submissionStateSnapshot(host.stateStore.getState()).activeThreadId;
|
||||
if (!activeThreadId) return;
|
||||
if (!(await host.applyPendingThreadSettings())) return;
|
||||
|
||||
|
|
@ -133,42 +144,52 @@ async function sendTurnText(
|
|||
}
|
||||
}
|
||||
|
||||
function planTurnSubmission(state: TurnSubmissionSnapshot): TurnSubmissionPlan {
|
||||
if (state.busy) {
|
||||
return state.activeThreadId && state.activeTurnId
|
||||
? { kind: "steer", threadId: state.activeThreadId, turnId: state.activeTurnId }
|
||||
: { kind: "blocked", message: currentTurnNotSteerableMessage() };
|
||||
}
|
||||
return state.activeThreadId ? { kind: "start-turn", threadId: state.activeThreadId } : { kind: "start-thread-then-turn" };
|
||||
}
|
||||
|
||||
async function startThreadForTurn(host: TurnSubmissionActionsHost, text: string): Promise<boolean> {
|
||||
const threadResponse = await host.startThread(text);
|
||||
if (!threadResponse) return false;
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
host.resetThreadTurnPresence(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function steerCurrentTurn(
|
||||
host: TurnSubmissionActionsHost,
|
||||
localItemIds: LocalIdSource,
|
||||
client: AppServerClient,
|
||||
plan: Extract<TurnSubmissionPlan, { kind: "steer" }>,
|
||||
text: string,
|
||||
codexInputOverride?: CodexInput,
|
||||
referencedThread?: ReferencedThreadMetadata,
|
||||
): Promise<void> {
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
const threadId = state.activeThreadId;
|
||||
const expectedTurnId = state.activeTurnId;
|
||||
if (!threadId || !expectedTurnId) {
|
||||
host.addSystemMessage(currentTurnNotSteerableMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
const codexInput = codexInputOverride ?? host.codexInput(text);
|
||||
const localSteerId = localItemIds.next("local-steer");
|
||||
host.setDraft("", { clearSuggestions: true });
|
||||
|
||||
try {
|
||||
await client.steerTurn(threadId, expectedTurnId, codexInput, localSteerId);
|
||||
if (!isCurrentTurn(host, threadId, expectedTurnId)) return;
|
||||
await client.steerTurn(plan.threadId, plan.turnId, codexInput, localSteerId);
|
||||
if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return;
|
||||
host.stateStore.dispatch({
|
||||
type: "message-stream/item-added",
|
||||
item: localUserMessageItemFromInput({
|
||||
id: localSteerId,
|
||||
text,
|
||||
turnId: expectedTurnId,
|
||||
turnId: plan.turnId,
|
||||
referencedThread,
|
||||
codexInput,
|
||||
}),
|
||||
});
|
||||
host.setStatus(STATUS_STEERED_CURRENT_TURN);
|
||||
} catch (error) {
|
||||
if (!isCurrentTurn(host, threadId, expectedTurnId)) return;
|
||||
if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return;
|
||||
host.setDraft(text, { focus: true });
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads";
|
||||
import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import type { LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { GoalMessageStreamItem } from "../../domain/message-stream/items";
|
||||
import { goalChangeItem } from "../../domain/message-stream/factories/goal-items";
|
||||
|
|
@ -9,6 +9,7 @@ import { goalChangeItem } from "../../domain/message-stream/factories/goal-items
|
|||
export interface ThreadGoalSyncHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
localItemIds: LocalIdSource;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addGoalEvent: (item: GoalMessageStreamItem) => void;
|
||||
refreshLiveState: () => void;
|
||||
|
|
@ -16,6 +17,7 @@ export interface ThreadGoalSyncHost {
|
|||
|
||||
export interface GoalActionsHost extends ThreadGoalSyncHost {
|
||||
ensureConnected: () => Promise<void>;
|
||||
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
|
||||
}
|
||||
|
||||
export interface ThreadGoalSyncActions {
|
||||
|
|
@ -24,79 +26,111 @@ export interface ThreadGoalSyncActions {
|
|||
|
||||
export interface GoalActions extends ThreadGoalSyncActions {
|
||||
activeGoal: () => ThreadGoal | null;
|
||||
saveObjective: (objective: string, tokenBudget: number | null) => Promise<boolean>;
|
||||
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
|
||||
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
|
||||
clear: (threadId: string) => Promise<boolean>;
|
||||
startEditingCurrent: () => void;
|
||||
startEditing: (threadId: string | null, objective: string, tokenBudget: number | null) => void;
|
||||
updateObjectiveDraft: (objective: string) => void;
|
||||
setObjectiveExpanded: (threadId: string, expanded: boolean) => void;
|
||||
closeEditor: () => void;
|
||||
}
|
||||
|
||||
type GoalObjectiveSavePlan =
|
||||
| { kind: "reject"; message: string }
|
||||
| { kind: "save-existing"; threadId: string; objective: string; tokenBudget: number | null }
|
||||
| { kind: "start-thread-and-save"; objective: string; tokenBudget: number | null };
|
||||
|
||||
function emptyGoalObjectiveMessage(): string {
|
||||
return "Goal objective cannot be empty.";
|
||||
}
|
||||
|
||||
export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoalSyncActions {
|
||||
const localItemIds = createLocalIdSource();
|
||||
return {
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId),
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
|
||||
};
|
||||
}
|
||||
|
||||
export function createGoalActions(host: GoalActionsHost): GoalActions {
|
||||
const localItemIds = createLocalIdSource();
|
||||
return {
|
||||
activeGoal: () => host.stateStore.getState().activeThread.goal,
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId),
|
||||
setObjective: (threadId, objective, tokenBudget) => setObjective(host, localItemIds, threadId, objective, tokenBudget),
|
||||
setStatus: (threadId, status) => setGoalStatus(host, localItemIds, threadId, status),
|
||||
clear: (threadId) => clearGoal(host, localItemIds, threadId),
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
|
||||
saveObjective: (objective, tokenBudget) => saveObjective(host, objective, tokenBudget),
|
||||
setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget),
|
||||
setStatus: (threadId, status) => setGoalStatus(host, threadId, status),
|
||||
clear: (threadId) => clearGoal(host, threadId),
|
||||
startEditingCurrent: () => {
|
||||
startEditingCurrent(host);
|
||||
},
|
||||
startEditing: (threadId, objective, tokenBudget) => {
|
||||
startEditing(host, threadId, objective, tokenBudget);
|
||||
},
|
||||
updateObjectiveDraft: (objective) => {
|
||||
host.stateStore.dispatch({ type: "ui/goal-editor-draft-updated", objective });
|
||||
},
|
||||
setObjectiveExpanded: (threadId, expanded) => {
|
||||
host.stateStore.dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded });
|
||||
},
|
||||
closeEditor: () => {
|
||||
host.stateStore.dispatch({ type: "ui/goal-editor-closed" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function syncThreadGoal(host: ThreadGoalSyncHost, localItemIds: LocalIdSource, threadId: string): Promise<void> {
|
||||
async function syncThreadGoal(host: ThreadGoalSyncHost, threadId: string): Promise<void> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
applyGoalIfActive(host, localItemIds, threadId, await readThreadGoal(client, threadId), { reportChange: false });
|
||||
applyGoalIfActive(host, threadId, await readThreadGoal(client, threadId), { reportChange: false });
|
||||
} catch (error) {
|
||||
addThreadScopedSystemMessage(host, threadId, `Could not load thread goal: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setObjective(
|
||||
host: GoalActionsHost,
|
||||
localItemIds: LocalIdSource,
|
||||
threadId: string,
|
||||
objective: string,
|
||||
tokenBudget: number | null,
|
||||
): Promise<boolean> {
|
||||
const trimmed = objective.trim();
|
||||
if (!trimmed) {
|
||||
async function setObjective(host: GoalActionsHost, threadId: string, objective: string, tokenBudget: number | null): Promise<boolean> {
|
||||
const normalized = normalizedGoalObjective(objective);
|
||||
if (!normalized) {
|
||||
host.addSystemMessage(emptyGoalObjectiveMessage());
|
||||
return false;
|
||||
}
|
||||
const current = host.stateStore.getState().activeThread.goal;
|
||||
const isNewGoal = current === null;
|
||||
const applied = await setGoal(host, localItemIds, threadId, {
|
||||
objective: trimmed,
|
||||
const applied = await setGoal(host, threadId, {
|
||||
objective: normalized,
|
||||
status: current?.status ?? "active",
|
||||
tokenBudget,
|
||||
});
|
||||
if (applied && isNewGoal) {
|
||||
await recordGoalUserMessage(host, threadId, trimmed);
|
||||
await recordGoalUserMessage(host, threadId, normalized);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
function setGoalStatus(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
|
||||
return setGoal(host, localItemIds, threadId, { status });
|
||||
async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise<boolean> {
|
||||
const plan = planGoalObjectiveSave(host.stateStore.getState().activeThread.id, objective, tokenBudget);
|
||||
switch (plan.kind) {
|
||||
case "reject":
|
||||
host.addSystemMessage(plan.message);
|
||||
return false;
|
||||
case "save-existing":
|
||||
return setObjective(host, plan.threadId, plan.objective, plan.tokenBudget);
|
||||
case "start-thread-and-save":
|
||||
return startThreadAndSaveObjective(host, plan);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string): Promise<boolean> {
|
||||
function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
|
||||
return setGoal(host, threadId, { status });
|
||||
}
|
||||
|
||||
async function clearGoal(host: GoalActionsHost, threadId: string): Promise<boolean> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
await client.clearThreadGoal(threadId);
|
||||
applyGoalIfActive(host, localItemIds, threadId, null, { reportChange: true });
|
||||
applyGoalIfActive(host, threadId, null, { reportChange: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
addThreadScopedSystemMessage(host, threadId, errorMessage(error));
|
||||
|
|
@ -104,12 +138,12 @@ async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, thr
|
|||
}
|
||||
}
|
||||
|
||||
async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
|
||||
async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
return applyGoalIfActive(host, localItemIds, threadId, await setThreadGoal(client, threadId, params), { reportChange: true });
|
||||
return applyGoalIfActive(host, threadId, await setThreadGoal(client, threadId, params), { reportChange: true });
|
||||
} catch (error) {
|
||||
addThreadScopedSystemMessage(host, threadId, errorMessage(error));
|
||||
return false;
|
||||
|
|
@ -118,20 +152,57 @@ async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threa
|
|||
|
||||
function applyGoalIfActive(
|
||||
host: ThreadGoalSyncHost,
|
||||
localItemIds: LocalIdSource,
|
||||
threadId: string,
|
||||
goal: ThreadGoal | null,
|
||||
options: { reportChange: boolean },
|
||||
): boolean {
|
||||
const state = host.stateStore.getState();
|
||||
if (state.activeThread.id !== threadId) return false;
|
||||
const item = options.reportChange ? goalChangeItem(localItemIds.next("goal"), state.activeThread.goal, goal) : null;
|
||||
const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), state.activeThread.goal, goal) : null;
|
||||
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
|
||||
if (item) host.addGoalEvent(item);
|
||||
host.refreshLiveState();
|
||||
return true;
|
||||
}
|
||||
|
||||
function startEditingCurrent(host: GoalActionsHost): void {
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const goal = host.stateStore.getState().activeThread.goal;
|
||||
startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null);
|
||||
}
|
||||
|
||||
function startEditing(host: GoalActionsHost, threadId: string | null, objective: string, tokenBudget: number | null): void {
|
||||
host.stateStore.dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
||||
}
|
||||
|
||||
function planGoalObjectiveSave(activeThreadId: string | null, objective: string, tokenBudget: number | null): GoalObjectiveSavePlan {
|
||||
const normalized = normalizedGoalObjective(objective);
|
||||
if (!normalized) return { kind: "reject", message: emptyGoalObjectiveMessage() };
|
||||
return activeThreadId
|
||||
? { kind: "save-existing", threadId: activeThreadId, objective: normalized, tokenBudget }
|
||||
: { kind: "start-thread-and-save", objective: normalized, tokenBudget };
|
||||
}
|
||||
|
||||
function normalizedGoalObjective(objective: string): string | null {
|
||||
const trimmed = objective.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
async function startThreadAndSaveObjective(
|
||||
host: GoalActionsHost,
|
||||
plan: Extract<GoalObjectiveSavePlan, { kind: "start-thread-and-save" }>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await host.ensureConnected();
|
||||
const response = await host.startThread(plan.objective, { syncGoal: false });
|
||||
const threadId = response?.threadId ?? null;
|
||||
return threadId ? await setObjective(host, threadId, plan.objective, plan.tokenBudget) : false;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(errorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise<void> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface ThreadManagementActionsHost {
|
|||
}
|
||||
|
||||
export interface ThreadManagementActions {
|
||||
compactActiveThread: () => Promise<void>;
|
||||
compactThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
|
||||
forkThread: (threadId: string) => Promise<void>;
|
||||
|
|
@ -40,6 +41,7 @@ export interface ThreadManagementActions {
|
|||
|
||||
export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
|
||||
return {
|
||||
compactActiveThread: () => compactActiveThread(host),
|
||||
compactThread: (threadId) => compactThread(host, threadId),
|
||||
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
|
||||
forkThread: (threadId) => forkThread(host, threadId),
|
||||
|
|
@ -81,10 +83,23 @@ function noCompletedTurnToRollbackMessage(): string {
|
|||
return "No completed turn to roll back.";
|
||||
}
|
||||
|
||||
function noActiveThreadToCompactMessage(): string {
|
||||
return "No active thread to compact.";
|
||||
}
|
||||
|
||||
function rollbackCompletedMessage(): string {
|
||||
return "Rolled back the latest turn. Local file changes were not reverted.";
|
||||
}
|
||||
|
||||
async function compactActiveThread(host: ThreadManagementActionsHost): Promise<void> {
|
||||
const threadId = threadManagementState(host).activeThread.id;
|
||||
if (!threadId) {
|
||||
host.addSystemMessage(noActiveThreadToCompactMessage());
|
||||
return;
|
||||
}
|
||||
await compactThread(host, threadId);
|
||||
}
|
||||
|
||||
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { AppServerClient } from "../../../app-server/connection/client";
|
|||
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
|
||||
import type { LocalIdSource } from "../../../shared/id/local-id";
|
||||
import {
|
||||
createChatConnectionController,
|
||||
handleChatConnectionExit,
|
||||
|
|
@ -35,6 +36,7 @@ interface ChatPanelConnectionStatus {
|
|||
interface ChatPanelConnectionBundleInput {
|
||||
connection: ConnectionManager;
|
||||
currentClient: CurrentAppServerClient;
|
||||
localItemIds: LocalIdSource;
|
||||
status: ChatPanelConnectionStatus;
|
||||
goalSync: ChatPanelGoalSyncActions;
|
||||
autoTitle: AutoTitleActions;
|
||||
|
|
@ -95,7 +97,7 @@ export function createConnectionBundle(
|
|||
input: ChatPanelConnectionBundleInput,
|
||||
): ChatPanelConnectionBundle {
|
||||
const { environment, stateStore } = host;
|
||||
const { connection, currentClient, status, goalSync, autoTitle } = input;
|
||||
const { connection, currentClient, localItemIds, status, goalSync, autoTitle } = input;
|
||||
const serverMetadata = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
|
|
@ -133,38 +135,42 @@ export function createConnectionBundle(
|
|||
status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
};
|
||||
const inboundController = new ChatInboundController(stateStore, {
|
||||
refreshActiveThreads: () => {
|
||||
refreshSharedThreadsQuietly();
|
||||
const inboundController = new ChatInboundController(
|
||||
stateStore,
|
||||
{
|
||||
refreshActiveThreads: () => {
|
||||
refreshSharedThreadsQuietly();
|
||||
},
|
||||
refreshRateLimits: () => {
|
||||
void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true });
|
||||
},
|
||||
refreshSkills: (forceReload) => void serverMetadata.refreshSkills(forceReload),
|
||||
applyAppServerMetadataSnapshot: () => {
|
||||
serverMetadata.applyAppServerMetadataSnapshot();
|
||||
},
|
||||
maybeNameThread: (threadId, turnId, completedSummary) => {
|
||||
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
|
||||
},
|
||||
upsertActiveThread: (thread) => {
|
||||
environment.plugin.threadCatalog.upsertFromAppServer(thread);
|
||||
},
|
||||
applyThreadArchived: (threadId) => {
|
||||
environment.plugin.threadCatalog.recordThreadArchived(threadId);
|
||||
},
|
||||
recordActiveThreadDeleted: (threadId) => {
|
||||
environment.plugin.threadCatalog.recordThreadDeleted(threadId);
|
||||
},
|
||||
applyThreadRenamed: (threadId, name) => {
|
||||
environment.plugin.threadCatalog.recordThreadRenamed(threadId, name);
|
||||
},
|
||||
recordMcpStartupStatus: (name, mcpStatus, message) => {
|
||||
serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message);
|
||||
},
|
||||
respondToServerRequest: (requestId, result) => respondToCurrentServerRequest(currentClient, requestId, result),
|
||||
rejectServerRequest: (requestId, code, message) => rejectCurrentServerRequest(currentClient, requestId, code, message),
|
||||
},
|
||||
refreshRateLimits: () => {
|
||||
void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true });
|
||||
},
|
||||
refreshSkills: (forceReload) => void serverMetadata.refreshSkills(forceReload),
|
||||
applyAppServerMetadataSnapshot: () => {
|
||||
serverMetadata.applyAppServerMetadataSnapshot();
|
||||
},
|
||||
maybeNameThread: (threadId, turnId, completedSummary) => {
|
||||
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
|
||||
},
|
||||
upsertActiveThread: (thread) => {
|
||||
environment.plugin.threadCatalog.upsertFromAppServer(thread);
|
||||
},
|
||||
applyThreadArchived: (threadId) => {
|
||||
environment.plugin.threadCatalog.recordThreadArchived(threadId);
|
||||
},
|
||||
recordActiveThreadDeleted: (threadId) => {
|
||||
environment.plugin.threadCatalog.recordThreadDeleted(threadId);
|
||||
},
|
||||
applyThreadRenamed: (threadId, name) => {
|
||||
environment.plugin.threadCatalog.recordThreadRenamed(threadId, name);
|
||||
},
|
||||
recordMcpStartupStatus: (name, mcpStatus, message) => {
|
||||
serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message);
|
||||
},
|
||||
respondToServerRequest: (requestId, result) => respondToCurrentServerRequest(currentClient, requestId, result),
|
||||
rejectServerRequest: (requestId, code, message) => rejectCurrentServerRequest(currentClient, requestId, code, message),
|
||||
});
|
||||
localItemIds,
|
||||
);
|
||||
const connectionExitHost = {
|
||||
stateStore,
|
||||
connectionWork: host.connectionWork,
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
host.resumeWork.invalidate();
|
||||
history.invalidate();
|
||||
};
|
||||
const goalSync = createSessionGoalSyncActions(host, currentClient, status);
|
||||
const goalSync = createSessionGoalSyncActions(host, currentClient, localItemIds, status);
|
||||
const serverParts = createConnectionBundle(
|
||||
{
|
||||
environment,
|
||||
|
|
@ -198,6 +198,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
{
|
||||
connection,
|
||||
currentClient,
|
||||
localItemIds,
|
||||
goalSync,
|
||||
autoTitle,
|
||||
status,
|
||||
|
|
@ -213,7 +214,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
const refreshActiveThreads = () => connectionController.refreshActiveThreads();
|
||||
const threadOperations = createSessionThreadOperations(environment, currentClient);
|
||||
const runtimeSettings = createSessionRuntimeSettingsActions(host, currentClient, status);
|
||||
const goals = createSessionGoalActions(host, currentClient, ensureConnected, status);
|
||||
const goals = createSessionGoalActions(host, currentClient, localItemIds, ensureConnected, status, serverThreads);
|
||||
const rename = createSessionThreadRenameEditorActions(stateStore, threadOperations, titleService, ensureConnected, status);
|
||||
const threadLifecycle = createSessionThreadLifecycle(
|
||||
host,
|
||||
|
|
@ -249,6 +250,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
});
|
||||
const composerAndTurn = createComposerAndTurnActions(host, {
|
||||
connection,
|
||||
localItemIds,
|
||||
ensureConnected,
|
||||
currentClient,
|
||||
status,
|
||||
|
|
@ -274,8 +276,6 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
const surfaceAndPresenter = createSurfacesAndPresenter(host, {
|
||||
connection,
|
||||
connectionController,
|
||||
inboundController,
|
||||
serverThreads,
|
||||
goals,
|
||||
rename,
|
||||
threadActions: threadActionParts.actions,
|
||||
|
|
@ -471,11 +471,13 @@ function createSessionHistoryController(
|
|||
function createSessionGoalSyncActions(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
localItemIds: LocalIdSource,
|
||||
status: ChatPanelSessionStatus,
|
||||
): ChatPanelGoalSyncActions {
|
||||
return createThreadGoalSyncActions({
|
||||
stateStore: host.stateStore,
|
||||
currentClient,
|
||||
localItemIds,
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
|
|
@ -537,13 +539,17 @@ function createSessionRuntimeSettingsActions(
|
|||
function createSessionGoalActions(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
localItemIds: LocalIdSource,
|
||||
ensureConnected: () => Promise<void>,
|
||||
status: ChatPanelSessionStatus,
|
||||
serverThreads: ChatServerThreadActions,
|
||||
): ChatPanelGoalActions {
|
||||
return createGoalActions({
|
||||
stateStore: host.stateStore,
|
||||
currentClient,
|
||||
localItemIds,
|
||||
ensureConnected,
|
||||
startThread: (preview, options) => serverThreads.startThread(preview, options),
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
|
|
@ -728,6 +734,7 @@ function createComposerAndTurnActions(
|
|||
host: ChatPanelSessionGraphHost,
|
||||
input: {
|
||||
connection: ConnectionManager;
|
||||
localItemIds: LocalIdSource;
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: CurrentAppServerClient;
|
||||
status: ChatPanelSessionStatus;
|
||||
|
|
@ -753,6 +760,7 @@ function createComposerAndTurnActions(
|
|||
): ChatPanelComposerAndTurnParts {
|
||||
const {
|
||||
connection,
|
||||
localItemIds,
|
||||
ensureConnected,
|
||||
currentClient,
|
||||
status,
|
||||
|
|
@ -806,6 +814,7 @@ function createComposerAndTurnActions(
|
|||
{
|
||||
vaultPath: host.environment.plugin.settingsRef.vaultPath,
|
||||
stateStore: host.stateStore,
|
||||
localItemIds,
|
||||
client: {
|
||||
currentClient,
|
||||
ensureConnected,
|
||||
|
|
@ -864,8 +873,6 @@ function createSurfacesAndPresenter(
|
|||
input: {
|
||||
connection: ConnectionManager;
|
||||
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
|
||||
inboundController: ChatInboundController;
|
||||
serverThreads: ChatServerThreadActions;
|
||||
goals: ChatPanelGoalActions;
|
||||
rename: ThreadRenameEditorActions;
|
||||
threadActions: ChatPanelThreadActions;
|
||||
|
|
@ -882,8 +889,6 @@ function createSurfacesAndPresenter(
|
|||
const {
|
||||
connection,
|
||||
connectionController,
|
||||
inboundController,
|
||||
serverThreads,
|
||||
goals,
|
||||
rename,
|
||||
threadActions,
|
||||
|
|
@ -899,14 +904,13 @@ function createSurfacesAndPresenter(
|
|||
const { environment, stateStore } = host;
|
||||
const toolbarActions = createChatPanelToolbarActions(
|
||||
{
|
||||
stateStore,
|
||||
startNewThread,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
reconnectPanel: reconnect,
|
||||
inboundController,
|
||||
threadActions,
|
||||
goals,
|
||||
toolbarPanels,
|
||||
rename,
|
||||
selection,
|
||||
|
|
@ -926,12 +930,8 @@ function createSurfacesAndPresenter(
|
|||
const goalSurface = createChatPanelGoalSurface(
|
||||
{
|
||||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
|
||||
stateStore,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
inboundController,
|
||||
threadStarter: serverThreads,
|
||||
goals,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import type { ChatConnectionController } from "../../application/connection/connection-controller";
|
||||
import type { ChatInboundController } from "../../app-server/inbound/controller";
|
||||
import type { GoalActions } from "../../application/threads/goal-actions";
|
||||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { h } from "preact";
|
||||
import type { ChatAction, ChatState } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
import type { SendShortcut } from "../../../../shared/ui/keyboard";
|
||||
import type { GoalPanelActions, GoalPanelDisplayState, GoalPanelEditorState, GoalPanelOptions } from "../../ui/goal";
|
||||
import { GoalPanel } from "../../ui/goal";
|
||||
import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../shell-state";
|
||||
|
||||
interface ChatPanelGoalActions {
|
||||
saveObjective: (objective: string, tokenBudget: number | null) => Promise<void>;
|
||||
saveObjective: (objective: string, tokenBudget: number | null) => Promise<boolean>;
|
||||
setStatus: (threadId: string, status: "active" | "paused") => Promise<unknown>;
|
||||
clear: (threadId: string) => Promise<unknown>;
|
||||
startEditing: (threadId: string | null, objective: string, tokenBudget: number | null) => void;
|
||||
|
|
@ -31,20 +27,12 @@ export interface ChatPanelGoalSurface {
|
|||
|
||||
export interface ChatPanelGoalSurfaceHost {
|
||||
sendShortcut: () => SendShortcut;
|
||||
stateStore: ChatStateStore;
|
||||
}
|
||||
|
||||
export interface ChatPanelGoalSurfaceDependencies {
|
||||
connectionController: ChatConnectionController;
|
||||
inboundController: ChatInboundController;
|
||||
threadStarter: ChatPanelGoalThreadStarter;
|
||||
goals: GoalActions;
|
||||
}
|
||||
|
||||
interface ChatPanelGoalThreadStarter {
|
||||
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
|
||||
}
|
||||
|
||||
interface ChatPanelGoalProjection {
|
||||
goal: ChatPanelGoalShellState["activeThread"]["goal"];
|
||||
goalThreadId: string | null;
|
||||
|
|
@ -53,30 +41,26 @@ interface ChatPanelGoalProjection {
|
|||
}
|
||||
|
||||
export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps: ChatPanelGoalSurfaceDependencies): ChatPanelGoalSurface {
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
host.stateStore.dispatch(action);
|
||||
};
|
||||
|
||||
return {
|
||||
settings: {
|
||||
sendShortcut: host.sendShortcut,
|
||||
},
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget),
|
||||
saveObjective: (objective, tokenBudget) => deps.goals.saveObjective(objective, tokenBudget),
|
||||
setStatus: (threadId, status) => deps.goals.setStatus(threadId, status),
|
||||
clear: (threadId) => deps.goals.clear(threadId),
|
||||
startEditing: (threadId, objective, tokenBudget) => {
|
||||
dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
||||
deps.goals.startEditing(threadId, objective, tokenBudget);
|
||||
},
|
||||
updateObjectiveDraft: (objective) => {
|
||||
dispatch({ type: "ui/goal-editor-draft-updated", objective });
|
||||
deps.goals.updateObjectiveDraft(objective);
|
||||
},
|
||||
setObjectiveExpanded: (threadId, expanded) => {
|
||||
dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded });
|
||||
deps.goals.setObjectiveExpanded(threadId, expanded);
|
||||
},
|
||||
closeEditor: () => {
|
||||
dispatch({ type: "ui/goal-editor-closed" });
|
||||
deps.goals.closeEditor();
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -88,27 +72,6 @@ export function ChatPanelGoal({ surface }: { surface: ChatPanelGoalSurface }): U
|
|||
return h(GoalPanel, props);
|
||||
}
|
||||
|
||||
async function saveGoalObjective(
|
||||
state: ChatState,
|
||||
deps: ChatPanelGoalSurfaceDependencies,
|
||||
objective: string,
|
||||
tokenBudget: number | null,
|
||||
): Promise<void> {
|
||||
let threadId = state.activeThread.id;
|
||||
if (!threadId) {
|
||||
try {
|
||||
await deps.connectionController.ensureConnected();
|
||||
const response = await deps.threadStarter.startThread(objective, { syncGoal: false });
|
||||
threadId = response?.threadId ?? null;
|
||||
} catch (error) {
|
||||
deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!threadId) return;
|
||||
void deps.goals.setObjective(threadId, objective, tokenBudget);
|
||||
}
|
||||
|
||||
function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPanelGoalProjection {
|
||||
const goal = state.activeThread.goal;
|
||||
const goalThreadId = goal?.threadId ?? null;
|
||||
|
|
@ -142,8 +105,9 @@ function chatPanelGoalViewModel(
|
|||
goal: projection.goal,
|
||||
actions: {
|
||||
onSave: (objective, tokenBudget) => {
|
||||
void surface.actions.goal.saveObjective(objective, tokenBudget);
|
||||
surface.actions.goal.closeEditor();
|
||||
void surface.actions.goal.saveObjective(objective, tokenBudget).then((saved) => {
|
||||
if (saved) surface.actions.goal.closeEditor();
|
||||
});
|
||||
},
|
||||
onPause: () => {
|
||||
if (!projection.goalThreadId) return;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { ThreadManagementActions } from "../application/threads/thread-mana
|
|||
import type { ChatAction, ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ChatConnectionController } from "../application/connection/connection-controller";
|
||||
import type { GoalActions } from "../application/threads/goal-actions";
|
||||
import type { ThreadRenameEditorActions } from "../application/threads/rename-editor-actions";
|
||||
import type { SelectionActions } from "../application/threads/selection-actions";
|
||||
import type { ChatInboundController } from "../app-server/inbound/controller";
|
||||
import type { ToolbarActions } from "../ui/toolbar";
|
||||
|
||||
export interface ToolbarPanelActionsHost {
|
||||
|
|
@ -25,15 +25,14 @@ export interface ToolbarPanelActions {
|
|||
}
|
||||
|
||||
export interface ChatPanelToolbarActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
startNewThread: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChatPanelToolbarActionDependencies {
|
||||
connectionController: ChatConnectionController;
|
||||
reconnectPanel: () => Promise<void>;
|
||||
inboundController: ChatInboundController;
|
||||
threadActions: ThreadManagementActions;
|
||||
goals: GoalActions;
|
||||
toolbarPanels: ToolbarPanelActions;
|
||||
rename: ThreadRenameEditorActions;
|
||||
selection: SelectionActions;
|
||||
|
|
@ -133,17 +132,10 @@ export function createChatPanelToolbarActions(host: ChatPanelToolbarActionsHost,
|
|||
deps.toolbarPanels.toggleChatActions();
|
||||
},
|
||||
compactConversation: () => {
|
||||
void compactConversation(host.stateStore.getState(), deps);
|
||||
void deps.threadActions.compactActiveThread();
|
||||
},
|
||||
setGoal: () => {
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const goal = host.stateStore.getState().activeThread.goal;
|
||||
host.stateStore.dispatch({
|
||||
type: "ui/goal-editor-started",
|
||||
threadId: goal?.threadId ?? null,
|
||||
objective: goal?.objective ?? "",
|
||||
tokenBudget: goal?.tokenBudget ?? null,
|
||||
});
|
||||
deps.goals.startEditingCurrent();
|
||||
},
|
||||
toggleHistory: () => {
|
||||
deps.toolbarPanels.toggleHistory();
|
||||
|
|
@ -184,18 +176,6 @@ export function createChatPanelToolbarActions(host: ChatPanelToolbarActionsHost,
|
|||
};
|
||||
}
|
||||
|
||||
async function compactConversation(
|
||||
state: ReturnType<ChatStateStore["getState"]>,
|
||||
deps: Pick<ChatPanelToolbarActionDependencies, "inboundController" | "threadActions">,
|
||||
): Promise<void> {
|
||||
const threadId = state.activeThread.id;
|
||||
if (!threadId) {
|
||||
deps.inboundController.addSystemMessage("No active thread to compact.");
|
||||
return;
|
||||
}
|
||||
await deps.threadActions.compactThread(threadId);
|
||||
}
|
||||
|
||||
function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element {
|
||||
return Boolean(viewWindow && target instanceof viewWindow.Element);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ describe("app-server diagnostics", () => {
|
|||
});
|
||||
|
||||
it("shortens error messages and tracks MCP server diagnostics", () => {
|
||||
expect(diagnosticProbeError("model/list", "a\n b\t c").message).toBe("a b c");
|
||||
expect(diagnosticProbeError("model/list", "x".repeat(200)).message).toHaveLength(160);
|
||||
expect(diagnosticProbeError("model/list", "a\n b\t c", 1).message).toBe("a b c");
|
||||
expect(diagnosticProbeError("model/list", "x".repeat(200), 1).message).toHaveLength(160);
|
||||
|
||||
let diagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), {
|
||||
name: "github",
|
||||
|
|
|
|||
|
|
@ -385,20 +385,20 @@ function metadata(
|
|||
diagnostics = diagnosticsWithProbe(
|
||||
diagnostics,
|
||||
overrides.modelProbeStatus === "failed"
|
||||
? diagnosticProbeError("model/list", new Error("offline"))
|
||||
: diagnosticProbeOk("model/list", "1 models"),
|
||||
? diagnosticProbeError("model/list", new Error("offline"), 1)
|
||||
: diagnosticProbeOk("model/list", "1 models", 1),
|
||||
);
|
||||
diagnostics = diagnosticsWithProbe(
|
||||
diagnostics,
|
||||
overrides.skillsProbeStatus === "failed"
|
||||
? diagnosticProbeError("skills/list", new Error("offline"))
|
||||
: diagnosticProbeOk("skills/list", "0 skills"),
|
||||
? diagnosticProbeError("skills/list", new Error("offline"), 1)
|
||||
: diagnosticProbeOk("skills/list", "0 skills", 1),
|
||||
);
|
||||
diagnostics = diagnosticsWithProbe(
|
||||
diagnostics,
|
||||
overrides.rateLimitProbeStatus === "failed"
|
||||
? diagnosticProbeError("account/rateLimits/read", new Error("offline"))
|
||||
: diagnosticProbeOk("account/rateLimits/read", "available"),
|
||||
? diagnosticProbeError("account/rateLimits/read", new Error("offline"), 1)
|
||||
: diagnosticProbeOk("account/rateLimits/read", "available", 1),
|
||||
);
|
||||
return {
|
||||
runtimeConfig: overrides.runtimeConfig ?? emptyRuntimeConfigSnapshot(),
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ function model(modelId: string): ModelMetadata {
|
|||
}
|
||||
|
||||
function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
|
||||
const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "0 models"));
|
||||
const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "0 models", 1));
|
||||
return {
|
||||
runtimeConfig: null,
|
||||
availableModels: [],
|
||||
|
|
|
|||
|
|
@ -258,10 +258,10 @@ describe("chat server actions", () => {
|
|||
rateLimit: rateLimitFixture(),
|
||||
serverDiagnostics: diagnosticsWithProbe(
|
||||
diagnosticsWithProbe(
|
||||
diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "1 models")),
|
||||
diagnosticProbeOk("skills/list", "1 skills"),
|
||||
diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "1 models", 1)),
|
||||
diagnosticProbeOk("skills/list", "1 skills", 1),
|
||||
),
|
||||
diagnosticProbeOk("account/rateLimits/read", "available"),
|
||||
diagnosticProbeOk("account/rateLimits/read", "available", 1),
|
||||
),
|
||||
});
|
||||
const refreshAppServerMetadata = vi.fn<() => Promise<SharedServerMetadata | null>>().mockResolvedValue(refreshedMetadata);
|
||||
|
|
@ -333,7 +333,7 @@ describe("chat server actions", () => {
|
|||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const metadataCache = metadataCacheHost({
|
||||
current: serverMetadataFixture({
|
||||
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "cached models")),
|
||||
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "cached models", 1)),
|
||||
}),
|
||||
});
|
||||
const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] });
|
||||
|
|
@ -456,7 +456,7 @@ describe("chat server actions", () => {
|
|||
const stateStore = createChatStateStore(state);
|
||||
const metadata = serverMetadataFixture({
|
||||
availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-cached")]),
|
||||
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"))),
|
||||
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"), 1)),
|
||||
});
|
||||
const controller = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
|
|
@ -478,7 +478,7 @@ describe("chat server actions", () => {
|
|||
const stateStore = createChatStateStore(state);
|
||||
const metadata = serverMetadataFixture({
|
||||
availableModels: [],
|
||||
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"))),
|
||||
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"), 1)),
|
||||
});
|
||||
const controller = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import {
|
|||
createTurnSubmissionActions,
|
||||
type TurnSubmissionActionsHost,
|
||||
} from "../../../../../src/features/chat/application/conversation/turn-submission-actions";
|
||||
import { optimisticTurnStart } from "../../../../../src/features/chat/application/conversation/optimistic-turn-start";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { createLocalIdSource } from "../../../../../src/shared/id/local-id";
|
||||
import { chatStateMessageStreamItems } from "../../support/message-stream";
|
||||
|
||||
const textInput = (text: string): CodexInput => [{ type: "text", text }];
|
||||
|
|
@ -61,6 +63,7 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
|
|||
setStatus: vi.fn(),
|
||||
addSystemMessage: vi.fn(),
|
||||
...overrides,
|
||||
localItemIds: overrides.localItemIds ?? createLocalIdSource(),
|
||||
};
|
||||
return { host, startTurn, stateStore, steerTurn };
|
||||
}
|
||||
|
|
@ -164,6 +167,34 @@ describe("TurnSubmissionActions", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports busy turns that cannot be steered", async () => {
|
||||
const { host, startTurn, stateStore, steerTurn } = createHost();
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
thread: thread("thread"),
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalPolicy: null,
|
||||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
const optimistic = optimisticTurnStart({ id: "local-user", text: "pending", codexInput: textInput("pending") });
|
||||
stateStore.dispatch({
|
||||
type: "turn/optimistic-started",
|
||||
item: optimistic.item,
|
||||
pendingTurnStart: optimistic.pendingTurnStart,
|
||||
});
|
||||
const actions = createTurnSubmissionActions(host);
|
||||
|
||||
await actions.sendTurnText("follow up");
|
||||
|
||||
expect(host.addSystemMessage).toHaveBeenCalledWith("Current turn is not steerable yet.");
|
||||
expect(steerTurn).not.toHaveBeenCalled();
|
||||
expect(startTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps local user ids distinct when submissions share the same timestamp", async () => {
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(1234);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ function surfaceFixture(options: { toolbarConnected?: () => boolean } = {}): {
|
|||
settings: { sendShortcut: () => "enter" },
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: async () => undefined,
|
||||
saveObjective: async () => true,
|
||||
setStatus: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
startEditing: () => undefined,
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ describe("chat panel surface projections", () => {
|
|||
},
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: async () => undefined,
|
||||
saveObjective: async () => true,
|
||||
setStatus: async (threadId, status) => {
|
||||
statuses.push([threadId, status]);
|
||||
},
|
||||
|
|
@ -447,7 +447,7 @@ function goalSurfaceFixture(): ChatPanelGoalSurface {
|
|||
},
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: async () => undefined,
|
||||
saveObjective: async () => true,
|
||||
setStatus: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
startEditing: () => undefined,
|
||||
|
|
|
|||
|
|
@ -46,65 +46,22 @@ describe("createToolbarPanelActions", () => {
|
|||
});
|
||||
|
||||
describe("createChatPanelToolbarActions", () => {
|
||||
it("reports compact without an active thread through the inbound controller", () => {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
it("delegates compacting the active thread", () => {
|
||||
const deps = toolbarActionDeps();
|
||||
const actions = createChatPanelToolbarActions({ stateStore, startNewThread: vi.fn() }, deps);
|
||||
const actions = createChatPanelToolbarActions({ startNewThread: vi.fn() }, deps);
|
||||
|
||||
actions.compactConversation();
|
||||
|
||||
expect((deps.inboundController.addSystemMessage as unknown as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
||||
["No active thread to compact."],
|
||||
]);
|
||||
expect(vi.mocked(deps.threadActions.compactThread)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compacts the active thread", () => {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
thread: { id: "thread", name: null, preview: "", archived: false, createdAt: 1, updatedAt: 1 },
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalPolicy: null,
|
||||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
const deps = toolbarActionDeps();
|
||||
const actions = createChatPanelToolbarActions({ stateStore, startNewThread: vi.fn() }, deps);
|
||||
|
||||
actions.compactConversation();
|
||||
|
||||
expect(vi.mocked(deps.threadActions.compactThread)).toHaveBeenCalledWith("thread");
|
||||
expect(vi.mocked(deps.threadActions.compactActiveThread)).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts the goal editor from the active goal", () => {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/goal-set",
|
||||
goal: {
|
||||
threadId: "thread",
|
||||
objective: "Ship it",
|
||||
status: "active",
|
||||
tokenBudget: 5,
|
||||
tokensUsed: 0,
|
||||
timeUsedSeconds: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
});
|
||||
const actions = createChatPanelToolbarActions({ stateStore, startNewThread: vi.fn() }, toolbarActionDeps());
|
||||
const deps = toolbarActionDeps();
|
||||
const actions = createChatPanelToolbarActions({ startNewThread: vi.fn() }, deps);
|
||||
|
||||
actions.setGoal();
|
||||
|
||||
expect(stateStore.getState().ui.goalEditor).toEqual({
|
||||
kind: "editing",
|
||||
threadId: "thread",
|
||||
objectiveDraft: "Ship it",
|
||||
tokenBudgetDraft: 5,
|
||||
});
|
||||
expect(vi.mocked(deps.goals.startEditingCurrent)).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -114,11 +71,14 @@ function toolbarActionDeps(): Parameters<typeof createChatPanelToolbarActions>[1
|
|||
typeof createChatPanelToolbarActions
|
||||
>[1]["connectionController"],
|
||||
reconnectPanel: vi.fn(),
|
||||
inboundController: { addSystemMessage: vi.fn() } as unknown as Parameters<typeof createChatPanelToolbarActions>[1]["inboundController"],
|
||||
threadActions: {
|
||||
archiveThread: vi.fn().mockResolvedValue(undefined),
|
||||
compactActiveThread: vi.fn().mockResolvedValue(undefined),
|
||||
compactThread: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ThreadManagementActions,
|
||||
goals: {
|
||||
startEditingCurrent: vi.fn(),
|
||||
} as unknown as Parameters<typeof createChatPanelToolbarActions>[1]["goals"],
|
||||
toolbarPanels: {
|
||||
toggleChatActions: vi.fn(),
|
||||
toggleHistory: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ function surfaceFixture(
|
|||
settings: { sendShortcut: () => "enter" },
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: async () => undefined,
|
||||
saveObjective: async () => true,
|
||||
setStatus: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
startEditing: () => undefined,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { ChatStateStore } from "../../../../../src/features/chat/applicatio
|
|||
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
|
||||
import type { Thread as PanelThread } from "../../../../../src/domain/threads/model";
|
||||
import type { TurnRecord } from "../../../../../src/app-server/protocol/turn";
|
||||
import { createLocalIdSource } from "../../../../../src/shared/id/local-id";
|
||||
import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream";
|
||||
import { chatStateFixture, chatStateWith } from "../../support/state";
|
||||
|
||||
|
|
@ -23,21 +24,25 @@ function controllerForState(
|
|||
): ChatInboundController & { currentState(): ChatState } {
|
||||
const store = testStoreForState(state);
|
||||
return Object.assign(
|
||||
new ChatInboundController(store, {
|
||||
refreshActiveThreads: vi.fn(),
|
||||
refreshRateLimits: vi.fn(),
|
||||
refreshSkills: vi.fn(),
|
||||
applyAppServerMetadataSnapshot: vi.fn(),
|
||||
maybeNameThread: vi.fn(),
|
||||
upsertActiveThread: vi.fn(),
|
||||
applyThreadArchived: vi.fn(),
|
||||
recordActiveThreadDeleted: vi.fn(),
|
||||
applyThreadRenamed: vi.fn(),
|
||||
recordMcpStartupStatus: vi.fn(),
|
||||
respondToServerRequest: vi.fn(() => true),
|
||||
rejectServerRequest: vi.fn(() => true),
|
||||
...actions,
|
||||
}),
|
||||
new ChatInboundController(
|
||||
store,
|
||||
{
|
||||
refreshActiveThreads: vi.fn(),
|
||||
refreshRateLimits: vi.fn(),
|
||||
refreshSkills: vi.fn(),
|
||||
applyAppServerMetadataSnapshot: vi.fn(),
|
||||
maybeNameThread: vi.fn(),
|
||||
upsertActiveThread: vi.fn(),
|
||||
applyThreadArchived: vi.fn(),
|
||||
recordActiveThreadDeleted: vi.fn(),
|
||||
applyThreadRenamed: vi.fn(),
|
||||
recordMcpStartupStatus: vi.fn(),
|
||||
respondToServerRequest: vi.fn(() => true),
|
||||
rejectServerRequest: vi.fn(() => true),
|
||||
...actions,
|
||||
},
|
||||
createLocalIdSource({ nowMs: () => 1, seed: "test" }),
|
||||
),
|
||||
{
|
||||
currentState: () => store.getState(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie
|
|||
import type { ThreadGoal } from "../../../../src/domain/threads/goal";
|
||||
import { createGoalActions } from "../../../../src/features/chat/application/threads/goal-actions";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import { createLocalIdSource } from "../../../../src/shared/id/local-id";
|
||||
import { deferred } from "../../../support/async";
|
||||
import { chatStateFixture, chatStateWith } from "../support/state";
|
||||
|
||||
|
|
@ -18,7 +19,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage: vi.fn(),
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState,
|
||||
|
|
@ -39,7 +42,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -70,7 +75,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent,
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -101,7 +108,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -127,7 +136,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -154,7 +165,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent,
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -181,6 +194,73 @@ describe("createGoalActions", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("starts a thread before saving a new goal objective when no thread is active", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const savedGoal = goal({ threadId: "thread-new", objective: "Plan release" });
|
||||
const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: savedGoal });
|
||||
const injectThreadItems = vi.fn().mockResolvedValue({});
|
||||
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
|
||||
const startThread = vi.fn().mockImplementation(async () => {
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
thread: { id: "thread-new", name: null, preview: "Plan release", archived: false, createdAt: 1, updatedAt: 1 },
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalPolicy: null,
|
||||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
return { threadId: "thread-new" };
|
||||
});
|
||||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread,
|
||||
addSystemMessage: vi.fn(),
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(controller.saveObjective(" Plan release ", null)).resolves.toBe(true);
|
||||
|
||||
expect(startThread).toHaveBeenCalledWith("Plan release", { syncGoal: false });
|
||||
expect(setThreadGoal).toHaveBeenCalledWith("thread-new", { objective: "Plan release", status: "active", tokenBudget: null });
|
||||
expect(injectThreadItems).toHaveBeenCalledWith("thread-new", [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Plan release" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects empty goal objective saves before connecting or starting a thread", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const ensureConnected = vi.fn().mockResolvedValue(undefined);
|
||||
const startThread = vi.fn().mockResolvedValue({ threadId: "thread" });
|
||||
const addSystemMessage = vi.fn();
|
||||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => ({ setThreadGoal: vi.fn() }) as unknown as AppServerClient,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected,
|
||||
startThread,
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(controller.saveObjective(" ", null)).resolves.toBe(false);
|
||||
|
||||
expect(addSystemMessage).toHaveBeenCalledWith("Goal objective cannot be empty.");
|
||||
expect(ensureConnected).not.toHaveBeenCalled();
|
||||
expect(startThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports goal user history injection failures while the thread remains active", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
|
|
@ -192,7 +272,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -217,7 +299,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -240,7 +324,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage: vi.fn(),
|
||||
addGoalEvent,
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -264,7 +350,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent,
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
@ -286,7 +374,9 @@ describe("createGoalActions", () => {
|
|||
const controller = createGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,17 @@ describe("thread management actions", () => {
|
|||
expect(host.setStatus).toHaveBeenCalledWith("Compaction requested.");
|
||||
});
|
||||
|
||||
it("reports compacting without an active thread", async () => {
|
||||
const client = clientMock();
|
||||
const host = hostMock({ client, items: [] });
|
||||
const controller = threadManagementActions(host);
|
||||
|
||||
await controller.compactActiveThread();
|
||||
|
||||
expect(host.addSystemMessage).toHaveBeenCalledWith("No active thread to compact.");
|
||||
expect(client.compactThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report compaction completion after the panel switches threads", async () => {
|
||||
const compact = deferred<undefined>();
|
||||
const client = clientMock();
|
||||
|
|
|
|||
Loading…
Reference in a new issue