mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(chat): centralize active panel operation policy
This commit is contained in:
parent
ec3bcd0312
commit
4b0218169c
38 changed files with 892 additions and 196 deletions
|
|
@ -58,6 +58,8 @@ Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex
|
|||
|
||||
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.
|
||||
|
||||
Mode-derived restrictions for the active panel thread belong to one pure operation policy. Actions and UI projections must use that policy instead of independently interpreting panel phase, side-chat lifetime, or subagent provenance. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.
|
||||
|
||||
Thread history 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, not replacements for Codex history.
|
||||
|
||||
Routine thread lists should load a bounded recent set and paginate older threads on demand instead of eagerly fetching complete inventories.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { type ActivePanelOperation, activePanelOperationDecisionForFacts } from "../panel-operation-policy";
|
||||
|
||||
type SlashCommandArgsKind =
|
||||
| "none"
|
||||
| "optionalThread"
|
||||
|
|
@ -33,7 +35,6 @@ export interface SlashCommandDefinitionShape {
|
|||
argsKind: SlashCommandArgsKind;
|
||||
surface: SlashCommandSurface;
|
||||
detail: string;
|
||||
sideChatAvailable?: false;
|
||||
subcommands?: readonly SlashCommandSubcommandDefinition[];
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +80,6 @@ export const SLASH_COMMANDS = [
|
|||
argsKind: "none",
|
||||
surface: "panelAction",
|
||||
detail: "Fork the active Codex thread.",
|
||||
sideChatAvailable: false,
|
||||
},
|
||||
{
|
||||
command: "/btw",
|
||||
|
|
@ -87,7 +87,6 @@ export const SLASH_COMMANDS = [
|
|||
argsKind: "none",
|
||||
surface: "panelAction",
|
||||
detail: "Open a temporary side chat.",
|
||||
sideChatAvailable: false,
|
||||
},
|
||||
{
|
||||
command: "/rollback",
|
||||
|
|
@ -95,7 +94,6 @@ export const SLASH_COMMANDS = [
|
|||
argsKind: "none",
|
||||
surface: "panelAction",
|
||||
detail: "Roll back the latest turn and restore its prompt.",
|
||||
sideChatAvailable: false,
|
||||
},
|
||||
{ command: "/compact", usage: "/compact", argsKind: "none", surface: "panelAction", detail: "Compact the current thread context." },
|
||||
{
|
||||
|
|
@ -220,8 +218,61 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole
|
|||
}
|
||||
|
||||
export function slashCommandAvailableInSideChat(command: SlashCommandName): boolean {
|
||||
const definition = slashCommandDefinition(command);
|
||||
return !("sideChatAvailable" in definition);
|
||||
const operation = activePanelOperationForSlashCommandSuggestion(command);
|
||||
return (
|
||||
!operation ||
|
||||
activePanelOperationDecisionForFacts({ phase: "active", lifetime: "ephemeral", provenance: "interactive" }, operation).kind ===
|
||||
"allowed"
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps a parsed command to the active-panel operation it performs, if any. */
|
||||
export function activePanelOperationForSlashCommand(command: SlashCommandName, args: string): ActivePanelOperation | null {
|
||||
switch (command) {
|
||||
case "goal":
|
||||
return args.trim() ? "goal-mutation" : "goal-read";
|
||||
case "compact":
|
||||
return "compact";
|
||||
case "fork":
|
||||
return "fork";
|
||||
case "rollback":
|
||||
return "rollback";
|
||||
case "btw":
|
||||
return "start-side-chat";
|
||||
case "fast":
|
||||
case "auto-review":
|
||||
case "plan":
|
||||
return "thread-settings";
|
||||
case "permissions":
|
||||
return args ? "permission-settings" : null;
|
||||
case "model":
|
||||
case "reasoning":
|
||||
return args ? "thread-settings" : null;
|
||||
case "status":
|
||||
case "doctor":
|
||||
case "tools":
|
||||
case "help":
|
||||
return null;
|
||||
default:
|
||||
return "submit";
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a slash-completion to its mode-derived operation without assuming arguments. */
|
||||
export function activePanelOperationForSlashCommandSuggestion(command: SlashCommandName): ActivePanelOperation | null {
|
||||
switch (command) {
|
||||
case "goal":
|
||||
case "compact":
|
||||
case "fork":
|
||||
case "rollback":
|
||||
case "btw":
|
||||
case "fast":
|
||||
case "auto-review":
|
||||
case "plan":
|
||||
return activePanelOperationForSlashCommand(command, "");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition {
|
||||
|
|
|
|||
|
|
@ -19,13 +19,7 @@ import {
|
|||
selectionContextReferenceMarker,
|
||||
} from "./context-references";
|
||||
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
|
||||
import {
|
||||
isSlashCommandName,
|
||||
SLASH_COMMANDS,
|
||||
type SlashCommandName,
|
||||
slashCommandAvailableInSideChat,
|
||||
slashCommandSubcommands,
|
||||
} from "./slash-commands";
|
||||
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
|
||||
|
||||
export interface ComposerSuggestion {
|
||||
display: string;
|
||||
|
|
@ -41,8 +35,7 @@ export interface ComposerSuggestion {
|
|||
|
||||
export interface ComposerSuggestionOptions {
|
||||
activeThreadId?: string | null;
|
||||
activeThreadEphemeral?: boolean;
|
||||
activeThreadSubagent?: boolean;
|
||||
slashCommandAvailable?: (command: SlashCommandName) => boolean;
|
||||
contextReferences?: ComposerContextReferences;
|
||||
dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]);
|
||||
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
||||
|
|
@ -121,14 +114,16 @@ 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));
|
||||
const slashCommandAvailable = options.slashCommandAvailable ?? (() => true);
|
||||
const slashSuggestions = slashSuggestionsForActivePanel(
|
||||
beforeCursor,
|
||||
threads,
|
||||
models,
|
||||
currentModel,
|
||||
options.activeThreadId ?? null,
|
||||
options.permissionProfiles ?? [],
|
||||
slashCommandAvailable,
|
||||
);
|
||||
return (
|
||||
activeWikiLinkSuggestions(beforeCursor, notes) ??
|
||||
activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ??
|
||||
|
|
@ -139,6 +134,27 @@ export function activeComposerSuggestions(
|
|||
);
|
||||
}
|
||||
|
||||
function slashSuggestionsForActivePanel(
|
||||
beforeCursor: string,
|
||||
threads: readonly Thread[],
|
||||
models: readonly ModelMetadata[],
|
||||
currentModel: string | null,
|
||||
activeThreadId: string | null,
|
||||
permissionProfiles: readonly RuntimePermissionProfileSummary[],
|
||||
slashCommandAvailable: (command: SlashCommandName) => boolean,
|
||||
): ComposerSuggestion[] | null {
|
||||
const command = /^\/([A-Za-z-]+)/.exec(beforeCursor)?.[1];
|
||||
if (command && isSlashCommandName(command) && !slashCommandAvailable(command)) return [];
|
||||
return (
|
||||
activeSlashSubcommandSuggestions(beforeCursor, slashCommandAvailable) ??
|
||||
activeThreadCommandSuggestions(beforeCursor, threads, activeThreadId) ??
|
||||
modelOverrideSuggestions(beforeCursor, models) ??
|
||||
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
|
||||
permissionProfileOverrideSuggestions(beforeCursor, permissionProfiles) ??
|
||||
activeSlashCommandSuggestions(beforeCursor, slashCommandAvailable)
|
||||
);
|
||||
}
|
||||
|
||||
function activeContextReferenceSuggestions(
|
||||
beforeCursor: string,
|
||||
references: ComposerContextReferences | undefined,
|
||||
|
|
@ -405,8 +421,7 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand
|
|||
|
||||
function activeSlashCommandSuggestions(
|
||||
beforeCursor: string,
|
||||
activeThreadEphemeral: boolean,
|
||||
activeThreadSubagent: boolean,
|
||||
slashCommandAvailable: (command: SlashCommandName) => boolean,
|
||||
): ComposerSuggestion[] | null {
|
||||
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
|
||||
if (match?.index === undefined) return null;
|
||||
|
|
@ -414,13 +429,10 @@ function activeSlashCommandSuggestions(
|
|||
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(
|
||||
(item) =>
|
||||
item.command.toLowerCase().startsWith(query) &&
|
||||
(!activeThreadEphemeral || slashCommandAvailableInSideChat(item.command.slice(1) as SlashCommandName)),
|
||||
(item) => item.command.toLowerCase().startsWith(query) && slashCommandAvailable(item.command.slice(1) as SlashCommandName),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((item) => ({
|
||||
|
|
@ -432,13 +444,17 @@ function activeSlashCommandSuggestions(
|
|||
}));
|
||||
}
|
||||
|
||||
function activeSlashSubcommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null {
|
||||
function activeSlashSubcommandSuggestions(
|
||||
beforeCursor: string,
|
||||
slashCommandAvailable: (command: SlashCommandName) => boolean,
|
||||
): ComposerSuggestion[] | null {
|
||||
const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor);
|
||||
if (!match) return null;
|
||||
|
||||
const command = match[1];
|
||||
const rawQuery = match[2];
|
||||
if (!command || !isSlashCommandName(command) || rawQuery === undefined) return null;
|
||||
if (!slashCommandAvailable(command)) return [];
|
||||
|
||||
const query = rawQuery.toLowerCase();
|
||||
const subcommands = slashCommandSubcommands(command);
|
||||
|
|
|
|||
122
src/features/chat/application/panel-operation-policy.ts
Normal file
122
src/features/chat/application/panel-operation-policy.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { ChatState } from "./state/root-reducer";
|
||||
|
||||
/**
|
||||
* Operations whose availability derives from the panel's current thread mode.
|
||||
*
|
||||
* This intentionally excludes connection, turn-busy, and operations aimed at a
|
||||
* different listed thread. Those are workflow concerns rather than properties
|
||||
* of the active panel thread.
|
||||
*/
|
||||
export type ActivePanelOperation =
|
||||
| "submit"
|
||||
| "start-side-chat"
|
||||
| "compact"
|
||||
| "fork"
|
||||
| "rollback"
|
||||
| "thread-settings"
|
||||
| "permission-settings"
|
||||
| "goal-read"
|
||||
| "goal-mutation"
|
||||
| "implement-plan";
|
||||
|
||||
export type ActivePanelOperationDecision =
|
||||
| { readonly kind: "allowed" }
|
||||
| { readonly kind: "blocked"; readonly message: string }
|
||||
| { readonly kind: "resume-required" };
|
||||
|
||||
export type ActivePanelThreadFacts =
|
||||
| { readonly phase: "empty" }
|
||||
| { readonly phase: "awaiting-resume"; readonly provenance: "interactive" | "subagent" | null }
|
||||
| {
|
||||
readonly phase: "active";
|
||||
readonly lifetime: "persistent" | "ephemeral";
|
||||
readonly provenance: "interactive" | "subagent" | null;
|
||||
};
|
||||
|
||||
const ALLOWED: ActivePanelOperationDecision = { kind: "allowed" };
|
||||
|
||||
export function activePanelOperationDecision(state: ChatState, operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||
return activePanelOperationDecisionForFacts(activePanelThreadFacts(state), operation);
|
||||
}
|
||||
|
||||
export function activePanelOperationDecisionForFacts(
|
||||
facts: ActivePanelThreadFacts,
|
||||
operation: ActivePanelOperation,
|
||||
): ActivePanelOperationDecision {
|
||||
if (facts.phase === "awaiting-resume") {
|
||||
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
|
||||
return operation === "goal-mutation" ? { kind: "resume-required" } : ALLOWED;
|
||||
}
|
||||
if (facts.phase === "empty") return ALLOWED;
|
||||
|
||||
// Keep the stricter interpretation if a malformed or future state contains
|
||||
// both mode facts. This makes mode restrictions monotonically safe.
|
||||
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
|
||||
return operation === "goal-read" ? sideChatDecision(operation) : agentThreadBlocked(operation);
|
||||
}
|
||||
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
|
||||
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
|
||||
return ALLOWED;
|
||||
}
|
||||
|
||||
function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
|
||||
const panelThread = state.panelThread;
|
||||
if (panelThread.kind === "empty") return { phase: "empty" };
|
||||
if (panelThread.kind === "awaiting-resume") {
|
||||
return { phase: "awaiting-resume", provenance: panelThread.provenance?.kind ?? null };
|
||||
}
|
||||
return {
|
||||
phase: "active",
|
||||
lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
|
||||
provenance: panelThread.thread.provenance?.kind ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||
switch (operation) {
|
||||
case "submit":
|
||||
return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." };
|
||||
case "goal-mutation":
|
||||
return { kind: "blocked", message: "Goals are read-only in agent threads." };
|
||||
case "goal-read":
|
||||
return ALLOWED;
|
||||
case "implement-plan":
|
||||
return { kind: "blocked", message: "Plans cannot be implemented from agent threads." };
|
||||
case "compact":
|
||||
return { kind: "blocked", message: "Agent threads cannot be compacted." };
|
||||
case "fork":
|
||||
return { kind: "blocked", message: "Agent threads cannot be forked." };
|
||||
case "rollback":
|
||||
return { kind: "blocked", message: "Agent threads cannot be rolled back." };
|
||||
case "start-side-chat":
|
||||
return { kind: "blocked", message: "Side chats cannot be started from agent threads." };
|
||||
case "thread-settings":
|
||||
case "permission-settings":
|
||||
return { kind: "blocked", message: "Thread settings are unavailable in agent threads." };
|
||||
}
|
||||
}
|
||||
|
||||
function sideChatDecision(operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||
switch (operation) {
|
||||
case "goal-mutation":
|
||||
return { kind: "blocked", message: "Goals are unavailable in side chats." };
|
||||
case "goal-read":
|
||||
return { kind: "blocked", message: "Goals are unavailable in side chats." };
|
||||
case "implement-plan":
|
||||
return { kind: "blocked", message: "Plans cannot be implemented from side chats." };
|
||||
case "compact":
|
||||
return ALLOWED;
|
||||
case "fork":
|
||||
return { kind: "blocked", message: "Side chats cannot be forked." };
|
||||
case "rollback":
|
||||
return { kind: "blocked", message: "Side chats cannot be rolled back." };
|
||||
case "start-side-chat":
|
||||
return { kind: "blocked", message: "Side chats cannot be started from another side chat." };
|
||||
case "permission-settings":
|
||||
return { kind: "blocked", message: "Permission changes are unavailable in side chats." };
|
||||
case "submit":
|
||||
return ALLOWED;
|
||||
case "thread-settings":
|
||||
return { kind: "blocked", message: "Thread settings are unavailable in side chats." };
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@ import {
|
|||
pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch,
|
||||
type PendingRuntimeSettingsPatch,
|
||||
} from "../../domain/runtime/thread-settings-patch";
|
||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { RuntimeSettingsTransport } from "./settings-transport";
|
||||
|
||||
|
|
@ -91,6 +92,7 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr
|
|||
if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied };
|
||||
|
||||
try {
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return { ok: false, collaborationModeApplied: false };
|
||||
if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) {
|
||||
return { ok: false, collaborationModeApplied: false };
|
||||
}
|
||||
|
|
@ -108,13 +110,13 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr
|
|||
}
|
||||
|
||||
async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||
dispatch(host, { type: "runtime/model-requested", model });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||
dispatch(host, { type: "runtime/model-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
|
@ -124,13 +126,13 @@ async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: strin
|
|||
}
|
||||
|
||||
async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) 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;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||
dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
|
@ -144,28 +146,21 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo
|
|||
}
|
||||
|
||||
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (permissionChangesBlocked(host)) return false;
|
||||
if (activePanelOperationBlocked(host, "permission-settings")) 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;
|
||||
if (activePanelOperationBlocked(host, "permission-settings")) return false;
|
||||
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean {
|
||||
if (activeThreadState(state(host))?.lifetime?.kind !== "ephemeral") return false;
|
||||
host.addSystemMessage("Permission changes are unavailable in side chats.");
|
||||
return true;
|
||||
}
|
||||
|
||||
function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean {
|
||||
if (activeThreadState(state(host))?.provenance?.kind !== "subagent") return false;
|
||||
host.addSystemMessage("Thread settings are unavailable in agent threads.");
|
||||
function activePanelOperationBlocked(host: RuntimeSettingsActionsHost, operation: ActivePanelOperation): boolean {
|
||||
const decision = activePanelOperationDecision(state(host), operation);
|
||||
if (decision.kind !== "blocked") return false;
|
||||
host.addSystemMessage(decision.message);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +170,7 @@ async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
|||
}
|
||||
|
||||
async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise<void> {
|
||||
if (agentThreadSettingsBlocked(host)) return;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return;
|
||||
const fastMode: RequestedFastMode = mode;
|
||||
await runRuntimeUiCommand(
|
||||
host,
|
||||
|
|
@ -194,7 +189,7 @@ async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promis
|
|||
}
|
||||
|
||||
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise<boolean> {
|
||||
if (agentThreadSettingsBlocked(host)) return false;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
|
||||
const result = await commitPendingThreadSettings(host);
|
||||
if (result.ok) closeRuntimePanel(host);
|
||||
|
|
@ -205,7 +200,7 @@ async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborat
|
|||
}
|
||||
|
||||
function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void {
|
||||
if (agentThreadSettingsBlocked(host)) return;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return;
|
||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +211,7 @@ async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void>
|
|||
}
|
||||
|
||||
async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise<void> {
|
||||
if (agentThreadSettingsBlocked(host)) return;
|
||||
if (activePanelOperationBlocked(host, "thread-settings")) return;
|
||||
await runRuntimeUiCommand(
|
||||
host,
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../..
|
|||
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
||||
import type { GoalThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import type { LocalIdSource } from "../local-id-source";
|
||||
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { activeThreadId, activeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport";
|
||||
|
|
@ -18,6 +19,7 @@ export interface ThreadGoalSyncHost {
|
|||
export interface GoalActionsHost extends ThreadGoalSyncHost {
|
||||
goalTransport: ThreadGoalTransport;
|
||||
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
|
||||
ensureRestoredThreadLoaded?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ThreadGoalSyncActions {
|
||||
|
|
@ -61,7 +63,7 @@ export function createGoalActions(host: GoalActionsHost): GoalActions {
|
|||
setStatus: (threadId, status) => setGoalStatus(host, threadId, status),
|
||||
clear: (threadId) => clearGoal(host, threadId),
|
||||
startEditingCurrent: () => {
|
||||
startEditingCurrent(host);
|
||||
void startEditingCurrent(host);
|
||||
},
|
||||
startEditing: (threadId, objective, tokenBudget) => {
|
||||
startEditing(host, threadId, objective, tokenBudget);
|
||||
|
|
@ -117,6 +119,7 @@ async function setNormalizedObjective(
|
|||
}
|
||||
|
||||
async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise<boolean> {
|
||||
if (!(await prepareGoalMutation(host))) return false;
|
||||
const plan = planGoalObjectiveSave(activeThreadId(host.stateStore.getState()), objective, tokenBudget);
|
||||
switch (plan.kind) {
|
||||
case "reject":
|
||||
|
|
@ -134,6 +137,7 @@ function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGo
|
|||
}
|
||||
|
||||
async function clearGoal(host: GoalActionsHost, threadId: string): Promise<boolean> {
|
||||
if (!(await prepareGoalMutation(host)) || !goalMutationTargetsActiveThread(host, threadId)) return false;
|
||||
try {
|
||||
if (!(await host.goalTransport.clearThreadGoal(threadId))) return false;
|
||||
applyGoalIfActive(host, threadId, null, { reportChange: true });
|
||||
|
|
@ -145,6 +149,7 @@ async function clearGoal(host: GoalActionsHost, threadId: string): Promise<boole
|
|||
}
|
||||
|
||||
async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
|
||||
if (!(await prepareGoalMutation(host)) || !goalMutationTargetsActiveThread(host, threadId)) return false;
|
||||
try {
|
||||
const goal = await host.goalTransport.setThreadGoal(threadId, params);
|
||||
if (goal === undefined) return false;
|
||||
|
|
@ -171,12 +176,38 @@ function applyGoalIfActive(
|
|||
return true;
|
||||
}
|
||||
|
||||
function startEditingCurrent(host: GoalActionsHost): void {
|
||||
async function startEditingCurrent(host: GoalActionsHost): Promise<void> {
|
||||
if (!(await prepareGoalMutation(host)) || !goalMutationAllowedNow(host)) return;
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const goal = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
||||
startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null);
|
||||
}
|
||||
|
||||
async function prepareGoalMutation(host: GoalActionsHost): Promise<boolean> {
|
||||
const decision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation");
|
||||
if (decision.kind === "allowed") return true;
|
||||
if (decision.kind === "blocked") {
|
||||
host.addSystemMessage(decision.message);
|
||||
return false;
|
||||
}
|
||||
if (!host.ensureRestoredThreadLoaded || !(await host.ensureRestoredThreadLoaded())) return false;
|
||||
const resumedDecision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation");
|
||||
if (resumedDecision.kind === "allowed") return true;
|
||||
if (resumedDecision.kind === "blocked") host.addSystemMessage(resumedDecision.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
function goalMutationTargetsActiveThread(host: GoalActionsHost, threadId: string): boolean {
|
||||
return activeThreadId(host.stateStore.getState()) === threadId && goalMutationAllowedNow(host);
|
||||
}
|
||||
|
||||
function goalMutationAllowedNow(host: GoalActionsHost): boolean {
|
||||
const decision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation");
|
||||
if (decision.kind === "allowed") return true;
|
||||
if (decision.kind === "blocked") host.addSystemMessage(decision.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
function startEditing(host: GoalActionsHost, threadId: string | null, objective: string, tokenBudget: number | null): void {
|
||||
host.stateStore.dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
||||
}
|
||||
|
|
@ -200,6 +231,7 @@ async function startThreadAndSaveObjective(
|
|||
): Promise<boolean> {
|
||||
try {
|
||||
if (!(await host.goalTransport.ensureConnected())) return false;
|
||||
if (!emptyPanelCanStartGoalThread(host)) return false;
|
||||
const response = await host.startThread(plan.objective, { syncGoal: false });
|
||||
const threadId = response?.threadId ?? null;
|
||||
return threadId ? await setNormalizedObjective(host, threadId, plan.objective, plan.tokenBudget) : false;
|
||||
|
|
@ -209,6 +241,11 @@ async function startThreadAndSaveObjective(
|
|||
}
|
||||
}
|
||||
|
||||
function emptyPanelCanStartGoalThread(host: GoalActionsHost): boolean {
|
||||
const state = host.stateStore.getState();
|
||||
return state.panelThread.kind === "empty" && activePanelOperationDecision(state, "goal-mutation").kind === "allowed";
|
||||
}
|
||||
|
||||
async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise<void> {
|
||||
try {
|
||||
await host.goalTransport.recordThreadGoalUserMessage(threadId, objective);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model";
|
||||
import { activeThreadRuntimeState } from "../../domain/runtime/state";
|
||||
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
|
||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
|
||||
import { chatTurnBusy } from "../turns/turn-state";
|
||||
|
|
@ -69,6 +70,7 @@ async function compactActiveThread(host: ThreadManagementActionsHost): Promise<v
|
|||
}
|
||||
|
||||
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
|
||||
if (activePanelOperationBlocked(host, threadId, "compact")) return;
|
||||
const scope = captureThreadManagementPanelScope(host, threadId);
|
||||
try {
|
||||
if (!(await host.threadTransport.compactThread(threadId))) return;
|
||||
|
|
@ -108,15 +110,7 @@ async function forkThreadFromTurn(
|
|||
turnId: string | null,
|
||||
archiveSource: boolean,
|
||||
): Promise<void> {
|
||||
const activeThread = activeThreadState(threadManagementState(host));
|
||||
if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") {
|
||||
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 (activePanelOperationBlocked(host, threadId, "fork")) return;
|
||||
if (chatTurnBusy(threadManagementState(host))) {
|
||||
host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
|
||||
return;
|
||||
|
|
@ -177,15 +171,7 @@ async function renameThread(host: ThreadManagementActionsHost, threadId: string,
|
|||
}
|
||||
|
||||
async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
|
||||
const activeThread = activeThreadState(threadManagementState(host));
|
||||
if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") {
|
||||
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 (activePanelOperationBlocked(host, threadId, "rollback")) return;
|
||||
if (chatTurnBusy(threadManagementState(host))) {
|
||||
host.addSystemMessage("Interrupt the current turn before rolling back.");
|
||||
return;
|
||||
|
|
@ -229,6 +215,15 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
}
|
||||
}
|
||||
|
||||
function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean {
|
||||
const state = threadManagementState(host);
|
||||
if (activeThreadId(state) !== threadId) return false;
|
||||
const decision = activePanelOperationDecision(state, operation);
|
||||
if (decision.kind !== "blocked") return false;
|
||||
host.addSystemMessage(decision.message);
|
||||
return true;
|
||||
}
|
||||
|
||||
function threadManagementState(host: ThreadManagementActionsHost): ChatState {
|
||||
return host.stateStore.getState();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ComposerInputSnapshot } from "../composer/input-snapshot";
|
|||
import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands";
|
||||
import { parseSlashCommand } from "../composer/suggestions";
|
||||
import type { LocalIdSource } from "../local-id-source";
|
||||
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { cancellablePendingSubmissionMatches } from "../state/pending-submission";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { parseWebCommandArgs, type SlashCommandExecutionResult } from "./slash-command-execution";
|
||||
|
|
@ -61,10 +62,12 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
|
|||
const originalDraft = host.composer.draft;
|
||||
const draft = originalDraft.trim();
|
||||
if (host.ensureRestoredThreadLoaded && !(await host.ensureRestoredThreadLoaded())) return;
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
const chatState = host.stateStore.getState();
|
||||
const state = submissionStateSnapshot(chatState);
|
||||
if (host.stateStore.getState().pendingSubmission) return;
|
||||
if (state.activeThreadSubagent) {
|
||||
host.status.addSystemMessage("Messages and slash commands are unavailable in agent threads. Start a new chat to continue.");
|
||||
const operationDecision = activePanelOperationDecision(chatState, "submit");
|
||||
if (operationDecision.kind === "blocked") {
|
||||
host.status.addSystemMessage(operationDecision.message);
|
||||
return;
|
||||
}
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ChatRuntimeState } from "../../domain/runtime/state";
|
||||
import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/thread-stream/selectors";
|
||||
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { activeThreadId, activeThreadState, type ChatActiveThreadState, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { type ChatThreadStreamState, threadStreamItems } from "../state/thread-stream";
|
||||
|
|
@ -15,7 +16,8 @@ export interface PlanImplementationHost {
|
|||
}
|
||||
|
||||
interface PlanImplementationState {
|
||||
activeThread: Pick<ChatActiveThreadState, "id" | "provenance"> | null;
|
||||
activeThread: Pick<ChatActiveThreadState, "id"> | null;
|
||||
modeAllowed: boolean;
|
||||
turn: ChatTurnState;
|
||||
runtime: { pending: Pick<ChatRuntimeState["pending"], "collaborationMode"> };
|
||||
threadStream: Pick<ChatThreadStreamState, "stableItems" | "activeSegment">;
|
||||
|
|
@ -24,6 +26,7 @@ interface PlanImplementationState {
|
|||
export function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null {
|
||||
return implementPlanTarget({
|
||||
activeThread: activeThreadState(state),
|
||||
modeAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed",
|
||||
turn: state.turn,
|
||||
runtime: state.runtime,
|
||||
threadStream: state.threadStream,
|
||||
|
|
@ -34,7 +37,7 @@ export function implementPlanTarget(state: PlanImplementationState): PlanImpleme
|
|||
const { activeThread } = state;
|
||||
if (
|
||||
!activeThread ||
|
||||
activeThread.provenance?.kind === "subagent" ||
|
||||
!state.modeAllowed ||
|
||||
chatTurnBusy(state) ||
|
||||
state.runtime.pending.collaborationMode.kind !== "set" ||
|
||||
state.runtime.pending.collaborationMode.value !== "plan"
|
||||
|
|
@ -46,7 +49,8 @@ export function implementPlanTarget(state: PlanImplementationState): PlanImpleme
|
|||
|
||||
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
|
||||
if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId) return;
|
||||
if (!(await host.ensureConnected()) || !activeThreadId(host.stateStore.getState())) return;
|
||||
if (!(await host.ensureConnected())) return;
|
||||
if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId || !activeThreadId(host.stateStore.getState())) return;
|
||||
|
||||
host.requestDefaultCollaborationModeForNextTurn();
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ export interface SlashCommandExecutionPorts {
|
|||
|
||||
export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts {
|
||||
activeThreadId: string | null;
|
||||
activeThreadEphemeral: boolean;
|
||||
listedThreads: readonly Thread[];
|
||||
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
|
||||
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise<WebUrlInput>;
|
||||
|
|
@ -166,10 +165,6 @@ export async function executeSlashCommand(
|
|||
context.addSystemMessage("No active thread to fork.");
|
||||
return;
|
||||
}
|
||||
if (context.activeThreadEphemeral) {
|
||||
context.addSystemMessage("Side chats cannot be forked.");
|
||||
return;
|
||||
}
|
||||
await context.threadActions.forkThread(context.activeThreadId);
|
||||
return;
|
||||
case "btw":
|
||||
|
|
@ -177,10 +172,6 @@ export async function executeSlashCommand(
|
|||
context.addSystemMessage("No active thread for a side chat.");
|
||||
return;
|
||||
}
|
||||
if (context.activeThreadEphemeral) {
|
||||
context.addSystemMessage("Side chats cannot be started from another side chat.");
|
||||
return;
|
||||
}
|
||||
if (!context.openSideChat) {
|
||||
context.addSystemMessage("Side chat is not available.");
|
||||
return;
|
||||
|
|
@ -192,10 +183,6 @@ export async function executeSlashCommand(
|
|||
context.addSystemMessage("No active thread to roll back.");
|
||||
return;
|
||||
}
|
||||
if (context.activeThreadEphemeral) {
|
||||
context.addSystemMessage("Side chats cannot be rolled back.");
|
||||
return;
|
||||
}
|
||||
await context.threadActions.rollbackThread(context.activeThreadId);
|
||||
return;
|
||||
case "compact":
|
||||
|
|
@ -246,10 +233,6 @@ export async function executeSlashCommand(
|
|||
case "permissions": {
|
||||
const requested = parsePermissionProfileOverride(args);
|
||||
if (requested !== undefined) {
|
||||
if (context.activeThreadEphemeral) {
|
||||
context.addSystemMessage("Permission changes are unavailable in side chats.");
|
||||
return;
|
||||
}
|
||||
const applied = await applyPermissionProfileOverride(context, requested);
|
||||
if (applied === false) return;
|
||||
context.addSystemMessage(permissionProfileOverrideMessage(requested));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { runtimeConfigOrDefault } from "../../../../domain/runtime/config";
|
|||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
|
||||
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
|
||||
import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands";
|
||||
import { activePanelOperationForSlashCommand, type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands";
|
||||
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import {
|
||||
|
|
@ -31,15 +32,17 @@ export async function executeSlashCommandWithState(
|
|||
inputSnapshot?: ComposerInputSnapshot,
|
||||
isWebImportCurrent?: () => boolean,
|
||||
): 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.");
|
||||
const chatState = host.stateStore.getState();
|
||||
const operation = activePanelOperationForSlashCommand(command, args);
|
||||
if (operation) {
|
||||
const decision = activePanelOperationDecision(chatState, operation);
|
||||
if (decision.kind === "blocked") throw new Error(decision.message);
|
||||
}
|
||||
const state = submissionStateSnapshot(chatState);
|
||||
if (!host.connectionAvailable() && slashCommandRequiresConnection(command)) return;
|
||||
return runSlashCommand(command, args, {
|
||||
...host,
|
||||
activeThreadId: state.activeThreadId,
|
||||
activeThreadEphemeral: state.activeThreadEphemeral,
|
||||
listedThreads: state.listedThreads,
|
||||
referThread: host.referThread,
|
||||
readWebUrl: host.readWebUrl,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import { activeThreadState, type ChatState, panelThreadProvenance } from "../state/root-reducer";
|
||||
import { activeThreadState, type ChatState } from "../state/root-reducer";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state";
|
||||
|
||||
export interface SubmissionStateSnapshot {
|
||||
activeThreadId: string | null;
|
||||
activeThreadEphemeral: boolean;
|
||||
activeThreadSubagent: boolean;
|
||||
activeTurnId: string | null;
|
||||
busy: boolean;
|
||||
listedThreads: readonly Thread[];
|
||||
|
|
@ -19,8 +17,6 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
|
|||
const activeThread = activeThreadState(state);
|
||||
return {
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
activeTurnId: activeTurnId(state),
|
||||
busy: chatTurnBusy(state),
|
||||
listedThreads: state.threadList.listedThreads,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type CodexInput, codexTextInput } from "../../../../domain/chat/input";
|
|||
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
|
||||
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
|
||||
import type { LocalIdSource } from "../local-id-source";
|
||||
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { pendingSubmissionMatches } from "../state/pending-submission";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import {
|
||||
|
|
@ -86,6 +87,12 @@ async function sendTurnText(
|
|||
if (!(await host.ensureRestoredThreadLoaded())) return false;
|
||||
if (!pendingRequestIsCurrent(host, request)) return false;
|
||||
|
||||
const operationDecision = activePanelOperationDecision(host.stateStore.getState(), "submit");
|
||||
if (operationDecision.kind === "blocked") {
|
||||
host.addSystemMessage(operationDecision.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialState = submissionStateSnapshot(host.stateStore.getState());
|
||||
const plan = planTurnSubmission(initialState);
|
||||
|
||||
|
|
@ -204,9 +211,6 @@ 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 }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
|
||||
import { activePanelOperationDecision } from "../../application/panel-operation-policy";
|
||||
import type { PendingRequestActions } from "../../application/pending-requests/pending-request-actions";
|
||||
import { activeThreadId, panelThreadProvenance } from "../../application/state/root-reducer";
|
||||
import { activeThreadId } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
import type { HistoryController } from "../../application/threads/history-controller";
|
||||
import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions";
|
||||
|
|
@ -69,12 +70,16 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
rename,
|
||||
navigation,
|
||||
openSideChat: () => {
|
||||
const threadId = activeThreadId(stateStore.getState());
|
||||
const state = stateStore.getState();
|
||||
if (activePanelOperationDecision(state, "start-side-chat").kind !== "allowed") return;
|
||||
const threadId = activeThreadId(state);
|
||||
if (!threadId) return;
|
||||
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||
const thread = state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
void environment.plugin.workspace.openSideChat(threadId, thread?.name ?? thread?.preview ?? null);
|
||||
},
|
||||
activeThreadChatActionsDisabled: () => panelThreadProvenance(stateStore.getState())?.kind === "subagent",
|
||||
canStartSideChat: () => activePanelOperationDecision(stateStore.getState(), "start-side-chat").kind === "allowed",
|
||||
canCompact: () => activePanelOperationDecision(stateStore.getState(), "compact").kind === "allowed",
|
||||
canMutateGoal: () => activePanelOperationDecision(stateStore.getState(), "goal-mutation").kind !== "blocked",
|
||||
});
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
connection: {
|
||||
|
|
|
|||
|
|
@ -211,11 +211,16 @@ export function createThreadLifecycleBundle(
|
|||
): ChatPanelThreadLifecycleBundle {
|
||||
const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, refreshLiveState, notifyActiveThreadIdentityChanged } =
|
||||
input;
|
||||
let sessionLifecycle: ChatPanelThreadLifecycle | null = null;
|
||||
const goals = createGoalActions({
|
||||
stateStore: host.stateStore,
|
||||
goalTransport: appServer.threadGoal,
|
||||
localItemIds,
|
||||
startThread: (preview, options) => threadStart.startThread(preview, options),
|
||||
ensureRestoredThreadLoaded: async () => {
|
||||
if (!sessionLifecycle) return false;
|
||||
return sessionLifecycle.restoration.ensureLoaded((threadId) => sessionLifecycle?.resume.resumeThread(threadId) ?? Promise.resolve());
|
||||
},
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
|
|
@ -243,6 +248,7 @@ export function createThreadLifecycleBundle(
|
|||
refreshLiveState,
|
||||
notifyActiveThreadIdentityChanged,
|
||||
});
|
||||
sessionLifecycle = lifecycle;
|
||||
const { identity, restoration, resume } = lifecycle;
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "../application/composer/context-references";
|
||||
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
||||
import type { NoteCandidateProvider } from "../application/composer/note-context";
|
||||
import { activePanelOperationForSlashCommandSuggestion } from "../application/composer/slash-commands";
|
||||
import {
|
||||
activeComposerSuggestions,
|
||||
applyComposerSuggestionInsertion,
|
||||
|
|
@ -27,7 +28,8 @@ import {
|
|||
type PreparedComposerInput,
|
||||
preparedUserInputWithWikiLinkMentionsSkillsAndContext,
|
||||
} from "../application/composer/wikilink-context";
|
||||
import { activeThreadState, type ChatAction, type ChatState, panelThreadProvenance } from "../application/state/root-reducer";
|
||||
import { activePanelOperationDecision } from "../application/panel-operation-policy";
|
||||
import { activeThreadState, type ChatAction, type ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer";
|
||||
import { syncComposerHeight } from "../ui/composer.dom";
|
||||
|
|
@ -107,7 +109,7 @@ export class ChatComposerController {
|
|||
draft: model.draft,
|
||||
busy: model.turnBusy,
|
||||
canInterrupt: this.options.canInterrupt(model),
|
||||
submissionDisabled: model.activeThreadSubagent || model.webSubmissionPending,
|
||||
submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending,
|
||||
webSubmissionCancellable: model.webSubmissionCancellable,
|
||||
normalPlaceholder: projection.placeholder,
|
||||
suggestions: model.suggestions,
|
||||
|
|
@ -287,7 +289,6 @@ export class ChatComposerController {
|
|||
}
|
||||
|
||||
private activeSuggestions(beforeCursor: string, state: ChatState): readonly ComposerSuggestion[] {
|
||||
const activeThread = activeThreadState(state);
|
||||
return activeComposerSuggestions(
|
||||
beforeCursor,
|
||||
this.noteCandidates(),
|
||||
|
|
@ -296,9 +297,12 @@ export class ChatComposerController {
|
|||
state.connection.availableModels,
|
||||
this.options.currentModelForSuggestions(),
|
||||
{
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
activeThreadId: activeThreadState(state)?.id ?? null,
|
||||
slashCommandAvailable: (command) => {
|
||||
if (activePanelOperationDecision(state, "submit").kind === "blocked") return false;
|
||||
const operation = activePanelOperationForSlashCommandSuggestion(command);
|
||||
return !operation || activePanelOperationDecision(state, operation).kind === "allowed";
|
||||
},
|
||||
contextReferences: this.contextReferences(),
|
||||
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { explicitThreadName } from "../../../domain/threads/model";
|
||||
import { activePanelOperationDecision } from "../application/panel-operation-policy";
|
||||
import { threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot";
|
||||
import { activeThreadState, type ChatActiveThreadState, type ChatState, panelThreadProvenance } from "../application/state/root-reducer";
|
||||
import { threadStreamItems } from "../application/state/thread-stream";
|
||||
|
|
@ -10,6 +11,9 @@ export interface ChatPanelToolbarModel {
|
|||
readonly threads: ChatState["threadList"]["listedThreads"];
|
||||
readonly activeThreadId: string | null;
|
||||
readonly activeThreadSubagent: boolean;
|
||||
readonly sideChatStartDisabled: boolean;
|
||||
readonly compactDisabled: boolean;
|
||||
readonly goalMutationDisabled: boolean;
|
||||
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
|
||||
readonly turnBusy: boolean;
|
||||
readonly connection: ChatState["connection"];
|
||||
|
|
@ -21,6 +25,7 @@ export interface ChatPanelToolbarModel {
|
|||
|
||||
export interface ChatPanelGoalModel {
|
||||
readonly goal: ChatActiveThreadState["goal"];
|
||||
readonly goalMutationsAllowed: boolean;
|
||||
readonly goalEditor: ChatState["ui"]["goalEditor"];
|
||||
readonly goalObjectiveExpanded: ChatState["ui"]["disclosures"]["goalObjectiveExpanded"];
|
||||
}
|
||||
|
|
@ -28,8 +33,9 @@ export interface ChatPanelGoalModel {
|
|||
export interface ChatPanelThreadStreamModel {
|
||||
readonly activeThreadId: string | null;
|
||||
readonly activeThreadCwd: ChatActiveThreadState["cwd"];
|
||||
readonly activeThreadLifetime: ChatActiveThreadState["lifetime"];
|
||||
readonly activeThreadProvenance: ChatActiveThreadState["provenance"];
|
||||
readonly forkAllowed: boolean;
|
||||
readonly rollbackAllowed: boolean;
|
||||
readonly planImplementationAllowed: boolean;
|
||||
readonly turn: ChatState["turn"];
|
||||
readonly runtimeCollaborationMode: ChatState["runtime"]["pending"]["collaborationMode"];
|
||||
readonly threadStream: ChatState["threadStream"];
|
||||
|
|
@ -57,6 +63,7 @@ export interface ChatPanelComposerModel {
|
|||
readonly activeThreadId: string | null;
|
||||
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
|
||||
readonly activeThreadSubagent: boolean;
|
||||
readonly submissionBlockedByPanelPolicy: boolean;
|
||||
readonly webSubmissionPending: boolean;
|
||||
readonly webSubmissionCancellable: boolean;
|
||||
readonly turnBusy: boolean;
|
||||
|
|
@ -71,6 +78,9 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel
|
|||
threads: state.threadList.listedThreads,
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
sideChatStartDisabled: activePanelOperationDecision(state, "start-side-chat").kind !== "allowed",
|
||||
compactDisabled: activePanelOperationDecision(state, "compact").kind !== "allowed",
|
||||
goalMutationDisabled: activePanelOperationDecision(state, "goal-mutation").kind === "blocked",
|
||||
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
|
||||
turnBusy: chatTurnBusy(state),
|
||||
connection: state.connection,
|
||||
|
|
@ -84,6 +94,7 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel
|
|||
export function selectChatPanelGoal(state: ChatState): ChatPanelGoalModel {
|
||||
return {
|
||||
goal: activeThreadState(state)?.goal ?? null,
|
||||
goalMutationsAllowed: activePanelOperationDecision(state, "goal-mutation").kind === "allowed",
|
||||
goalEditor: state.ui.goalEditor,
|
||||
goalObjectiveExpanded: state.ui.disclosures.goalObjectiveExpanded,
|
||||
};
|
||||
|
|
@ -94,8 +105,9 @@ export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadSt
|
|||
return {
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadCwd: activeThread?.cwd ?? null,
|
||||
activeThreadLifetime: activeThread?.lifetime ?? null,
|
||||
activeThreadProvenance: panelThreadProvenance(state),
|
||||
forkAllowed: activePanelOperationDecision(state, "fork").kind === "allowed",
|
||||
rollbackAllowed: activePanelOperationDecision(state, "rollback").kind === "allowed",
|
||||
planImplementationAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed",
|
||||
turn: state.turn,
|
||||
runtimeCollaborationMode: state.runtime.pending.collaborationMode,
|
||||
threadStream: state.threadStream,
|
||||
|
|
@ -128,6 +140,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
|
|||
activeThreadId,
|
||||
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
submissionBlockedByPanelPolicy: activePanelOperationDecision(state, "submit").kind === "blocked",
|
||||
webSubmissionPending: state.pendingSubmission !== null,
|
||||
webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable",
|
||||
turnBusy: chatTurnBusy(state),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ function chatPanelGoalViewModel(
|
|||
},
|
||||
options: {
|
||||
sendShortcut: surface.sendShortcut(),
|
||||
readOnly: !model.goalMutationsAllowed,
|
||||
},
|
||||
editor: projection.editor,
|
||||
display: projection.display,
|
||||
|
|
|
|||
|
|
@ -148,12 +148,11 @@ function threadStreamStateProjection(
|
|||
};
|
||||
const workspaceRoot = model.activeThreadCwd ?? context.vaultPath;
|
||||
const turnBusy = chatTurnBusy(model.turn);
|
||||
const actionCandidatesAllowed =
|
||||
!turnBusy && model.activeThreadLifetime?.kind !== "ephemeral" && model.activeThreadProvenance?.kind !== "subagent";
|
||||
const rollbackCandidate = actionCandidatesAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null;
|
||||
const forkCandidates = actionCandidatesAllowed ? forkCandidatesFromItems(canonicalItems) : [];
|
||||
const rollbackCandidate = !turnBusy && model.rollbackAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null;
|
||||
const forkCandidates = !turnBusy && model.forkAllowed ? forkCandidatesFromItems(canonicalItems) : [];
|
||||
const planTarget = implementPlanTarget({
|
||||
activeThread: model.activeThreadId ? { id: model.activeThreadId, provenance: model.activeThreadProvenance } : null,
|
||||
activeThread: model.activeThreadId ? { id: model.activeThreadId } : null,
|
||||
modeAllowed: model.planImplementationAllowed,
|
||||
turn: model.turn,
|
||||
runtime: { pending: { collaborationMode: model.runtimeCollaborationMode } },
|
||||
threadStream: model.threadStream,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ interface ToolbarViewModelInput {
|
|||
|
||||
interface ToolbarStateProjection {
|
||||
newChatDisabled: boolean;
|
||||
activeThreadChatActionsDisabled: boolean;
|
||||
sideChatStartDisabled: boolean;
|
||||
compactDisabled: boolean;
|
||||
goalMutationDisabled: boolean;
|
||||
chatActionsOpen: boolean;
|
||||
historyOpen: boolean;
|
||||
statusPanelOpen: boolean;
|
||||
|
|
@ -97,7 +99,9 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
|
|||
});
|
||||
return {
|
||||
newChatDisabled: projection.newChatDisabled,
|
||||
activeThreadChatActionsDisabled: projection.activeThreadChatActionsDisabled,
|
||||
sideChatStartDisabled: projection.sideChatStartDisabled,
|
||||
compactDisabled: projection.compactDisabled,
|
||||
goalMutationDisabled: projection.goalMutationDisabled,
|
||||
chatActionsOpen: projection.chatActionsOpen,
|
||||
historyOpen: projection.historyOpen,
|
||||
statusPanelOpen: projection.statusPanelOpen,
|
||||
|
|
@ -158,7 +162,9 @@ function toolbarStateProjection(input: {
|
|||
const statusPanelOpen = toolbarPanel === "status-panel";
|
||||
return {
|
||||
newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent,
|
||||
activeThreadChatActionsDisabled: input.model.activeThreadSubagent,
|
||||
sideChatStartDisabled: input.model.sideChatStartDisabled,
|
||||
compactDisabled: input.model.compactDisabled,
|
||||
goalMutationDisabled: input.model.goalMutationDisabled,
|
||||
chatActionsOpen,
|
||||
historyOpen,
|
||||
statusPanelOpen,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export interface ToolbarUiActionDependencies {
|
|||
rename: ThreadRenameEditorActions;
|
||||
navigation: ThreadNavigationActions;
|
||||
openSideChat?: () => void;
|
||||
activeThreadChatActionsDisabled: () => boolean;
|
||||
canStartSideChat: () => boolean;
|
||||
canCompact: () => boolean;
|
||||
canMutateGoal: () => boolean;
|
||||
}
|
||||
|
||||
export interface ToolbarOutsidePointerHit {
|
||||
|
|
@ -139,17 +141,17 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb
|
|||
...(deps.openSideChat
|
||||
? {
|
||||
startSideChat: () => {
|
||||
if (deps.activeThreadChatActionsDisabled()) return;
|
||||
if (!deps.canStartSideChat()) return;
|
||||
deps.openSideChat?.();
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
compactContext: () => {
|
||||
if (deps.activeThreadChatActionsDisabled()) return;
|
||||
if (!deps.canCompact()) return;
|
||||
void deps.threadActions.compactActiveThread();
|
||||
},
|
||||
setGoal: () => {
|
||||
if (deps.activeThreadChatActionsDisabled()) return;
|
||||
if (!deps.canMutateGoal()) return;
|
||||
deps.goals.startEditingCurrent();
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface GoalPanelActions {
|
|||
|
||||
export interface GoalPanelOptions {
|
||||
sendShortcut: SendShortcut;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface GoalPanelEditorState {
|
||||
|
|
@ -58,7 +59,7 @@ export function GoalPanel({
|
|||
const resetObjective = goal?.objective ?? "";
|
||||
const resetStatus = goal?.status ?? null;
|
||||
const resetTokenBudget = goal?.tokenBudget ?? null;
|
||||
const editing = editor.editing;
|
||||
const editing = editor.editing && !options.readOnly;
|
||||
const objective = editor.objectiveDraft;
|
||||
const tokenBudget = editor.tokenBudgetDraft;
|
||||
const objectiveExpanded = display.objectiveExpanded;
|
||||
|
|
@ -114,7 +115,7 @@ export function GoalPanel({
|
|||
<div className="codex-panel__goal-role">
|
||||
<span>Goal</span>
|
||||
<div className="codex-panel__goal-actions">
|
||||
{goal && !editing ? (
|
||||
{goal && !editing && !options.readOnly ? (
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
label="Edit goal"
|
||||
|
|
@ -122,7 +123,7 @@ export function GoalPanel({
|
|||
onClick={actions.onStartEditing}
|
||||
/>
|
||||
) : null}
|
||||
{goal && !terminal && !editing && goal.status === "active" ? (
|
||||
{goal && !terminal && !editing && !options.readOnly && goal.status === "active" ? (
|
||||
<IconButton
|
||||
icon="pause"
|
||||
label="Pause goal"
|
||||
|
|
@ -130,7 +131,7 @@ export function GoalPanel({
|
|||
onClick={actions.onPause}
|
||||
/>
|
||||
) : null}
|
||||
{goal && !terminal && !editing && goal.status === "paused" ? (
|
||||
{goal && !terminal && !editing && !options.readOnly && goal.status === "paused" ? (
|
||||
<IconButton
|
||||
icon="play"
|
||||
label="Resume goal"
|
||||
|
|
@ -138,7 +139,7 @@ export function GoalPanel({
|
|||
onClick={actions.onResume}
|
||||
/>
|
||||
) : null}
|
||||
{goal && !editing ? (
|
||||
{goal && !editing && !options.readOnly ? (
|
||||
<IconButton
|
||||
icon="x"
|
||||
label="Clear goal"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ interface ToolbarStatusSection {
|
|||
|
||||
export interface ToolbarViewModel {
|
||||
newChatDisabled: boolean;
|
||||
activeThreadChatActionsDisabled: boolean;
|
||||
sideChatStartDisabled: boolean;
|
||||
compactDisabled: boolean;
|
||||
goalMutationDisabled: boolean;
|
||||
chatActionsOpen: boolean;
|
||||
historyOpen: boolean;
|
||||
statusPanelOpen: boolean;
|
||||
|
|
@ -176,19 +178,19 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions
|
|||
actions.startSideChat?.();
|
||||
}}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.activeThreadChatActionsDisabled || model.threads.every((thread) => !thread.selected)}
|
||||
disabled={model.sideChatStartDisabled || model.threads.every((thread) => !thread.selected)}
|
||||
/>
|
||||
<ToolbarPanelItem
|
||||
label="Compact context"
|
||||
onClick={actions.compactContext}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.activeThreadChatActionsDisabled}
|
||||
disabled={model.compactDisabled}
|
||||
/>
|
||||
<ToolbarPanelItem
|
||||
label="Set goal..."
|
||||
onClick={actions.setGoal}
|
||||
className="codex-panel__chat-actions-panel-item"
|
||||
disabled={model.activeThreadChatActionsDisabled}
|
||||
disabled={model.goalMutationDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,6 @@ describe("slash command catalog", () => {
|
|||
SLASH_COMMANDS.filter((definition) => !slashCommandAvailableInSideChat(definition.command.slice(1) as SlashCommandName)).map(
|
||||
(item) => item.command,
|
||||
),
|
||||
).toEqual(["/fork", "/btw", "/rollback"]);
|
||||
).toEqual(["/fork", "/btw", "/rollback", "/auto-review", "/fast", "/plan", "/goal"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references";
|
||||
import { slashCommandAvailableInSideChat } from "../../../../../src/features/chat/application/composer/slash-commands";
|
||||
import {
|
||||
activeComposerSuggestions,
|
||||
applyComposerSuggestionInsertion,
|
||||
|
|
@ -282,17 +283,17 @@ describe("composer suggestions", () => {
|
|||
});
|
||||
|
||||
it("omits unavailable thread mutations from side-chat slash suggestions", () => {
|
||||
const options = { activeThreadEphemeral: true };
|
||||
const options = { slashCommandAvailable: slashCommandAvailableInSideChat };
|
||||
|
||||
expect(suggestionReplacements(activeComposerSuggestions("/f", notes, [], [], [], null, options))).not.toContain("/fork");
|
||||
expect(suggestionReplacements(activeComposerSuggestions("/r", notes, [], [], [], null, options))).not.toContain("/rollback");
|
||||
expect(suggestionReplacements(activeComposerSuggestions("/b", notes, [], [], [], null, options))).not.toContain("/btw");
|
||||
expect(suggestionReplacements(activeComposerSuggestions("/c", notes, [], [], [], null, options))).toContain("/compact");
|
||||
expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).toContain("/goal");
|
||||
expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).not.toContain("/goal");
|
||||
});
|
||||
|
||||
it("omits slash suggestions from subagent threads", () => {
|
||||
const options = { activeThreadSubagent: true };
|
||||
const options = { slashCommandAvailable: () => false };
|
||||
expect(activeComposerSuggestions("/", notes, [], [], [], null, options)).toEqual([]);
|
||||
expect(activeComposerSuggestions("/permissions ", notes, [], [], [], null, options)).toEqual([]);
|
||||
expect(activeComposerSuggestions("/resume ", notes, [], [], [], null, options)).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ActivePanelOperation,
|
||||
type ActivePanelThreadFacts,
|
||||
activePanelOperationDecisionForFacts,
|
||||
} from "../../../../src/features/chat/application/panel-operation-policy";
|
||||
|
||||
const operations: readonly ActivePanelOperation[] = [
|
||||
"submit",
|
||||
"start-side-chat",
|
||||
"compact",
|
||||
"fork",
|
||||
"rollback",
|
||||
"thread-settings",
|
||||
"permission-settings",
|
||||
"goal-read",
|
||||
"goal-mutation",
|
||||
"implement-plan",
|
||||
];
|
||||
|
||||
describe("activePanelOperationDecisionForFacts", () => {
|
||||
it("allows every mode-derived operation in an interactive persistent panel", () => {
|
||||
expectDecisionKinds({ phase: "active", lifetime: "persistent", provenance: "interactive" }, allAllowed());
|
||||
});
|
||||
|
||||
it("keeps side-chat submission and compaction available while blocking persistent-thread mutations", () => {
|
||||
expectDecisionKinds(
|
||||
{ phase: "active", lifetime: "ephemeral", provenance: "interactive" },
|
||||
decisions({ submit: "allowed", compact: "allowed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps subagent panels read-only", () => {
|
||||
expectDecisionKinds({ phase: "active", lifetime: "persistent", provenance: "subagent" }, decisions({ "goal-read": "allowed" }));
|
||||
});
|
||||
|
||||
it("keeps the stricter subagent restrictions when mode facts conflict", () => {
|
||||
expectDecisionKinds({ phase: "active", lifetime: "ephemeral", provenance: "subagent" }, decisions({}));
|
||||
});
|
||||
|
||||
it("requires restoration before a persistent goal mutation", () => {
|
||||
expect(activePanelOperationDecisionForFacts({ phase: "awaiting-resume", provenance: "interactive" }, "goal-mutation")).toEqual({
|
||||
kind: "resume-required",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps restored subagent goals read-only before loading", () => {
|
||||
expect(activePanelOperationDecisionForFacts({ phase: "awaiting-resume", provenance: "subagent" }, "goal-mutation")).toEqual({
|
||||
kind: "blocked",
|
||||
message: "Goals are read-only in agent threads.",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows empty panels to start a goal-backed thread", () => {
|
||||
expectDecisionKinds({ phase: "empty" }, allAllowed());
|
||||
});
|
||||
});
|
||||
|
||||
function allAllowed(): Record<ActivePanelOperation, "allowed"> {
|
||||
return Object.fromEntries(operations.map((operation) => [operation, "allowed"])) as Record<ActivePanelOperation, "allowed">;
|
||||
}
|
||||
|
||||
function decisions(allowed: Partial<Record<ActivePanelOperation, "allowed">>): Record<ActivePanelOperation, "allowed" | "blocked"> {
|
||||
return Object.fromEntries(operations.map((operation) => [operation, allowed[operation] ?? "blocked"])) as Record<
|
||||
ActivePanelOperation,
|
||||
"allowed" | "blocked"
|
||||
>;
|
||||
}
|
||||
|
||||
function expectDecisionKinds(facts: ActivePanelThreadFacts, expected: Record<ActivePanelOperation, "allowed" | "blocked">): void {
|
||||
for (const operation of operations) {
|
||||
expect(activePanelOperationDecisionForFacts(facts, operation).kind).toBe(expected[operation]);
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +114,26 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
expect(messages).toEqual(["Permission changes are unavailable in side chats.", "Permission changes are unavailable in side chats."]);
|
||||
});
|
||||
|
||||
it("keeps an empty settings commit harmless in a side chat while blocking a real settings mutation", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
activeThread: {
|
||||
id: "side",
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
},
|
||||
});
|
||||
const store = createChatStateStore(state);
|
||||
const transport = settingsTransportFixture();
|
||||
const messages: string[] = [];
|
||||
const actions = runtimeActionsFixture(store, transport, messages);
|
||||
|
||||
await expect(actions.applyPendingThreadSettings()).resolves.toBe(true);
|
||||
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false);
|
||||
|
||||
expect(transport.updateThreadSettings).not.toHaveBeenCalled();
|
||||
expect(messages).toEqual(["Thread settings are unavailable in side chats."]);
|
||||
});
|
||||
|
||||
it("blocks all thread setting changes for subagent threads", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,35 @@ describe("createGoalActions", () => {
|
|||
expect(activeThreadState(stateStore.getState())?.goal).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks goal mutations in side chats before calling app-server", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
activeThread: {
|
||||
id: "side",
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
},
|
||||
});
|
||||
const stateStore = createChatStateStore(state);
|
||||
const goalTransport = goalTransportFixture();
|
||||
const addSystemMessage = vi.fn();
|
||||
const actions = createGoalActions({
|
||||
stateStore,
|
||||
goalTransport,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "side" }),
|
||||
addSystemMessage,
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(actions.setObjective("side", "Ship", null)).resolves.toBe(false);
|
||||
await expect(actions.clear("side")).resolves.toBe(false);
|
||||
|
||||
expect(goalTransport.setThreadGoal).not.toHaveBeenCalled();
|
||||
expect(goalTransport.clearThreadGoal).not.toHaveBeenCalled();
|
||||
expect(addSystemMessage).toHaveBeenCalledWith("Goals are unavailable in side chats.");
|
||||
});
|
||||
|
||||
it("does not report stale goal action failures after the active thread changes", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
|
|
@ -147,6 +176,29 @@ describe("createGoalActions", () => {
|
|||
expect(addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clear a stale goal when the active thread changes during the policy guard", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = chatStateWith(state, { activeThread: { goal: goal() } });
|
||||
const stateStore = createChatStateStore(state);
|
||||
const goalTransport = goalTransportFixture();
|
||||
const actions = createGoalActions({
|
||||
stateStore,
|
||||
goalTransport,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
startThread: vi.fn().mockResolvedValue({ threadId: "thread" }),
|
||||
addSystemMessage: vi.fn(),
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
});
|
||||
|
||||
const pending = actions.clear("thread");
|
||||
stateStore.dispatch({ type: "active-thread/cleared" });
|
||||
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(goalTransport.clearThreadGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports goal creation as a structured goal event", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
|
|
@ -227,6 +279,86 @@ describe("createGoalActions", () => {
|
|||
expect(goalTransport.recordThreadGoalUserMessage).toHaveBeenCalledWith("thread-new", "Plan release");
|
||||
});
|
||||
|
||||
it("does not start a goal thread when the empty panel changes during connection", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const connection = deferred<boolean>();
|
||||
const goalTransport = goalTransportFixture({ ensureConnected: vi.fn(() => connection.promise) });
|
||||
const startThread = vi.fn().mockResolvedValue({ threadId: "thread-new" });
|
||||
const actions = createGoalActions({
|
||||
stateStore,
|
||||
goalTransport,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
startThread,
|
||||
addSystemMessage: vi.fn(),
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
});
|
||||
|
||||
const pending = actions.saveObjective("Plan release", null);
|
||||
await vi.waitFor(() => expect(goalTransport.ensureConnected).toHaveBeenCalledOnce());
|
||||
stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" });
|
||||
connection.resolve(true);
|
||||
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(startThread).not.toHaveBeenCalled();
|
||||
expect(goalTransport.setThreadGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads an awaiting restored thread before saving its goal", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" });
|
||||
const goalTransport = goalTransportFixture({
|
||||
setThreadGoal: vi.fn().mockResolvedValue(goal({ threadId: "restored", objective: "Resume work" })),
|
||||
});
|
||||
const startThread = vi.fn().mockResolvedValue({ threadId: "new-thread" });
|
||||
const ensureRestoredThreadLoaded = vi.fn(async () => {
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: {
|
||||
id: "restored",
|
||||
preview: "Restored",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
const actions = createGoalActions({
|
||||
stateStore,
|
||||
goalTransport,
|
||||
localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }),
|
||||
startThread,
|
||||
ensureRestoredThreadLoaded,
|
||||
addSystemMessage: vi.fn(),
|
||||
addGoalEvent: vi.fn(),
|
||||
refreshLiveState: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(actions.saveObjective("Resume work", null)).resolves.toBe(true);
|
||||
|
||||
expect(ensureRestoredThreadLoaded).toHaveBeenCalledOnce();
|
||||
expect(startThread).not.toHaveBeenCalled();
|
||||
expect(goalTransport.setThreadGoal).toHaveBeenCalledWith("restored", {
|
||||
objective: "Resume work",
|
||||
status: "active",
|
||||
tokenBudget: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty goal objective saves before connecting or starting a thread", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const goalTransport = goalTransportFixture();
|
||||
|
|
|
|||
|
|
@ -54,6 +54,20 @@ type ThreadManagementActionsHostMock = Omit<
|
|||
};
|
||||
|
||||
describe("thread management actions", () => {
|
||||
it("allows direct compaction of an active side chat", async () => {
|
||||
const host = hostMock({
|
||||
items: [],
|
||||
activeThread: {
|
||||
id: "side-thread",
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
},
|
||||
});
|
||||
|
||||
await threadManagementActions(host).compactThread("side-thread");
|
||||
|
||||
expect(host.threadTransport.compactThread).toHaveBeenCalledWith("side-thread");
|
||||
});
|
||||
|
||||
it("does not fork an ephemeral side chat", async () => {
|
||||
const host = hostMock({
|
||||
items: [],
|
||||
|
|
|
|||
|
|
@ -120,9 +120,7 @@ describe("submitComposer", () => {
|
|||
|
||||
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.",
|
||||
);
|
||||
expect(host.status.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue.");
|
||||
});
|
||||
|
||||
it("sends plain drafts as turn text", async () => {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ const streamingPlanItem = (id: string): ThreadStreamItem => ({
|
|||
dialogueState: "streaming",
|
||||
});
|
||||
|
||||
function resumeThread(stateStore: ChatStateStore, items: readonly ThreadStreamItem[]): void {
|
||||
function resumeThread(
|
||||
stateStore: ChatStateStore,
|
||||
items: readonly ThreadStreamItem[],
|
||||
lifetime: { kind: "persistent" } | { kind: "ephemeral"; sourceThreadId: string; sourceThreadTitle: string | null } = {
|
||||
kind: "persistent",
|
||||
},
|
||||
): void {
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
|
|
@ -43,6 +49,7 @@ function resumeThread(stateStore: ChatStateStore, items: readonly ThreadStreamIt
|
|||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
items,
|
||||
lifetime,
|
||||
});
|
||||
stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" });
|
||||
}
|
||||
|
|
@ -99,6 +106,19 @@ describe("implementPlan", () => {
|
|||
expect(implementPlanTargetFromState(stateStore.getState())).toEqual({ itemId: completed.id });
|
||||
});
|
||||
|
||||
it("does not offer or send plan implementation from a side chat", async () => {
|
||||
const { host, ensureConnected, sendTurnText, stateStore } = createPlanImplementationHost();
|
||||
const plan = planItem("plan");
|
||||
resumeThread(stateStore, [plan], { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" });
|
||||
|
||||
expect(implementPlanTargetFromState(stateStore.getState())).toBeNull();
|
||||
|
||||
await implementPlan(host, plan.id);
|
||||
|
||||
expect(ensureConnected).not.toHaveBeenCalled();
|
||||
expect(sendTurnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches out of plan mode and submits the implementation prompt", async () => {
|
||||
const { host, ensureConnected, requestDefaultCollaborationModeForNextTurn, sendTurnText, stateStore } = createPlanImplementationHost();
|
||||
const plan = planItem("plan");
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCommandExecutionContext {
|
||||
return {
|
||||
activeThreadId: "thread-1",
|
||||
activeThreadEphemeral: false,
|
||||
listedThreads: [thread({ id: "thread-1", name: "Current" })],
|
||||
startNewThread: vi.fn().mockResolvedValue(undefined),
|
||||
startThreadForGoal: vi.fn().mockResolvedValue("thread-new"),
|
||||
|
|
@ -299,15 +298,6 @@ describe("slash commands", () => {
|
|||
expect(ctx.threadActions.forkThread).toHaveBeenCalledWith("active-thread");
|
||||
});
|
||||
|
||||
it("does not fork a side chat", async () => {
|
||||
const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true });
|
||||
|
||||
await executeSlashCommand("fork", "", ctx);
|
||||
|
||||
expect(ctx.threadActions.forkThread).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be forked.");
|
||||
});
|
||||
|
||||
it("opens a side chat from the active thread", async () => {
|
||||
const openSideChat = vi.fn().mockResolvedValue(undefined);
|
||||
const ctx = context({ activeThreadId: "active-thread", openSideChat });
|
||||
|
|
@ -317,16 +307,6 @@ describe("slash commands", () => {
|
|||
expect(openSideChat).toHaveBeenCalledWith("active-thread");
|
||||
});
|
||||
|
||||
it("does not open a nested side chat", async () => {
|
||||
const openSideChat = vi.fn().mockResolvedValue(undefined);
|
||||
const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true, openSideChat });
|
||||
|
||||
await executeSlashCommand("btw", "", ctx);
|
||||
|
||||
expect(openSideChat).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be started from another side chat.");
|
||||
});
|
||||
|
||||
it("rolls back the active thread for /rollback", async () => {
|
||||
const ctx = context({ activeThreadId: "active-thread" });
|
||||
|
||||
|
|
@ -335,15 +315,6 @@ describe("slash commands", () => {
|
|||
expect(ctx.threadActions.rollbackThread).toHaveBeenCalledWith("active-thread");
|
||||
});
|
||||
|
||||
it("does not roll back a side chat", async () => {
|
||||
const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true });
|
||||
|
||||
await executeSlashCommand("rollback", "", ctx);
|
||||
|
||||
expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be rolled back.");
|
||||
});
|
||||
|
||||
it("rejects /rollback without an active thread", async () => {
|
||||
const ctx = context({ activeThreadId: null });
|
||||
|
||||
|
|
@ -601,18 +572,6 @@ describe("slash commands", () => {
|
|||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile reset to default for subsequent turns.");
|
||||
});
|
||||
|
||||
it("blocks permission profile changes in side chats while preserving permission status", async () => {
|
||||
const ctx = context({ activeThreadEphemeral: true });
|
||||
|
||||
await executeSlashCommand("permissions", ":workspace", ctx);
|
||||
await executeSlashCommand("permissions", "", ctx);
|
||||
|
||||
expect(ctx.runtimeSettings.requestPermissionProfile).not.toHaveBeenCalled();
|
||||
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission changes are unavailable in side chats.");
|
||||
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", ctx.permissionDetails());
|
||||
});
|
||||
|
||||
it.each(["reset", "clear", "off"])("treats permission profile alias-like value %s as a profile id", async (profile) => {
|
||||
const ctx = context();
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,103 @@ describe("executeSlashCommandWithState", () => {
|
|||
expect(host.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
|
||||
});
|
||||
|
||||
it("rejects a directly typed goal command in a side chat before it reaches the goal transport", async () => {
|
||||
const { host, stateStore } = createHost();
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: thread("side", "Side chat"),
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
});
|
||||
|
||||
await expect(executeSlashCommandWithState(host, "goal", "")).rejects.toThrow("Goals are unavailable in side chats.");
|
||||
await expect(executeSlashCommandWithState(host, "goal", "set Ship this")).rejects.toThrow("Goals are unavailable in side chats.");
|
||||
|
||||
expect(host.goals.setObjective).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows directly typed goal display in a persistent subagent panel", async () => {
|
||||
const { host, stateStore } = createHost();
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: {
|
||||
...thread("child", "Child"),
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
},
|
||||
},
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
});
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/goal-set",
|
||||
goal: {
|
||||
threadId: "child",
|
||||
objective: "Inspect",
|
||||
status: "active",
|
||||
tokenBudget: null,
|
||||
tokensUsed: 0,
|
||||
timeUsedSeconds: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(executeSlashCommandWithState(host, "goal", "")).resolves.toBeUndefined();
|
||||
|
||||
expect(host.addStructuredSystemMessage).toHaveBeenCalledWith("Thread goal", expect.any(Array));
|
||||
expect(host.goals.setObjective).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps directly typed compact available in a side chat", async () => {
|
||||
const { compactThread, host, stateStore } = createHost();
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: thread("side", "Side chat"),
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
});
|
||||
|
||||
await executeSlashCommandWithState(host, "compact", "");
|
||||
|
||||
expect(compactThread).toHaveBeenCalledWith("side");
|
||||
});
|
||||
|
||||
it("runs reconnect even when there is no current app-server client", async () => {
|
||||
const { host } = createHost({ connectionAvailable: () => false });
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,25 @@ function resumeSubagentThread(stateStore: ReturnType<typeof createChatStateStore
|
|||
});
|
||||
}
|
||||
|
||||
function resumeSideChat(stateStore: ReturnType<typeof createChatStateStore>) {
|
||||
stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: thread("side"),
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("TurnSubmissionActions", () => {
|
||||
it("aborts when restoration changes target while hydration is pending", async () => {
|
||||
const { host, startTurn, stateStore } = createHost();
|
||||
|
|
@ -147,6 +166,18 @@ describe("TurnSubmissionActions", () => {
|
|||
expect(host.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue.");
|
||||
});
|
||||
|
||||
it("starts a side-chat turn when no pending runtime setting needs transport", async () => {
|
||||
const { host, startTurn, stateStore } = createHost();
|
||||
resumeSideChat(stateStore);
|
||||
const actions = createTurnSubmissionActions(host);
|
||||
|
||||
await expect(actions.sendTurnText({ text: "hello" })).resolves.toBe(true);
|
||||
|
||||
expect(host.applyPendingThreadSettings).toHaveBeenCalledOnce();
|
||||
expect(startTurn).toHaveBeenCalledWith(expect.objectContaining({ threadId: "side", input: textInput("hello") }));
|
||||
expect(host.addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a thread when needed and acknowledges the optimistic turn", async () => {
|
||||
const { host, startTurn, stateStore } = createHost();
|
||||
const actions = createTurnSubmissionActions(host);
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ describe("ChatComposerController", () => {
|
|||
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fork");
|
||||
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).toContain("/fast");
|
||||
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fast");
|
||||
});
|
||||
|
||||
it("keeps a disconnected subagent composer read-only without slash suggestions", () => {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,56 @@ describe("chat panel surface projections", () => {
|
|||
expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true');
|
||||
});
|
||||
|
||||
it("keeps compact available but hides goal mutation behind the side-chat policy", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
activeThread: {
|
||||
id: "side",
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
},
|
||||
ui: { toolbarPanel: "chat-actions" },
|
||||
});
|
||||
const parent = renderWithShellModels(state, (models) =>
|
||||
h(ChatPanelToolbar, {
|
||||
model: models.toolbar,
|
||||
stateStore: createChatStateStore(state),
|
||||
surface: toolbarSurfaceFixture(),
|
||||
actions: toolbarActionsFixture(),
|
||||
}),
|
||||
);
|
||||
|
||||
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", false],
|
||||
["Set goal...", true],
|
||||
]);
|
||||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("keeps Set goal enabled while a restored thread still needs hydration", () => {
|
||||
const store = createChatStateStore(chatStateFixture());
|
||||
store.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" });
|
||||
store.dispatch({ type: "ui/panel-set", panel: "chat-actions" });
|
||||
const restored = store.getState();
|
||||
const parent = renderWithShellModels(restored, (models) =>
|
||||
h(ChatPanelToolbar, {
|
||||
model: models.toolbar,
|
||||
stateStore: store,
|
||||
surface: toolbarSurfaceFixture(),
|
||||
actions: toolbarActionsFixture(),
|
||||
}),
|
||||
);
|
||||
|
||||
const goalAction = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")].find(
|
||||
(item) => item.textContent === "Set goal...",
|
||||
);
|
||||
expect(goalAction?.classList.contains("is-disabled")).toBe(false);
|
||||
expect(selectChatPanelGoal(restored).goalMutationsAllowed).toBe(false);
|
||||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("builds toolbar rows from immutable chat state snapshots", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
|
||||
|
|
@ -448,6 +498,32 @@ describe("chat panel surface projections", () => {
|
|||
unmountUiRoot(resumeParent);
|
||||
});
|
||||
|
||||
it("renders persistent subagent goals without mutation controls", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
activeThread: {
|
||||
id: "child",
|
||||
goal: goalFixture("child"),
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
subagentKind: "thread-spawn",
|
||||
parentThreadId: "parent",
|
||||
sessionId: "session",
|
||||
depth: 1,
|
||||
agentNickname: "Scout",
|
||||
agentRole: "explorer",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface: goalSurfaceFixture() }));
|
||||
|
||||
expect(parent.querySelector('[aria-label="Edit goal"]')).toBeNull();
|
||||
expect(parent.querySelector('[aria-label="Pause goal"]')).toBeNull();
|
||||
expect(parent.querySelector('[aria-label="Clear goal"]')).toBeNull();
|
||||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("builds composer meta from one captured chat state", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { createChatState } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions";
|
||||
import { createToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions";
|
||||
import { createToolbarPanelActions, createToolbarUiActions } from "../../../../src/features/chat/panel/toolbar-actions";
|
||||
|
||||
describe("createToolbarPanelActions", () => {
|
||||
it("tracks archive confirmation and delegates archive actions", async () => {
|
||||
|
|
@ -41,4 +41,26 @@ describe("createToolbarPanelActions", () => {
|
|||
|
||||
expect(stateStore.getState().ui.toolbarPanel).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the Set goal action reachable while a restored thread needs hydration", () => {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" });
|
||||
const startEditingCurrent = vi.fn();
|
||||
const actions = createToolbarUiActions({
|
||||
connectionActions: {} as never,
|
||||
reconnectPanel: vi.fn(),
|
||||
threadActions: {} as never,
|
||||
goals: { startEditingCurrent } as never,
|
||||
toolbarPanel: {} as never,
|
||||
rename: {} as never,
|
||||
navigation: {} as never,
|
||||
canStartSideChat: () => false,
|
||||
canCompact: () => false,
|
||||
canMutateGoal: () => true,
|
||||
});
|
||||
|
||||
actions.chat.setGoal();
|
||||
|
||||
expect(startEditingCurrent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -404,7 +404,9 @@ describe("Toolbar decisions", () => {
|
|||
function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewModel {
|
||||
return {
|
||||
newChatDisabled: false,
|
||||
activeThreadChatActionsDisabled: false,
|
||||
sideChatStartDisabled: false,
|
||||
compactDisabled: false,
|
||||
goalMutationDisabled: false,
|
||||
chatActionsOpen: false,
|
||||
historyOpen: false,
|
||||
statusPanelOpen: false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue