mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Functionize chat use case services
This commit is contained in:
parent
02df0cc4b7
commit
09545939cc
30 changed files with 1363 additions and 1187 deletions
198
src/features/chat/app-server/diagnostics-actions.ts
Normal file
198
src/features/chat/app-server/diagnostics-actions.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import {
|
||||
capabilityProbeError,
|
||||
capabilityProbeOk,
|
||||
upsertMcpServerDiagnostic,
|
||||
type CapabilityProbeMethod,
|
||||
} from "../../../app-server/compatibility";
|
||||
import type { McpServerStatus } from "../../../generated/app-server/v2/McpServerStatus";
|
||||
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
|
||||
import { mcpStatusLines as buildMcpStatusLines } from "../mcp-status";
|
||||
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
|
||||
|
||||
interface RefreshCapabilityDiagnosticsOptions {
|
||||
cachedAppServerMetadata?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatAppServerDiagnosticsActionsHost extends ChatAppServerBaseHost {
|
||||
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
|
||||
appServerMetadataSnapshot: () => SharedAppServerMetadata;
|
||||
}
|
||||
|
||||
export interface ChatAppServerDiagnosticsActions {
|
||||
refreshCapabilityDiagnostics: (options?: RefreshCapabilityDiagnosticsOptions) => Promise<void>;
|
||||
refreshPublishedCapabilityDiagnostics: (options?: RefreshCapabilityDiagnosticsOptions) => Promise<void>;
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
recordMcpStartupStatus: (name: string, startupStatus: "starting" | "ready" | "failed" | "cancelled", message: string | null) => void;
|
||||
}
|
||||
|
||||
export function createChatAppServerDiagnosticsActions(host: ChatAppServerDiagnosticsActionsHost): ChatAppServerDiagnosticsActions {
|
||||
return {
|
||||
refreshCapabilityDiagnostics: (options) => refreshCapabilityDiagnostics(host, options),
|
||||
refreshPublishedCapabilityDiagnostics: (options) => refreshPublishedCapabilityDiagnostics(host, options),
|
||||
mcpStatusLines: () => mcpStatusLines(host),
|
||||
recordMcpStartupStatus: (name, startupStatus, message) => {
|
||||
recordMcpStartupStatus(host, name, startupStatus, message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshCapabilityDiagnostics(
|
||||
host: ChatAppServerDiagnosticsActionsHost,
|
||||
options: RefreshCapabilityDiagnosticsOptions = {},
|
||||
): Promise<void> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
const probes: Promise<void>[] = [];
|
||||
if (!options.cachedAppServerMetadata) {
|
||||
probes.push(
|
||||
probeCapability(
|
||||
host,
|
||||
"model/list",
|
||||
() => client.listModels(false),
|
||||
(response) => `${String(response.data.length)} models`,
|
||||
),
|
||||
probeCapability(
|
||||
host,
|
||||
"skills/list",
|
||||
() => client.listSkills(host.vaultPath),
|
||||
(response) => {
|
||||
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
|
||||
return `${String(count)} skills`;
|
||||
},
|
||||
),
|
||||
probeCapability(
|
||||
host,
|
||||
"account/rateLimits/read",
|
||||
() => client.readAccountRateLimits(),
|
||||
(response) => (response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
probes.push(
|
||||
probeCapability(
|
||||
host,
|
||||
"hooks/list",
|
||||
() => client.listHooks(host.vaultPath),
|
||||
(response) => {
|
||||
const count = response.data.reduce((total, entry) => total + entry.hooks.length, 0);
|
||||
return `${String(count)} hooks`;
|
||||
},
|
||||
),
|
||||
probeCapability(
|
||||
host,
|
||||
"mcpServerStatus/list",
|
||||
() => client.listMcpServerStatus(mcpServerStatusParams(host.stateStore.getState().activeThread.id)),
|
||||
(response) => {
|
||||
recordMcpServerStatus(host, response.data);
|
||||
const issueCount = response.data.filter((server) => server.authStatus === "notLoggedIn").length;
|
||||
return issueCount > 0
|
||||
? `${String(response.data.length)} servers, ${String(issueCount)} auth issues`
|
||||
: `${String(response.data.length)} servers`;
|
||||
},
|
||||
),
|
||||
probeCapability(
|
||||
host,
|
||||
"collaborationMode/list",
|
||||
() => client.listCollaborationModes(),
|
||||
(response) => `${String(response.data.length)} modes`,
|
||||
),
|
||||
probeCapability(
|
||||
host,
|
||||
"modelProvider/capabilities/read",
|
||||
() => client.readModelProviderCapabilities(),
|
||||
(response) =>
|
||||
[
|
||||
response.namespaceTools ? "namespace tools" : null,
|
||||
response.imageGeneration ? "image generation" : null,
|
||||
response.webSearch ? "web search" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "no optional capabilities",
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(probes);
|
||||
}
|
||||
|
||||
async function refreshPublishedCapabilityDiagnostics(
|
||||
host: ChatAppServerDiagnosticsActionsHost,
|
||||
options: RefreshCapabilityDiagnosticsOptions = {},
|
||||
): Promise<void> {
|
||||
await refreshCapabilityDiagnostics(host, options);
|
||||
host.publishAppServerMetadata(host.appServerMetadataSnapshot());
|
||||
}
|
||||
|
||||
async function mcpStatusLines(host: ChatAppServerDiagnosticsActionsHost): Promise<string[]> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return ["MCP servers", "Codex app-server is not connected."];
|
||||
|
||||
try {
|
||||
const state = host.stateStore.getState();
|
||||
const response = await client.listMcpServerStatus(mcpServerStatusParams(state.activeThread.id));
|
||||
return buildMcpStatusLines(response.data, state.connection.appServerDiagnostics.mcpServers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return ["MCP servers", `Could not load MCP servers: ${message}`];
|
||||
}
|
||||
}
|
||||
|
||||
function recordMcpStartupStatus(
|
||||
host: ChatAppServerDiagnosticsActionsHost,
|
||||
name: string,
|
||||
startupStatus: "starting" | "ready" | "failed" | "cancelled",
|
||||
message: string | null,
|
||||
): void {
|
||||
host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
appServerDiagnostics: upsertMcpServerDiagnostic(host.stateStore.getState().connection.appServerDiagnostics, {
|
||||
name,
|
||||
startupStatus,
|
||||
authStatus: null,
|
||||
toolCount: null,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function probeCapability<T>(
|
||||
host: ChatAppServerDiagnosticsActionsHost,
|
||||
method: CapabilityProbeMethod,
|
||||
request: () => Promise<T>,
|
||||
summarize: (response: T) => string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await request();
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes[method] = capabilityProbeOk(method, summarize(response));
|
||||
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
} catch (error) {
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes[method] = capabilityProbeError(method, error);
|
||||
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
}
|
||||
}
|
||||
|
||||
function recordMcpServerStatus(host: ChatAppServerDiagnosticsActionsHost, servers: McpServerStatus[]): void {
|
||||
let diagnostics = host.stateStore.getState().connection.appServerDiagnostics;
|
||||
for (const server of servers) {
|
||||
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
|
||||
name: server.name,
|
||||
startupStatus: "unknown",
|
||||
authStatus: server.authStatus,
|
||||
toolCount: Object.keys(server.tools).length,
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
}
|
||||
|
||||
function mcpServerStatusParams(threadId: string | null): Parameters<AppServerClient["listMcpServerStatus"]>[0] {
|
||||
return {
|
||||
detail: "toolsAndAuthOnly",
|
||||
limit: 100,
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import {
|
||||
capabilityProbeError,
|
||||
capabilityProbeOk,
|
||||
upsertMcpServerDiagnostic,
|
||||
type CapabilityProbeMethod,
|
||||
} from "../../../app-server/compatibility";
|
||||
import type { McpServerStatus } from "../../../generated/app-server/v2/McpServerStatus";
|
||||
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
|
||||
import { mcpStatusLines as buildMcpStatusLines } from "../mcp-status";
|
||||
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
|
||||
|
||||
export interface RefreshCapabilityDiagnosticsOptions {
|
||||
cachedAppServerMetadata?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatAppServerDiagnosticsControllerHost extends ChatAppServerBaseHost {
|
||||
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
|
||||
appServerMetadataSnapshot: () => SharedAppServerMetadata;
|
||||
}
|
||||
|
||||
export class ChatAppServerDiagnosticsController {
|
||||
constructor(private readonly host: ChatAppServerDiagnosticsControllerHost) {}
|
||||
|
||||
async refreshCapabilityDiagnostics(options: RefreshCapabilityDiagnosticsOptions = {}): Promise<void> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
const probes: Promise<void>[] = [];
|
||||
if (!options.cachedAppServerMetadata) {
|
||||
probes.push(
|
||||
this.probeCapability(
|
||||
"model/list",
|
||||
() => client.listModels(false),
|
||||
(response) => `${String(response.data.length)} models`,
|
||||
),
|
||||
this.probeCapability(
|
||||
"skills/list",
|
||||
() => client.listSkills(this.host.vaultPath),
|
||||
(response) => {
|
||||
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
|
||||
return `${String(count)} skills`;
|
||||
},
|
||||
),
|
||||
this.probeCapability(
|
||||
"account/rateLimits/read",
|
||||
() => client.readAccountRateLimits(),
|
||||
(response) => (response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
probes.push(
|
||||
this.probeCapability(
|
||||
"hooks/list",
|
||||
() => client.listHooks(this.host.vaultPath),
|
||||
(response) => {
|
||||
const count = response.data.reduce((total, entry) => total + entry.hooks.length, 0);
|
||||
return `${String(count)} hooks`;
|
||||
},
|
||||
),
|
||||
this.probeCapability(
|
||||
"mcpServerStatus/list",
|
||||
() => client.listMcpServerStatus(mcpServerStatusParams(this.host.stateStore.getState().activeThread.id)),
|
||||
(response) => {
|
||||
this.recordMcpServerStatus(response.data);
|
||||
const issueCount = response.data.filter((server) => server.authStatus === "notLoggedIn").length;
|
||||
return issueCount > 0
|
||||
? `${String(response.data.length)} servers, ${String(issueCount)} auth issues`
|
||||
: `${String(response.data.length)} servers`;
|
||||
},
|
||||
),
|
||||
this.probeCapability(
|
||||
"collaborationMode/list",
|
||||
() => client.listCollaborationModes(),
|
||||
(response) => `${String(response.data.length)} modes`,
|
||||
),
|
||||
this.probeCapability(
|
||||
"modelProvider/capabilities/read",
|
||||
() => client.readModelProviderCapabilities(),
|
||||
(response) =>
|
||||
[
|
||||
response.namespaceTools ? "namespace tools" : null,
|
||||
response.imageGeneration ? "image generation" : null,
|
||||
response.webSearch ? "web search" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "no optional capabilities",
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(probes);
|
||||
}
|
||||
|
||||
async refreshPublishedCapabilityDiagnostics(options: RefreshCapabilityDiagnosticsOptions = {}): Promise<void> {
|
||||
await this.refreshCapabilityDiagnostics(options);
|
||||
this.host.publishAppServerMetadata(this.host.appServerMetadataSnapshot());
|
||||
}
|
||||
|
||||
async mcpStatusLines(): Promise<string[]> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return ["MCP servers", "Codex app-server is not connected."];
|
||||
|
||||
try {
|
||||
const state = this.host.stateStore.getState();
|
||||
const response = await client.listMcpServerStatus(mcpServerStatusParams(state.activeThread.id));
|
||||
return buildMcpStatusLines(response.data, state.connection.appServerDiagnostics.mcpServers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return ["MCP servers", `Could not load MCP servers: ${message}`];
|
||||
}
|
||||
}
|
||||
|
||||
recordMcpStartupStatus(name: string, startupStatus: "starting" | "ready" | "failed" | "cancelled", message: string | null): void {
|
||||
this.host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
appServerDiagnostics: upsertMcpServerDiagnostic(this.host.stateStore.getState().connection.appServerDiagnostics, {
|
||||
name,
|
||||
startupStatus,
|
||||
authStatus: null,
|
||||
toolCount: null,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async probeCapability<T>(
|
||||
method: CapabilityProbeMethod,
|
||||
request: () => Promise<T>,
|
||||
summarize: (response: T) => string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await request();
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes[method] = capabilityProbeOk(method, summarize(response));
|
||||
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
} catch (error) {
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes[method] = capabilityProbeError(method, error);
|
||||
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
}
|
||||
}
|
||||
|
||||
private recordMcpServerStatus(servers: McpServerStatus[]): void {
|
||||
let diagnostics = this.host.stateStore.getState().connection.appServerDiagnostics;
|
||||
for (const server of servers) {
|
||||
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
|
||||
name: server.name,
|
||||
startupStatus: "unknown",
|
||||
authStatus: server.authStatus,
|
||||
toolCount: Object.keys(server.tools).length,
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
}
|
||||
}
|
||||
|
||||
function mcpServerStatusParams(threadId: string | null): Parameters<AppServerClient["listMcpServerStatus"]>[0] {
|
||||
return {
|
||||
detail: "toolsAndAuthOnly",
|
||||
limit: 100,
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
215
src/features/chat/app-server/metadata-actions.ts
Normal file
215
src/features/chat/app-server/metadata-actions.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { type AppServerDiagnostics, capabilityProbeError, capabilityProbeOk } from "../../../app-server/compatibility";
|
||||
import type { Model } from "../../../generated/app-server/v2/Model";
|
||||
import type { RateLimitSnapshot } from "../../../generated/app-server/v2/RateLimitSnapshot";
|
||||
import type { SkillMetadata } from "../../../generated/app-server/v2/SkillMetadata";
|
||||
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
|
||||
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
|
||||
|
||||
export interface ChatAppServerMetadataActionsHost extends ChatAppServerBaseHost {
|
||||
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
|
||||
}
|
||||
|
||||
export interface ChatAppServerMetadataActions {
|
||||
appServerMetadataSnapshot: () => SharedAppServerMetadata;
|
||||
applyAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
|
||||
loadAppServerMetadata: () => Promise<SharedAppServerMetadata | null>;
|
||||
refreshAppServerMetadata: () => Promise<SharedAppServerMetadata | null>;
|
||||
refreshPublishedAppServerMetadata: () => Promise<SharedAppServerMetadata | null>;
|
||||
publishAppServerMetadataSnapshot: () => void;
|
||||
refreshModels: () => Promise<void>;
|
||||
loadModels: () => Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }>;
|
||||
refreshSkills: (forceReload?: boolean) => Promise<void>;
|
||||
refreshPublishedSkills: (forceReload?: boolean) => Promise<void>;
|
||||
loadSkills: (forceReload?: boolean) => Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }>;
|
||||
refreshRateLimits: () => Promise<void>;
|
||||
refreshPublishedRateLimits: () => Promise<void>;
|
||||
loadRateLimit: () => Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }>;
|
||||
}
|
||||
|
||||
export function createChatAppServerMetadataActions(host: ChatAppServerMetadataActionsHost): ChatAppServerMetadataActions {
|
||||
return {
|
||||
appServerMetadataSnapshot: () => appServerMetadataSnapshot(host),
|
||||
applyAppServerMetadata: (metadata) => {
|
||||
applyAppServerMetadata(host, metadata);
|
||||
},
|
||||
loadAppServerMetadata: () => loadAppServerMetadata(host),
|
||||
refreshAppServerMetadata: () => refreshAppServerMetadata(host),
|
||||
refreshPublishedAppServerMetadata: () => refreshPublishedAppServerMetadata(host),
|
||||
publishAppServerMetadataSnapshot: () => {
|
||||
publishAppServerMetadataSnapshot(host);
|
||||
},
|
||||
refreshModels: () => refreshModels(host),
|
||||
loadModels: () => loadModels(host),
|
||||
refreshSkills: (forceReload) => refreshSkills(host, forceReload),
|
||||
refreshPublishedSkills: (forceReload) => refreshPublishedSkills(host, forceReload),
|
||||
loadSkills: (forceReload) => loadSkills(host, forceReload),
|
||||
refreshRateLimits: () => refreshRateLimits(host),
|
||||
refreshPublishedRateLimits: () => refreshPublishedRateLimits(host),
|
||||
loadRateLimit: () => loadRateLimit(host),
|
||||
};
|
||||
}
|
||||
|
||||
function appServerMetadataSnapshot(host: ChatAppServerMetadataActionsHost): SharedAppServerMetadata {
|
||||
const state = host.stateStore.getState();
|
||||
return {
|
||||
effectiveConfig: state.connection.effectiveConfig,
|
||||
availableModels: state.connection.availableModels,
|
||||
availableSkills: state.connection.availableSkills,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
appServerDiagnostics: state.connection.appServerDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAppServerMetadata(host: ChatAppServerMetadataActionsHost, metadata: SharedAppServerMetadata): void {
|
||||
host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
effectiveConfig: metadata.effectiveConfig,
|
||||
availableModels: metadata.availableModels,
|
||||
availableSkills: metadata.availableSkills,
|
||||
rateLimit: metadata.rateLimit,
|
||||
appServerDiagnostics: metadata.appServerDiagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAppServerMetadata(host: ChatAppServerMetadataActionsHost): Promise<SharedAppServerMetadata | null> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return null;
|
||||
const effectiveConfig = await client.readEffectiveConfig(host.vaultPath);
|
||||
const [models, skills, rateLimit] = await Promise.all([loadModels(host), loadSkills(host), loadRateLimit(host)]);
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["model/list"] = models.probe;
|
||||
diagnostics.probes["skills/list"] = skills.probe;
|
||||
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
|
||||
return {
|
||||
effectiveConfig,
|
||||
availableModels: models.data,
|
||||
availableSkills: skills.data,
|
||||
rateLimit: rateLimit.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAppServerMetadata(host: ChatAppServerMetadataActionsHost): Promise<SharedAppServerMetadata | null> {
|
||||
const metadata = await loadAppServerMetadata(host);
|
||||
if (metadata) applyAppServerMetadata(host, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function refreshPublishedAppServerMetadata(host: ChatAppServerMetadataActionsHost): Promise<SharedAppServerMetadata | null> {
|
||||
const metadata = await refreshAppServerMetadata(host);
|
||||
if (metadata) host.publishAppServerMetadata(metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function publishAppServerMetadataSnapshot(host: ChatAppServerMetadataActionsHost): void {
|
||||
host.publishAppServerMetadata(appServerMetadataSnapshot(host));
|
||||
}
|
||||
|
||||
async function refreshModels(host: ChatAppServerMetadataActionsHost): Promise<void> {
|
||||
const models = await loadModels(host);
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["model/list"] = models.probe;
|
||||
host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
availableModels: models.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadModels(
|
||||
host: ChatAppServerMetadataActionsHost,
|
||||
): Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return { data: [], probe: capabilityProbeError("model/list", new Error("Codex app-server is not connected.")) };
|
||||
try {
|
||||
const response = await client.listModels(false);
|
||||
return { data: response.data, probe: capabilityProbeOk("model/list", `${String(response.data.length)} models`) };
|
||||
} catch (error) {
|
||||
return { data: [], probe: capabilityProbeError("model/list", error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSkills(host: ChatAppServerMetadataActionsHost, forceReload = false): Promise<void> {
|
||||
const skills = await loadSkills(host, forceReload);
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["skills/list"] = skills.probe;
|
||||
host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
availableSkills: skills.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshPublishedSkills(host: ChatAppServerMetadataActionsHost, forceReload = false): Promise<void> {
|
||||
await refreshSkills(host, forceReload);
|
||||
publishAppServerMetadataSnapshot(host);
|
||||
}
|
||||
|
||||
async function loadSkills(
|
||||
host: ChatAppServerMetadataActionsHost,
|
||||
forceReload = false,
|
||||
): Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return { data: [], probe: capabilityProbeError("skills/list", new Error("Codex app-server is not connected.")) };
|
||||
try {
|
||||
const response = await client.listSkills(host.vaultPath, forceReload);
|
||||
const data = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled);
|
||||
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
|
||||
return { data, probe: capabilityProbeOk("skills/list", `${String(count)} skills`) };
|
||||
} catch (error) {
|
||||
return { data: [], probe: capabilityProbeError("skills/list", error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRateLimits(host: ChatAppServerMetadataActionsHost): Promise<void> {
|
||||
const rateLimit = await loadRateLimit(host);
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
|
||||
host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
rateLimit: rateLimit.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshPublishedRateLimits(host: ChatAppServerMetadataActionsHost): Promise<void> {
|
||||
const rateLimit = await loadRateLimit(host);
|
||||
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
|
||||
if (rateLimit.probe.status === "ok") {
|
||||
host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
rateLimit: rateLimit.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
publishAppServerMetadataSnapshot(host);
|
||||
return;
|
||||
}
|
||||
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
}
|
||||
|
||||
async function loadRateLimit(
|
||||
host: ChatAppServerMetadataActionsHost,
|
||||
): Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }> {
|
||||
const client = host.currentClient();
|
||||
if (!client) {
|
||||
return {
|
||||
data: null,
|
||||
probe: capabilityProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await client.readAccountRateLimits();
|
||||
const rateLimitsByLimitId = response.rateLimitsByLimitId;
|
||||
const codexRateLimit = rateLimitsByLimitId && Object.hasOwn(rateLimitsByLimitId, "codex") ? rateLimitsByLimitId["codex"] : undefined;
|
||||
return {
|
||||
data: codexRateLimit ?? response.rateLimits,
|
||||
probe: capabilityProbeOk(
|
||||
"account/rateLimits/read",
|
||||
response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available",
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return { data: null, probe: capabilityProbeError("account/rateLimits/read", error) };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import { type AppServerDiagnostics, capabilityProbeError, capabilityProbeOk } from "../../../app-server/compatibility";
|
||||
import type { Model } from "../../../generated/app-server/v2/Model";
|
||||
import type { RateLimitSnapshot } from "../../../generated/app-server/v2/RateLimitSnapshot";
|
||||
import type { SkillMetadata } from "../../../generated/app-server/v2/SkillMetadata";
|
||||
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
|
||||
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
|
||||
|
||||
export interface ChatAppServerMetadataControllerHost extends ChatAppServerBaseHost {
|
||||
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
|
||||
}
|
||||
|
||||
export class ChatAppServerMetadataController {
|
||||
constructor(private readonly host: ChatAppServerMetadataControllerHost) {}
|
||||
|
||||
appServerMetadataSnapshot(): SharedAppServerMetadata {
|
||||
const state = this.host.stateStore.getState();
|
||||
return {
|
||||
effectiveConfig: state.connection.effectiveConfig,
|
||||
availableModels: state.connection.availableModels,
|
||||
availableSkills: state.connection.availableSkills,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
appServerDiagnostics: state.connection.appServerDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
applyAppServerMetadata(metadata: SharedAppServerMetadata): void {
|
||||
this.host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
effectiveConfig: metadata.effectiveConfig,
|
||||
availableModels: metadata.availableModels,
|
||||
availableSkills: metadata.availableSkills,
|
||||
rateLimit: metadata.rateLimit,
|
||||
appServerDiagnostics: metadata.appServerDiagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async loadAppServerMetadata(): Promise<SharedAppServerMetadata | null> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return null;
|
||||
const effectiveConfig = await client.readEffectiveConfig(this.host.vaultPath);
|
||||
const [models, skills, rateLimit] = await Promise.all([this.loadModels(), this.loadSkills(), this.loadRateLimit()]);
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["model/list"] = models.probe;
|
||||
diagnostics.probes["skills/list"] = skills.probe;
|
||||
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
|
||||
return {
|
||||
effectiveConfig,
|
||||
availableModels: models.data,
|
||||
availableSkills: skills.data,
|
||||
rateLimit: rateLimit.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAppServerMetadata(): Promise<SharedAppServerMetadata | null> {
|
||||
const metadata = await this.loadAppServerMetadata();
|
||||
if (metadata) this.applyAppServerMetadata(metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async refreshPublishedAppServerMetadata(): Promise<SharedAppServerMetadata | null> {
|
||||
const metadata = await this.refreshAppServerMetadata();
|
||||
if (metadata) this.host.publishAppServerMetadata(metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
publishAppServerMetadataSnapshot(): void {
|
||||
this.host.publishAppServerMetadata(this.appServerMetadataSnapshot());
|
||||
}
|
||||
|
||||
async refreshModels(): Promise<void> {
|
||||
const models = await this.loadModels();
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["model/list"] = models.probe;
|
||||
this.host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
availableModels: models.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async loadModels(): Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return { data: [], probe: capabilityProbeError("model/list", new Error("Codex app-server is not connected.")) };
|
||||
try {
|
||||
const response = await client.listModels(false);
|
||||
return { data: response.data, probe: capabilityProbeOk("model/list", `${String(response.data.length)} models`) };
|
||||
} catch (error) {
|
||||
return { data: [], probe: capabilityProbeError("model/list", error) };
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSkills(forceReload = false): Promise<void> {
|
||||
const skills = await this.loadSkills(forceReload);
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["skills/list"] = skills.probe;
|
||||
this.host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
availableSkills: skills.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async refreshPublishedSkills(forceReload = false): Promise<void> {
|
||||
await this.refreshSkills(forceReload);
|
||||
this.publishAppServerMetadataSnapshot();
|
||||
}
|
||||
|
||||
async loadSkills(forceReload = false): Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return { data: [], probe: capabilityProbeError("skills/list", new Error("Codex app-server is not connected.")) };
|
||||
try {
|
||||
const response = await client.listSkills(this.host.vaultPath, forceReload);
|
||||
const data = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled);
|
||||
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
|
||||
return { data, probe: capabilityProbeOk("skills/list", `${String(count)} skills`) };
|
||||
} catch (error) {
|
||||
return { data: [], probe: capabilityProbeError("skills/list", error) };
|
||||
}
|
||||
}
|
||||
|
||||
async refreshRateLimits(): Promise<void> {
|
||||
const rateLimit = await this.loadRateLimit();
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
|
||||
this.host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
rateLimit: rateLimit.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
async refreshPublishedRateLimits(): Promise<void> {
|
||||
const rateLimit = await this.loadRateLimit();
|
||||
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
|
||||
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
|
||||
if (rateLimit.probe.status === "ok") {
|
||||
this.host.stateStore.dispatch({
|
||||
type: "connection/metadata-applied",
|
||||
rateLimit: rateLimit.data,
|
||||
appServerDiagnostics: diagnostics,
|
||||
});
|
||||
this.publishAppServerMetadataSnapshot();
|
||||
return;
|
||||
}
|
||||
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
|
||||
}
|
||||
|
||||
async loadRateLimit(): Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) {
|
||||
return {
|
||||
data: null,
|
||||
probe: capabilityProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await client.readAccountRateLimits();
|
||||
const rateLimitsByLimitId = response.rateLimitsByLimitId;
|
||||
const codexRateLimit = rateLimitsByLimitId && Object.hasOwn(rateLimitsByLimitId, "codex") ? rateLimitsByLimitId["codex"] : undefined;
|
||||
return {
|
||||
data: codexRateLimit ?? response.rateLimits,
|
||||
probe: capabilityProbeOk(
|
||||
"account/rateLimits/read",
|
||||
response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available",
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return { data: null, probe: capabilityProbeError("account/rateLimits/read", error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/features/chat/app-server/thread-actions.ts
Normal file
62
src/features/chat/app-server/thread-actions.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { upsertThread } from "../../../domain/threads/model";
|
||||
import type { Thread } from "../../../generated/app-server/v2/Thread";
|
||||
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../../../runtime/state";
|
||||
import { resumedThreadAction } from "../thread-resume";
|
||||
import type { ChatAppServerBaseHost } from "./shared";
|
||||
|
||||
export interface ChatAppServerThreadActionsHost extends ChatAppServerBaseHost {
|
||||
runtimeSnapshot: () => RuntimeSnapshot;
|
||||
forceMessagesToBottom: () => void;
|
||||
publishThreadList: (threads: readonly Thread[]) => void;
|
||||
syncThreadGoal: (threadId: string) => void;
|
||||
}
|
||||
|
||||
export interface ChatAppServerThreadActions {
|
||||
applyThreadList: (threads: readonly Thread[]) => void;
|
||||
loadThreadList: () => Promise<readonly Thread[]>;
|
||||
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null>;
|
||||
}
|
||||
|
||||
export function createChatAppServerThreadActions(host: ChatAppServerThreadActionsHost): ChatAppServerThreadActions {
|
||||
return {
|
||||
applyThreadList: (threads) => {
|
||||
applyThreadList(host, threads);
|
||||
},
|
||||
loadThreadList: () => loadThreadList(host),
|
||||
startThread: (preview, options) => startThread(host, preview, options),
|
||||
};
|
||||
}
|
||||
|
||||
function applyThreadList(host: ChatAppServerThreadActionsHost, threads: readonly Thread[]): void {
|
||||
host.stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true });
|
||||
}
|
||||
|
||||
async function loadThreadList(host: ChatAppServerThreadActionsHost): Promise<readonly Thread[]> {
|
||||
const client = host.currentClient();
|
||||
if (!client) throw new Error("Codex app-server is not connected.");
|
||||
const response = await client.listThreads(host.vaultPath);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function startThread(
|
||||
host: ChatAppServerThreadActionsHost,
|
||||
preview?: string,
|
||||
options: { syncGoal?: boolean } = {},
|
||||
): Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return null;
|
||||
const serviceTier = requestedOrConfiguredServiceTier(host.runtimeSnapshot());
|
||||
const response = await client.startThread(host.vaultPath, serviceTier);
|
||||
const state = host.stateStore.getState();
|
||||
const fallbackPreview = preview?.trim();
|
||||
const thread =
|
||||
response.thread.preview.trim().length > 0 || !fallbackPreview ? response.thread : { ...response.thread, preview: fallbackPreview };
|
||||
const listedThreads = upsertThread(state.threadList.listedThreads, thread);
|
||||
const resumedResponse = thread === response.thread ? response : { ...response, thread };
|
||||
host.stateStore.dispatch(resumedThreadAction({ response: resumedResponse, listedThreads, forceMessagesToBottom: true }));
|
||||
host.publishThreadList(listedThreads);
|
||||
host.forceMessagesToBottom();
|
||||
if (options.syncGoal ?? true) host.syncThreadGoal(response.thread.id);
|
||||
return response;
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { upsertThread } from "../../../domain/threads/model";
|
||||
import type { Thread } from "../../../generated/app-server/v2/Thread";
|
||||
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../../../runtime/state";
|
||||
import { resumedThreadAction } from "../thread-resume";
|
||||
import type { ChatAppServerBaseHost } from "./shared";
|
||||
|
||||
export interface ChatAppServerThreadControllerHost extends ChatAppServerBaseHost {
|
||||
runtimeSnapshot: () => RuntimeSnapshot;
|
||||
forceMessagesToBottom: () => void;
|
||||
publishThreadList: (threads: readonly Thread[]) => void;
|
||||
syncThreadGoal: (threadId: string) => void;
|
||||
}
|
||||
|
||||
export class ChatAppServerThreadController {
|
||||
constructor(private readonly host: ChatAppServerThreadControllerHost) {}
|
||||
|
||||
applyThreadList(threads: readonly Thread[]): void {
|
||||
this.host.stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true });
|
||||
}
|
||||
|
||||
async loadThreadList(): Promise<readonly Thread[]> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) throw new Error("Codex app-server is not connected.");
|
||||
const response = await client.listThreads(this.host.vaultPath);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async startThread(
|
||||
preview?: string,
|
||||
options: { syncGoal?: boolean } = {},
|
||||
): Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return null;
|
||||
const serviceTier = requestedOrConfiguredServiceTier(this.host.runtimeSnapshot());
|
||||
const response = await client.startThread(this.host.vaultPath, serviceTier);
|
||||
const state = this.host.stateStore.getState();
|
||||
const fallbackPreview = preview?.trim();
|
||||
const thread =
|
||||
response.thread.preview.trim().length > 0 || !fallbackPreview ? response.thread : { ...response.thread, preview: fallbackPreview };
|
||||
const listedThreads = upsertThread(state.threadList.listedThreads, thread);
|
||||
const resumedResponse = thread === response.thread ? response : { ...response, thread };
|
||||
this.host.stateStore.dispatch(resumedThreadAction({ response: resumedResponse, listedThreads, forceMessagesToBottom: true }));
|
||||
this.host.publishThreadList(listedThreads);
|
||||
this.host.forceMessagesToBottom();
|
||||
if (options.syncGoal ?? true) this.host.syncThreadGoal(response.thread.id);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import type { ChatAppServerMetadataController } from "../app-server/metadata-controller";
|
||||
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
|
||||
import type { ChatAppServerMetadataActions } from "../app-server/metadata-actions";
|
||||
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
|
||||
import type { ChatPanelContext } from "./context";
|
||||
|
||||
export function applyCachedSharedAppServerState(
|
||||
context: ChatPanelContext,
|
||||
appServerThreads: ChatAppServerThreadController,
|
||||
appServerMetadata: ChatAppServerMetadataController,
|
||||
appServerThreads: ChatAppServerThreadActions,
|
||||
appServerMetadata: ChatAppServerMetadataActions,
|
||||
): void {
|
||||
const threads = context.plugin.cachedThreadList();
|
||||
if (threads) appServerThreads.applyThreadList(threads);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { ConnectionManager } from "../../../app-server/connection-manager";
|
||||
import type { ChatAppServerDiagnosticsController } from "../app-server/diagnostics-controller";
|
||||
import type { ChatAppServerMetadataController } from "../app-server/metadata-controller";
|
||||
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
|
||||
import type { ChatAppServerDiagnosticsActions } from "../app-server/diagnostics-actions";
|
||||
import type { ChatAppServerMetadataActions } from "../app-server/metadata-actions";
|
||||
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
|
||||
import type { ChatComposerController } from "../composer/controller";
|
||||
import type { ChatInboundController } from "../inbound/controller";
|
||||
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
|
||||
import type { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
|
||||
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
|
||||
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
|
||||
import type { ChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
|
||||
import type { ChatThreadActions } from "../threads/thread-actions";
|
||||
import type { ThreadHistoryController } from "../threads/thread-history-controller";
|
||||
import type { ThreadRenameController } from "../threads/thread-rename-controller";
|
||||
import type { ToolbarPanelController } from "./toolbar-controller";
|
||||
|
|
@ -15,7 +15,7 @@ import type { ChatConnectionController } from "../session/connection-controller"
|
|||
import type { ChatReconnectActions } from "../session/reconnect-actions";
|
||||
import type { PendingRequestController } from "../requests/pending-request-controller";
|
||||
import { createServerRequestActions } from "../requests/server-request-actions";
|
||||
import type { ComposerSubmissionController } from "../turns/composer-submission-controller";
|
||||
import type { ComposerSubmissionActions } from "../turns/composer-submission-actions";
|
||||
import type { RestoredThreadController } from "../threads/restored-thread-controller";
|
||||
import type { ThreadIdentityActions } from "../threads/thread-identity-actions";
|
||||
import type { ThreadResumeController } from "../threads/thread-resume-controller";
|
||||
|
|
@ -41,22 +41,22 @@ export interface ChatViewControllers {
|
|||
controller: ChatInboundController;
|
||||
};
|
||||
appServer: {
|
||||
threads: ChatAppServerThreadController;
|
||||
metadata: ChatAppServerMetadataController;
|
||||
diagnostics: ChatAppServerDiagnosticsController;
|
||||
threads: ChatAppServerThreadActions;
|
||||
metadata: ChatAppServerMetadataActions;
|
||||
diagnostics: ChatAppServerDiagnosticsActions;
|
||||
};
|
||||
thread: {
|
||||
history: ThreadHistoryController;
|
||||
resume: ThreadResumeController;
|
||||
actions: ChatThreadActionController;
|
||||
actions: ChatThreadActions;
|
||||
restored: RestoredThreadController;
|
||||
identity: ThreadIdentityActions;
|
||||
rename: ThreadRenameController;
|
||||
selection: ThreadSelectionActions;
|
||||
};
|
||||
runtime: {
|
||||
settings: ChatRuntimeSettingsController;
|
||||
goals: ChatThreadGoalController;
|
||||
settings: ChatRuntimeSettingsActions;
|
||||
goals: ChatThreadGoalActions;
|
||||
};
|
||||
requests: {
|
||||
pending: PendingRequestController;
|
||||
|
|
@ -66,7 +66,7 @@ export interface ChatViewControllers {
|
|||
};
|
||||
composer: {
|
||||
controller: ChatComposerController;
|
||||
submission: ComposerSubmissionController;
|
||||
submission: ComposerSubmissionActions;
|
||||
};
|
||||
render: {
|
||||
controller: ChatViewRenderController;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
|
||||
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
|
||||
import type { ChatThreadActions } from "../threads/thread-actions";
|
||||
import type { ChatViewRenderScheduleOptions } from "./lifecycle";
|
||||
|
||||
export interface ToolbarPanelControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
threadActions: ChatThreadActionController;
|
||||
threadActions: ChatThreadActions;
|
||||
scheduleRender: (options?: ChatViewRenderScheduleOptions) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ConnectionManager } from "../../../app-server/connection-manager";
|
||||
import type { ChatAppServerMetadataController } from "../app-server/metadata-controller";
|
||||
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
|
||||
import type { ChatAppServerMetadataActions } from "../app-server/metadata-actions";
|
||||
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
|
||||
import type { ChatComposerController } from "../composer/controller";
|
||||
import { createAppServerWarmupActions } from "../session/app-server-warmup-controller";
|
||||
import { createChatViewOpenCloseActions } from "./open-close-actions";
|
||||
|
|
@ -45,8 +45,8 @@ export function createConnectionLifecycleControllerGroup(
|
|||
connection: ConnectionManager;
|
||||
composerController: ChatComposerController;
|
||||
messageRenderer: ChatMessageRenderer;
|
||||
appServerThreads: ChatAppServerThreadController;
|
||||
appServerMetadata: ChatAppServerMetadataController;
|
||||
appServerThreads: ChatAppServerThreadActions;
|
||||
appServerMetadata: ChatAppServerMetadataActions;
|
||||
},
|
||||
) {
|
||||
const { obsidian, lifecycle, render, liveState, scroll, client } = context;
|
||||
|
|
|
|||
172
src/features/chat/runtime/runtime-settings-actions.ts
Normal file
172
src/features/chat/runtime/runtime-settings-actions.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { requestedServiceTierRequestValue, type RequestedServiceTier } from "../../../app-server/service-tier";
|
||||
import type { ModeKind } from "../../../generated/app-server/ModeKind";
|
||||
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
|
||||
import type { ApprovalsReviewer } from "../../../generated/app-server/v2/ApprovalsReviewer";
|
||||
import type { ThreadSettingsUpdateParams } from "../../../generated/app-server/v2/ThreadSettingsUpdateParams";
|
||||
import { collaborationModeToggleMessage, nextCollaborationMode } from "../../../runtime/collaboration-mode";
|
||||
import { readRuntimeConfig } from "../../../runtime/config";
|
||||
import {
|
||||
autoReviewActive,
|
||||
fastModeActive,
|
||||
fastServiceTierRequestValue,
|
||||
pendingRuntimeSettingPayload,
|
||||
requestedTurnRuntimeSettings,
|
||||
type RuntimeSnapshot,
|
||||
} from "../../../runtime/state";
|
||||
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../../runtime/settings";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
|
||||
|
||||
type ThreadSettingsUpdate = Omit<ThreadSettingsUpdateParams, "threadId">;
|
||||
|
||||
export interface RuntimeSettingsActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
runtimeSnapshot: () => RuntimeSnapshot;
|
||||
collaborationModeLabel: () => string;
|
||||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface ChatRuntimeSettingsActions {
|
||||
applyPendingThreadSettings: () => Promise<boolean>;
|
||||
setRequestedModel: (model: string | null) => Promise<boolean>;
|
||||
setRequestedModelFromUi: (model: string | null) => Promise<void>;
|
||||
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => Promise<boolean>;
|
||||
setRequestedReasoningEffortFromUi: (effort: ReasoningEffort | null) => Promise<void>;
|
||||
toggleFastMode: () => Promise<void>;
|
||||
toggleCollaborationMode: () => Promise<void>;
|
||||
setCollaborationMode: (collaborationMode: ModeKind) => Promise<boolean>;
|
||||
toggleAutoReview: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHost): ChatRuntimeSettingsActions {
|
||||
return {
|
||||
applyPendingThreadSettings: () => applyPendingThreadSettings(host),
|
||||
setRequestedModel: (model) => setRequestedModel(host, model),
|
||||
setRequestedModelFromUi: (model) => setRequestedModelFromUi(host, model),
|
||||
setRequestedReasoningEffort: (effort) => setRequestedReasoningEffort(host, effort),
|
||||
setRequestedReasoningEffortFromUi: (effort) => setRequestedReasoningEffortFromUi(host, effort),
|
||||
toggleFastMode: () => toggleFastMode(host),
|
||||
toggleCollaborationMode: () => toggleCollaborationMode(host),
|
||||
setCollaborationMode: (collaborationMode) => setCollaborationMode(host, collaborationMode),
|
||||
toggleAutoReview: () => toggleAutoReview(host),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
const client = host.currentClient();
|
||||
const threadId = state(host).activeThread.id;
|
||||
if (!client || !threadId) return true;
|
||||
|
||||
const update = pendingThreadSettingsUpdate(host);
|
||||
if (Object.keys(update).length === 0) return true;
|
||||
|
||||
try {
|
||||
await client.updateThreadSettings(threadId, update);
|
||||
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
|
||||
return true;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setRequestedModel(host: RuntimeSettingsActionsHost, model: string | null): Promise<boolean> {
|
||||
dispatch(host, { type: "runtime/requested-model-set", model });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function setRequestedModelFromUi(host: RuntimeSettingsActionsHost, model: string | null): Promise<void> {
|
||||
if (!(await setRequestedModel(host, model))) return;
|
||||
dispatch(host, { type: "ui/panel-set", panel: null });
|
||||
host.addSystemMessage(modelOverrideMessage(model));
|
||||
}
|
||||
|
||||
async function setRequestedReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort | null): Promise<boolean> {
|
||||
dispatch(host, { type: "runtime/requested-effort-set", effort });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function setRequestedReasoningEffortFromUi(host: RuntimeSettingsActionsHost, effort: ReasoningEffort | null): Promise<void> {
|
||||
if (!(await setRequestedReasoningEffort(host, effort))) return;
|
||||
dispatch(host, { type: "ui/panel-set", panel: null });
|
||||
host.addSystemMessage(reasoningEffortOverrideMessage(effort));
|
||||
}
|
||||
|
||||
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
||||
const snapshot = host.runtimeSnapshot();
|
||||
const config = readRuntimeConfig(state(host).connection.effectiveConfig);
|
||||
const next: RequestedServiceTier = fastModeActive(snapshot, config) ? "off" : "fast";
|
||||
dispatch(host, { type: "runtime/requested-service-tier-set", serviceTier: next });
|
||||
dispatch(host, { type: "ui/panel-set", panel: null });
|
||||
if (!(await applyPendingThreadSettings(host))) return;
|
||||
host.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
|
||||
}
|
||||
|
||||
async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
||||
const next = nextCollaborationMode(state(host).runtime.selectedCollaborationMode);
|
||||
await setCollaborationMode(host, next);
|
||||
}
|
||||
|
||||
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: ModeKind): Promise<boolean> {
|
||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
|
||||
dispatch(host, { type: "ui/panel-set", panel: null });
|
||||
const applied = await applyPendingThreadSettings(host);
|
||||
if (applied) host.addSystemMessage(collaborationModeToggleMessage(collaborationMode));
|
||||
return applied;
|
||||
}
|
||||
|
||||
async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void> {
|
||||
const next: ApprovalsReviewer = autoReviewActive(host.runtimeSnapshot(), readRuntimeConfig(state(host).connection.effectiveConfig))
|
||||
? "user"
|
||||
: "auto_review";
|
||||
dispatch(host, { type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next });
|
||||
dispatch(host, { type: "ui/panel-set", panel: null });
|
||||
if (!(await applyPendingThreadSettings(host))) return;
|
||||
host.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
|
||||
}
|
||||
|
||||
function pendingThreadSettingsUpdate(host: RuntimeSettingsActionsHost): ThreadSettingsUpdate {
|
||||
const update: ThreadSettingsUpdate = {};
|
||||
const currentState = state(host);
|
||||
const snapshot = host.runtimeSnapshot();
|
||||
const turnSettings = requestedTurnRuntimeSettings(snapshot);
|
||||
|
||||
if (currentState.runtime.requestedModel.kind !== "unchanged") {
|
||||
const model = pendingRuntimeSettingPayload(currentState.runtime.requestedModel);
|
||||
if (model !== undefined) update.model = model;
|
||||
}
|
||||
if (currentState.runtime.requestedReasoningEffort.kind !== "unchanged") {
|
||||
const effort = pendingRuntimeSettingPayload(currentState.runtime.requestedReasoningEffort);
|
||||
if (effort !== undefined) update.effort = effort;
|
||||
}
|
||||
if (currentState.runtime.requestedServiceTier.kind === "set") {
|
||||
const serviceTier = requestedServiceTierRequestValue(
|
||||
currentState.runtime.requestedServiceTier.value,
|
||||
fastServiceTierRequestValue(snapshot, readRuntimeConfig(currentState.connection.effectiveConfig)),
|
||||
);
|
||||
if (serviceTier !== undefined) update.serviceTier = serviceTier;
|
||||
} else if (currentState.runtime.requestedServiceTier.kind === "resetToConfig") {
|
||||
update.serviceTier = null;
|
||||
}
|
||||
if (currentState.runtime.requestedApprovalsReviewer.kind !== "unchanged") {
|
||||
const approvalsReviewer = pendingRuntimeSettingPayload(currentState.runtime.requestedApprovalsReviewer);
|
||||
if (approvalsReviewer !== undefined) update.approvalsReviewer = approvalsReviewer;
|
||||
}
|
||||
if (currentState.runtime.selectedCollaborationMode !== currentState.runtime.activeCollaborationMode) {
|
||||
if (turnSettings.warning) {
|
||||
host.addSystemMessage(`${host.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
|
||||
} else if (turnSettings.collaborationMode) {
|
||||
update.collaborationMode = turnSettings.collaborationMode;
|
||||
}
|
||||
}
|
||||
return update;
|
||||
}
|
||||
|
||||
function state(host: RuntimeSettingsActionsHost): ChatState {
|
||||
return host.stateStore.getState();
|
||||
}
|
||||
|
||||
function dispatch(host: RuntimeSettingsActionsHost, action: ChatAction): void {
|
||||
host.stateStore.dispatch(action);
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { requestedServiceTierRequestValue, type RequestedServiceTier } from "../../../app-server/service-tier";
|
||||
import type { ModeKind } from "../../../generated/app-server/ModeKind";
|
||||
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
|
||||
import type { ApprovalsReviewer } from "../../../generated/app-server/v2/ApprovalsReviewer";
|
||||
import type { ThreadSettingsUpdateParams } from "../../../generated/app-server/v2/ThreadSettingsUpdateParams";
|
||||
import { collaborationModeToggleMessage, nextCollaborationMode } from "../../../runtime/collaboration-mode";
|
||||
import { readRuntimeConfig } from "../../../runtime/config";
|
||||
import {
|
||||
autoReviewActive,
|
||||
fastModeActive,
|
||||
fastServiceTierRequestValue,
|
||||
pendingRuntimeSettingPayload,
|
||||
requestedTurnRuntimeSettings,
|
||||
type RuntimeSnapshot,
|
||||
} from "../../../runtime/state";
|
||||
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../../runtime/settings";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
|
||||
|
||||
type ThreadSettingsUpdate = Omit<ThreadSettingsUpdateParams, "threadId">;
|
||||
|
||||
export interface RuntimeSettingsControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
runtimeSnapshot: () => RuntimeSnapshot;
|
||||
collaborationModeLabel: () => string;
|
||||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
export class ChatRuntimeSettingsController {
|
||||
constructor(private readonly host: RuntimeSettingsControllerHost) {}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.host.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
async applyPendingThreadSettings(): Promise<boolean> {
|
||||
const client = this.host.currentClient();
|
||||
const threadId = this.state.activeThread.id;
|
||||
if (!client || !threadId) return true;
|
||||
|
||||
const update = this.pendingThreadSettingsUpdate();
|
||||
if (Object.keys(update).length === 0) return true;
|
||||
|
||||
try {
|
||||
await client.updateThreadSettings(threadId, update);
|
||||
this.dispatch({ type: "runtime/pending-thread-settings-committed", update });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async setRequestedModel(model: string | null): Promise<boolean> {
|
||||
this.dispatch({ type: "runtime/requested-model-set", model });
|
||||
return this.applyPendingThreadSettings();
|
||||
}
|
||||
|
||||
async setRequestedModelFromUi(model: string | null): Promise<void> {
|
||||
if (!(await this.setRequestedModel(model))) return;
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
this.host.addSystemMessage(modelOverrideMessage(model));
|
||||
}
|
||||
|
||||
async setRequestedReasoningEffort(effort: ReasoningEffort | null): Promise<boolean> {
|
||||
this.dispatch({ type: "runtime/requested-effort-set", effort });
|
||||
return this.applyPendingThreadSettings();
|
||||
}
|
||||
|
||||
async setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): Promise<void> {
|
||||
if (!(await this.setRequestedReasoningEffort(effort))) return;
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
this.host.addSystemMessage(reasoningEffortOverrideMessage(effort));
|
||||
}
|
||||
|
||||
async toggleFastMode(): Promise<void> {
|
||||
const snapshot = this.host.runtimeSnapshot();
|
||||
const config = readRuntimeConfig(this.state.connection.effectiveConfig);
|
||||
const next: RequestedServiceTier = fastModeActive(snapshot, config) ? "off" : "fast";
|
||||
this.dispatch({ type: "runtime/requested-service-tier-set", serviceTier: next });
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
if (!(await this.applyPendingThreadSettings())) return;
|
||||
this.host.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
|
||||
}
|
||||
|
||||
async toggleCollaborationMode(): Promise<void> {
|
||||
const next = nextCollaborationMode(this.state.runtime.selectedCollaborationMode);
|
||||
await this.setCollaborationMode(next);
|
||||
}
|
||||
|
||||
async setCollaborationMode(collaborationMode: ModeKind): Promise<boolean> {
|
||||
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode });
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const applied = await this.applyPendingThreadSettings();
|
||||
if (applied) this.host.addSystemMessage(collaborationModeToggleMessage(collaborationMode));
|
||||
return applied;
|
||||
}
|
||||
|
||||
async toggleAutoReview(): Promise<void> {
|
||||
const next: ApprovalsReviewer = autoReviewActive(this.host.runtimeSnapshot(), readRuntimeConfig(this.state.connection.effectiveConfig))
|
||||
? "user"
|
||||
: "auto_review";
|
||||
this.dispatch({ type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next });
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
if (!(await this.applyPendingThreadSettings())) return;
|
||||
this.host.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
|
||||
}
|
||||
|
||||
private pendingThreadSettingsUpdate(): ThreadSettingsUpdate {
|
||||
const update: ThreadSettingsUpdate = {};
|
||||
const state = this.state;
|
||||
const snapshot = this.host.runtimeSnapshot();
|
||||
const turnSettings = requestedTurnRuntimeSettings(snapshot);
|
||||
|
||||
if (state.runtime.requestedModel.kind !== "unchanged") {
|
||||
const model = pendingRuntimeSettingPayload(state.runtime.requestedModel);
|
||||
if (model !== undefined) update.model = model;
|
||||
}
|
||||
if (state.runtime.requestedReasoningEffort.kind !== "unchanged") {
|
||||
const effort = pendingRuntimeSettingPayload(state.runtime.requestedReasoningEffort);
|
||||
if (effort !== undefined) update.effort = effort;
|
||||
}
|
||||
if (state.runtime.requestedServiceTier.kind === "set") {
|
||||
const serviceTier = requestedServiceTierRequestValue(
|
||||
state.runtime.requestedServiceTier.value,
|
||||
fastServiceTierRequestValue(snapshot, readRuntimeConfig(state.connection.effectiveConfig)),
|
||||
);
|
||||
if (serviceTier !== undefined) update.serviceTier = serviceTier;
|
||||
} else if (state.runtime.requestedServiceTier.kind === "resetToConfig") {
|
||||
update.serviceTier = null;
|
||||
}
|
||||
if (state.runtime.requestedApprovalsReviewer.kind !== "unchanged") {
|
||||
const approvalsReviewer = pendingRuntimeSettingPayload(state.runtime.requestedApprovalsReviewer);
|
||||
if (approvalsReviewer !== undefined) update.approvalsReviewer = approvalsReviewer;
|
||||
}
|
||||
if (state.runtime.selectedCollaborationMode !== state.runtime.activeCollaborationMode) {
|
||||
if (turnSettings.warning) {
|
||||
this.host.addSystemMessage(`${this.host.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
|
||||
} else if (turnSettings.collaborationMode) {
|
||||
update.collaborationMode = turnSettings.collaborationMode;
|
||||
}
|
||||
}
|
||||
return update;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import type { ConnectionManager } from "../../../app-server/connection-manager";
|
||||
import { ChatAppServerDiagnosticsController } from "../app-server/diagnostics-controller";
|
||||
import { ChatAppServerMetadataController } from "../app-server/metadata-controller";
|
||||
import { ChatAppServerThreadController } from "../app-server/thread-controller";
|
||||
import { createChatAppServerDiagnosticsActions, type ChatAppServerDiagnosticsActions } from "../app-server/diagnostics-actions";
|
||||
import { createChatAppServerMetadataActions, type ChatAppServerMetadataActions } from "../app-server/metadata-actions";
|
||||
import { createChatAppServerThreadActions } from "../app-server/thread-actions";
|
||||
import { ChatConnectionController } from "./connection-controller";
|
||||
import type { ServerRequestActions } from "../requests/server-request-actions";
|
||||
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
|
||||
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
|
||||
import type { ThreadRenameController } from "../threads/thread-rename-controller";
|
||||
import { ChatInboundController } from "../inbound/controller";
|
||||
import type { ChatPanelContext } from "../panel/context";
|
||||
|
|
@ -15,7 +15,7 @@ export function createChatAppServerControllers(
|
|||
context: ChatPanelContext,
|
||||
refs: {
|
||||
connection: ConnectionManager;
|
||||
goals: ChatThreadGoalController;
|
||||
goals: ChatThreadGoalActions;
|
||||
},
|
||||
) {
|
||||
const { plugin, runtime, scroll } = context;
|
||||
|
|
@ -25,20 +25,20 @@ export function createChatAppServerControllers(
|
|||
vaultPath: plugin.vaultPath,
|
||||
currentClient: () => refs.connection.currentClient(),
|
||||
};
|
||||
const appServerMetadata = new ChatAppServerMetadataController({
|
||||
const appServerMetadata = createChatAppServerMetadataActions({
|
||||
...appServerBaseHost,
|
||||
publishAppServerMetadata: (metadata) => {
|
||||
plugin.publishAppServerMetadata(metadata);
|
||||
},
|
||||
});
|
||||
const appServerDiagnostics = new ChatAppServerDiagnosticsController({
|
||||
const appServerDiagnostics = createChatAppServerDiagnosticsActions({
|
||||
...appServerBaseHost,
|
||||
publishAppServerMetadata: (metadata) => {
|
||||
plugin.publishAppServerMetadata(metadata);
|
||||
},
|
||||
appServerMetadataSnapshot: () => appServerMetadata.appServerMetadataSnapshot(),
|
||||
});
|
||||
const appServerThreads = new ChatAppServerThreadController({
|
||||
const appServerThreads = createChatAppServerThreadActions({
|
||||
...appServerBaseHost,
|
||||
runtimeSnapshot: runtime.runtimeSnapshot,
|
||||
forceMessagesToBottom: scroll.forceBottom,
|
||||
|
|
@ -56,8 +56,8 @@ export function createChatAppServerControllers(
|
|||
export function createChatInboundController(
|
||||
context: ChatPanelContext,
|
||||
refs: {
|
||||
appServerMetadata: ChatAppServerMetadataController;
|
||||
appServerDiagnostics: ChatAppServerDiagnosticsController;
|
||||
appServerMetadata: ChatAppServerMetadataActions;
|
||||
appServerDiagnostics: ChatAppServerDiagnosticsActions;
|
||||
threadRename: ThreadRenameController;
|
||||
serverRequestResponder: ServerRequestActions;
|
||||
},
|
||||
|
|
@ -91,8 +91,8 @@ export function createChatConnectionControllers(
|
|||
context: ChatPanelContext,
|
||||
refs: {
|
||||
connection: ConnectionManager;
|
||||
appServerMetadata: ChatAppServerMetadataController;
|
||||
appServerDiagnostics: ChatAppServerDiagnosticsController;
|
||||
appServerMetadata: ChatAppServerMetadataActions;
|
||||
appServerDiagnostics: ChatAppServerDiagnosticsActions;
|
||||
},
|
||||
) {
|
||||
const { plugin, client, thread, status, liveState, render, lifecycle } = context;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ConnectionManager } from "../../../app-server/connection-manager";
|
||||
import { recoverRolloutTokenUsage } from "../../../app-server/rollout-token-usage";
|
||||
import { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
|
||||
import { ChatThreadActionController } from "./thread-actions-controller";
|
||||
import { ChatThreadGoalController } from "./thread-goal-controller";
|
||||
import { createChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
|
||||
import { createChatThreadActions } from "./thread-actions";
|
||||
import { createChatThreadGoalActions } from "./thread-goal-actions";
|
||||
import { ThreadHistoryController } from "./thread-history-controller";
|
||||
import { createThreadIdentityActions } from "./thread-identity-actions";
|
||||
import { ThreadRenameController } from "./thread-rename-controller";
|
||||
|
|
@ -34,7 +34,7 @@ export function createThreadControllerGroup(
|
|||
keepCurrentScrollPosition: scroll.preservePosition,
|
||||
setThreadTurnPresence: thread.resetTurnPresence,
|
||||
});
|
||||
const threadActions = new ChatThreadActionController({
|
||||
const threadActions = createChatThreadActions({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
|
|
@ -86,14 +86,14 @@ export function createThreadControllerGroup(
|
|||
resumeThread: thread.resumeThread,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
});
|
||||
const runtimeSettings = new ChatRuntimeSettingsController({
|
||||
const runtimeSettings = createChatRuntimeSettingsActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
runtimeSnapshot: runtime.runtimeSnapshot,
|
||||
collaborationModeLabel: runtime.collaborationModeLabel,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
});
|
||||
const goals = new ChatThreadGoalController({
|
||||
const goals = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { exportArchivedThreadMarkdown } from "../../../domain/threads/export";
|
||||
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
|
||||
import { inheritedForkThreadName, upsertThread } from "../../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../../settings/model";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
|
||||
import { turnsAfterTurnId } from "../fork";
|
||||
import { rollbackCandidateFromItems } from "../rollback";
|
||||
import type { ThreadHistoryController } from "./thread-history-controller";
|
||||
|
||||
export interface ChatThreadActionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
settings: () => CodexPanelSettings;
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: () => AppServerClient | null;
|
||||
history: ThreadHistoryController;
|
||||
addSystemMessage: (text: string) => void;
|
||||
setStatus: (status: string) => void;
|
||||
setComposerText: (text: string) => void;
|
||||
openThreadInNewView: (threadId: string) => Promise<unknown>;
|
||||
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
|
||||
notifyThreadArchived: (threadId: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
refreshThreads: () => Promise<void>;
|
||||
refreshSharedThreadListFromOpenSurface: () => void;
|
||||
}
|
||||
|
||||
export class ChatThreadActionController {
|
||||
constructor(private readonly host: ChatThreadActionControllerHost) {}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.host.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
async compactThread(threadId: string): Promise<void> {
|
||||
await this.host.ensureConnected();
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.compactThread(threadId);
|
||||
this.host.addSystemMessage("Compaction requested.");
|
||||
this.host.setStatus("Compaction requested.");
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<void> {
|
||||
if (await this.archiveThreadOnServer(threadId, saveMarkdown)) {
|
||||
this.host.notifyThreadArchived(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
private async archiveThreadOnServer(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<boolean> {
|
||||
if (chatTurnBusy(this.state)) {
|
||||
this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
|
||||
return false;
|
||||
}
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
const settings = this.host.settings();
|
||||
if (saveMarkdown) {
|
||||
const response = await client.readThread(threadId, true);
|
||||
const result = await exportArchivedThreadMarkdown(
|
||||
response.thread,
|
||||
{ ...settings, vaultPath: this.host.vaultPath },
|
||||
this.host.archiveAdapter(),
|
||||
);
|
||||
new Notice(`Saved archived thread to ${result.path}.`);
|
||||
}
|
||||
await client.archiveThread(threadId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async forkThread(threadId: string): Promise<void> {
|
||||
await this.forkThreadFromTurn(threadId, null, false);
|
||||
}
|
||||
|
||||
async forkThreadFromTurn(threadId: string, turnId: string | null, archiveSource: boolean): Promise<void> {
|
||||
if (chatTurnBusy(this.state)) {
|
||||
this.host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
|
||||
return;
|
||||
}
|
||||
await this.host.ensureConnected();
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
const turnsToDrop = turnId ? turnsAfterTurnId(this.state.transcript.displayItems, turnId) : 0;
|
||||
if (turnsToDrop === null) {
|
||||
this.host.addSystemMessage("Could not find the selected turn to fork.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceName = inheritedForkThreadName(threadId, this.state.threadList.listedThreads);
|
||||
const response = await client.forkThread(threadId, this.host.vaultPath);
|
||||
const forkedThreadId = response.thread.id;
|
||||
if (turnsToDrop > 0) {
|
||||
await client.rollbackThread(forkedThreadId, turnsToDrop);
|
||||
}
|
||||
if (sourceName) {
|
||||
try {
|
||||
await client.setThreadName(forkedThreadId, sourceName);
|
||||
this.host.notifyThreadRenamed(forkedThreadId, sourceName);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
|
||||
}
|
||||
}
|
||||
if (archiveSource) {
|
||||
if (!(await this.archiveThreadOnServer(threadId))) return;
|
||||
try {
|
||||
await this.host.openThreadInCurrentPanel(forkedThreadId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
|
||||
}
|
||||
this.host.notifyThreadArchived(threadId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.host.openThreadInNewView(forkedThreadId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackThread(threadId: string): Promise<void> {
|
||||
if (chatTurnBusy(this.state)) {
|
||||
this.host.addSystemMessage("Interrupt the current turn before rolling back.");
|
||||
return;
|
||||
}
|
||||
await this.host.ensureConnected();
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
const candidate = rollbackCandidateFromItems(this.state.transcript.displayItems);
|
||||
if (!candidate) {
|
||||
this.host.addSystemMessage("No completed turn to roll back.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.host.setStatus("Rolling back latest turn...");
|
||||
const response = await client.rollbackThread(threadId);
|
||||
this.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
thread: response.thread,
|
||||
cwd: response.thread.cwd,
|
||||
model: this.state.runtime.activeModel,
|
||||
reasoningEffort: this.state.runtime.activeReasoningEffort,
|
||||
serviceTier: this.state.runtime.activeServiceTier,
|
||||
approvalPolicy: this.state.runtime.activeApprovalPolicy,
|
||||
approvalsReviewer: this.state.runtime.activeApprovalsReviewer,
|
||||
activePermissionProfile: this.state.runtime.activePermissionProfile,
|
||||
listedThreads: upsertThread(this.state.threadList.listedThreads, response.thread),
|
||||
});
|
||||
await this.host.history.loadLatest(response.thread.id);
|
||||
this.host.setComposerText(candidate.text);
|
||||
this.host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
|
||||
this.host.setStatus("Rolled back latest turn.");
|
||||
this.host.notifyActiveThreadIdentityChanged();
|
||||
await this.host.refreshThreads();
|
||||
this.host.refreshSharedThreadListFromOpenSurface();
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
this.host.setStatus("Rollback failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src/features/chat/threads/thread-actions.ts
Normal file
211
src/features/chat/threads/thread-actions.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { exportArchivedThreadMarkdown } from "../../../domain/threads/export";
|
||||
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
|
||||
import { inheritedForkThreadName, upsertThread } from "../../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../../settings/model";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
|
||||
import { turnsAfterTurnId } from "../fork";
|
||||
import { rollbackCandidateFromItems } from "../rollback";
|
||||
import type { ThreadHistoryController } from "./thread-history-controller";
|
||||
|
||||
export interface ChatThreadActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
settings: () => CodexPanelSettings;
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: () => AppServerClient | null;
|
||||
history: ThreadHistoryController;
|
||||
addSystemMessage: (text: string) => void;
|
||||
setStatus: (status: string) => void;
|
||||
setComposerText: (text: string) => void;
|
||||
openThreadInNewView: (threadId: string) => Promise<unknown>;
|
||||
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
|
||||
notifyThreadArchived: (threadId: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
refreshThreads: () => Promise<void>;
|
||||
refreshSharedThreadListFromOpenSurface: () => void;
|
||||
}
|
||||
|
||||
export interface ChatThreadActions {
|
||||
compactThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
|
||||
forkThread: (threadId: string) => Promise<void>;
|
||||
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
|
||||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createChatThreadActions(host: ChatThreadActionsHost): ChatThreadActions {
|
||||
return {
|
||||
compactThread: (threadId) => compactThread(host, threadId),
|
||||
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
|
||||
forkThread: (threadId) => forkThread(host, threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource),
|
||||
rollbackThread: (threadId) => rollbackThread(host, threadId),
|
||||
};
|
||||
}
|
||||
|
||||
async function compactThread(host: ChatThreadActionsHost, threadId: string): Promise<void> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.compactThread(threadId);
|
||||
host.addSystemMessage("Compaction requested.");
|
||||
host.setStatus("Compaction requested.");
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
host: ChatThreadActionsHost,
|
||||
threadId: string,
|
||||
saveMarkdown = host.settings().archiveExportEnabled,
|
||||
): Promise<void> {
|
||||
if (await archiveThreadOnServer(host, threadId, saveMarkdown)) {
|
||||
host.notifyThreadArchived(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveThreadOnServer(
|
||||
host: ChatThreadActionsHost,
|
||||
threadId: string,
|
||||
saveMarkdown = host.settings().archiveExportEnabled,
|
||||
): Promise<boolean> {
|
||||
if (chatTurnBusy(state(host))) {
|
||||
host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
|
||||
return false;
|
||||
}
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
const settings = host.settings();
|
||||
if (saveMarkdown) {
|
||||
const response = await client.readThread(threadId, true);
|
||||
const result = await exportArchivedThreadMarkdown(response.thread, { ...settings, vaultPath: host.vaultPath }, host.archiveAdapter());
|
||||
new Notice(`Saved archived thread to ${result.path}.`);
|
||||
}
|
||||
await client.archiveThread(threadId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function forkThread(host: ChatThreadActionsHost, threadId: string): Promise<void> {
|
||||
return forkThreadFromTurn(host, threadId, null, false);
|
||||
}
|
||||
|
||||
async function forkThreadFromTurn(
|
||||
host: ChatThreadActionsHost,
|
||||
threadId: string,
|
||||
turnId: string | null,
|
||||
archiveSource: boolean,
|
||||
): Promise<void> {
|
||||
if (chatTurnBusy(state(host))) {
|
||||
host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
|
||||
return;
|
||||
}
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
const turnsToDrop = turnId ? turnsAfterTurnId(state(host).transcript.displayItems, turnId) : 0;
|
||||
if (turnsToDrop === null) {
|
||||
host.addSystemMessage("Could not find the selected turn to fork.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceName = inheritedForkThreadName(threadId, state(host).threadList.listedThreads);
|
||||
const response = await client.forkThread(threadId, host.vaultPath);
|
||||
const forkedThreadId = response.thread.id;
|
||||
if (turnsToDrop > 0) {
|
||||
await client.rollbackThread(forkedThreadId, turnsToDrop);
|
||||
}
|
||||
if (sourceName) {
|
||||
try {
|
||||
await client.setThreadName(forkedThreadId, sourceName);
|
||||
host.notifyThreadRenamed(forkedThreadId, sourceName);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
|
||||
}
|
||||
}
|
||||
if (archiveSource) {
|
||||
if (!(await archiveThreadOnServer(host, threadId))) return;
|
||||
try {
|
||||
await host.openThreadInCurrentPanel(forkedThreadId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
|
||||
}
|
||||
host.notifyThreadArchived(threadId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await host.openThreadInNewView(forkedThreadId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackThread(host: ChatThreadActionsHost, threadId: string): Promise<void> {
|
||||
if (chatTurnBusy(state(host))) {
|
||||
host.addSystemMessage("Interrupt the current turn before rolling back.");
|
||||
return;
|
||||
}
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
const candidate = rollbackCandidateFromItems(state(host).transcript.displayItems);
|
||||
if (!candidate) {
|
||||
host.addSystemMessage("No completed turn to roll back.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
host.setStatus("Rolling back latest turn...");
|
||||
const response = await client.rollbackThread(threadId);
|
||||
dispatch(host, {
|
||||
type: "active-thread/resumed",
|
||||
thread: response.thread,
|
||||
cwd: response.thread.cwd,
|
||||
model: state(host).runtime.activeModel,
|
||||
reasoningEffort: state(host).runtime.activeReasoningEffort,
|
||||
serviceTier: state(host).runtime.activeServiceTier,
|
||||
approvalPolicy: state(host).runtime.activeApprovalPolicy,
|
||||
approvalsReviewer: state(host).runtime.activeApprovalsReviewer,
|
||||
activePermissionProfile: state(host).runtime.activePermissionProfile,
|
||||
listedThreads: upsertThread(state(host).threadList.listedThreads, response.thread),
|
||||
});
|
||||
await host.history.loadLatest(response.thread.id);
|
||||
host.setComposerText(candidate.text);
|
||||
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
|
||||
host.setStatus("Rolled back latest turn.");
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
await host.refreshThreads();
|
||||
host.refreshSharedThreadListFromOpenSurface();
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
host.setStatus("Rollback failed.");
|
||||
}
|
||||
}
|
||||
|
||||
function state(host: ChatThreadActionsHost): ChatState {
|
||||
return host.stateStore.getState();
|
||||
}
|
||||
|
||||
function dispatch(host: ChatThreadActionsHost, action: ChatAction): void {
|
||||
host.stateStore.dispatch(action);
|
||||
}
|
||||
148
src/features/chat/threads/thread-goal-actions.ts
Normal file
148
src/features/chat/threads/thread-goal-actions.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import type { JsonValue } from "../../../generated/app-server/serde_json/JsonValue";
|
||||
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
|
||||
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
|
||||
import type { ChatStateStore } from "../chat-state";
|
||||
import type { GoalDisplayItem } from "../display/types";
|
||||
import { goalChangeItem } from "../goal-messages";
|
||||
|
||||
export interface ChatThreadGoalActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addGoalEvent: (item: GoalDisplayItem) => void;
|
||||
render: () => void;
|
||||
refreshLiveState: () => void;
|
||||
}
|
||||
|
||||
export interface ChatThreadGoalActions {
|
||||
activeGoal: () => ThreadGoal | null;
|
||||
syncThreadGoal: (threadId: string) => Promise<void>;
|
||||
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
|
||||
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
|
||||
clear: (threadId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createChatThreadGoalActions(host: ChatThreadGoalActionsHost): ChatThreadGoalActions {
|
||||
return {
|
||||
activeGoal: () => host.stateStore.getState().activeThread.goal,
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
|
||||
setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget),
|
||||
setStatus: (threadId, status) => setGoalStatus(host, threadId, status),
|
||||
clear: (threadId) => clearGoal(host, threadId),
|
||||
};
|
||||
}
|
||||
|
||||
async function syncThreadGoal(host: ChatThreadGoalActionsHost, threadId: string): Promise<void> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
const response = await client.getThreadGoal(threadId);
|
||||
applyGoalIfActive(host, threadId, response.goal, { reportChange: false });
|
||||
} catch (error) {
|
||||
if (host.stateStore.getState().activeThread.id !== threadId) return;
|
||||
host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setObjective(
|
||||
host: ChatThreadGoalActionsHost,
|
||||
threadId: string,
|
||||
objective: string,
|
||||
tokenBudget: number | null,
|
||||
): Promise<boolean> {
|
||||
const trimmed = objective.trim();
|
||||
if (!trimmed) {
|
||||
host.addSystemMessage("Goal objective cannot be empty.");
|
||||
return false;
|
||||
}
|
||||
const current = host.stateStore.getState().activeThread.goal;
|
||||
const isNewGoal = current === null;
|
||||
const applied = await setGoal(host, threadId, {
|
||||
objective: trimmed,
|
||||
status: current?.status ?? "active",
|
||||
tokenBudget,
|
||||
});
|
||||
if (applied && isNewGoal) {
|
||||
await recordGoalUserMessage(host, threadId, trimmed);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
function setGoalStatus(host: ChatThreadGoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
|
||||
return setGoal(host, threadId, { status });
|
||||
}
|
||||
|
||||
async function clearGoal(host: ChatThreadGoalActionsHost, threadId: string): Promise<boolean> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
await client.clearThreadGoal(threadId);
|
||||
applyGoalIfActive(host, threadId, null, { reportChange: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(errorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setGoal(
|
||||
host: ChatThreadGoalActionsHost,
|
||||
threadId: string,
|
||||
params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
|
||||
): Promise<boolean> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
const response = await client.setThreadGoal(threadId, params);
|
||||
return applyGoalIfActive(host, threadId, response.goal, { reportChange: true });
|
||||
} catch (error) {
|
||||
host.addSystemMessage(errorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGoalIfActive(
|
||||
host: ChatThreadGoalActionsHost,
|
||||
threadId: string,
|
||||
goal: ThreadGoal | null,
|
||||
options: { reportChange: boolean },
|
||||
): boolean {
|
||||
const state = host.stateStore.getState();
|
||||
if (state.activeThread.id !== threadId) return false;
|
||||
const item = options.reportChange ? goalChangeItem(goalEventId(), state.activeThread.goal, goal) : null;
|
||||
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
|
||||
if (item) host.addGoalEvent(item);
|
||||
host.refreshLiveState();
|
||||
host.render();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function recordGoalUserMessage(host: ChatThreadGoalActionsHost, threadId: string, objective: string): Promise<void> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.injectThreadItems(threadId, [goalUserHistoryItem(objective)]);
|
||||
} catch (error) {
|
||||
host.addSystemMessage(`Could not record goal message: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function goalEventId(): string {
|
||||
return `goal-${String(Date.now())}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function goalUserHistoryItem(text: string): JsonValue {
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
};
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import type { JsonValue } from "../../../generated/app-server/serde_json/JsonValue";
|
||||
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
|
||||
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
|
||||
import type { ChatStateStore } from "../chat-state";
|
||||
import type { GoalDisplayItem } from "../display/types";
|
||||
import { goalChangeItem } from "../goal-messages";
|
||||
|
||||
export interface ChatThreadGoalControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addGoalEvent: (item: GoalDisplayItem) => void;
|
||||
render: () => void;
|
||||
refreshLiveState: () => void;
|
||||
}
|
||||
|
||||
export class ChatThreadGoalController {
|
||||
constructor(private readonly host: ChatThreadGoalControllerHost) {}
|
||||
|
||||
activeGoal(): ThreadGoal | null {
|
||||
return this.host.stateStore.getState().activeThread.goal;
|
||||
}
|
||||
|
||||
async syncThreadGoal(threadId: string): Promise<void> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
const response = await client.getThreadGoal(threadId);
|
||||
this.applyGoalIfActive(threadId, response.goal, { reportChange: false });
|
||||
} catch (error) {
|
||||
if (this.host.stateStore.getState().activeThread.id !== threadId) return;
|
||||
this.host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async setObjective(threadId: string, objective: string, tokenBudget: number | null): Promise<boolean> {
|
||||
const trimmed = objective.trim();
|
||||
if (!trimmed) {
|
||||
this.host.addSystemMessage("Goal objective cannot be empty.");
|
||||
return false;
|
||||
}
|
||||
const current = this.host.stateStore.getState().activeThread.goal;
|
||||
const isNewGoal = current === null;
|
||||
const applied = await this.setGoal(threadId, {
|
||||
objective: trimmed,
|
||||
status: current?.status ?? "active",
|
||||
tokenBudget,
|
||||
});
|
||||
if (applied && isNewGoal) {
|
||||
await this.recordGoalUserMessage(threadId, trimmed);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
async setStatus(threadId: string, status: ThreadGoalStatus): Promise<boolean> {
|
||||
return this.setGoal(threadId, { status });
|
||||
}
|
||||
|
||||
async clear(threadId: string): Promise<boolean> {
|
||||
await this.host.ensureConnected();
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
await client.clearThreadGoal(threadId);
|
||||
this.applyGoalIfActive(threadId, null, { reportChange: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(errorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async setGoal(
|
||||
threadId: string,
|
||||
params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
|
||||
): Promise<boolean> {
|
||||
await this.host.ensureConnected();
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
const response = await client.setThreadGoal(threadId, params);
|
||||
return this.applyGoalIfActive(threadId, response.goal, { reportChange: true });
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(errorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyGoalIfActive(threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }): boolean {
|
||||
const state = this.host.stateStore.getState();
|
||||
if (state.activeThread.id !== threadId) return false;
|
||||
const item = options.reportChange ? goalChangeItem(goalEventId(), state.activeThread.goal, goal) : null;
|
||||
this.host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
|
||||
if (item) this.host.addGoalEvent(item);
|
||||
this.host.refreshLiveState();
|
||||
this.host.render();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async recordGoalUserMessage(threadId: string, objective: string): Promise<void> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.injectThreadItems(threadId, [goalUserHistoryItem(objective)]);
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(`Could not record goal message: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function goalEventId(): string {
|
||||
return `goal-${String(Date.now())}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function goalUserHistoryItem(text: string): JsonValue {
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
};
|
||||
}
|
||||
96
src/features/chat/turns/composer-submission-actions.ts
Normal file
96
src/features/chat/turns/composer-submission-actions.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { submissionStateSnapshot } from "../chat-state-selectors";
|
||||
import type { ChatStateStore } from "../chat-state";
|
||||
import { parseSlashCommand } from "../composer/suggestions";
|
||||
import type { SlashCommandExecutionResult } from "../slash-command-execution";
|
||||
import type { SlashCommandName } from "../composer/slash-commands";
|
||||
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
|
||||
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
|
||||
|
||||
interface ComposerDraftPort {
|
||||
readonly trimmedDraft: string;
|
||||
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
|
||||
}
|
||||
|
||||
interface ComposerSlashCommandPort {
|
||||
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
|
||||
}
|
||||
|
||||
interface ComposerTurnSubmissionPort {
|
||||
sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void>;
|
||||
}
|
||||
|
||||
interface ComposerConnectionPort {
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: () => AppServerClient | null;
|
||||
}
|
||||
|
||||
interface ComposerStatusPort {
|
||||
setStatus: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface ComposerSubmissionActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
composer: ComposerDraftPort;
|
||||
slashCommands: ComposerSlashCommandPort;
|
||||
turnSubmission: ComposerTurnSubmissionPort;
|
||||
connection: ComposerConnectionPort;
|
||||
status: ComposerStatusPort;
|
||||
}
|
||||
|
||||
export interface ComposerSubmissionActions {
|
||||
submit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createComposerSubmissionActions(host: ComposerSubmissionActionsHost): ComposerSubmissionActions {
|
||||
return {
|
||||
submit: () => submitComposer(host),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitComposer(host: ComposerSubmissionActionsHost): Promise<void> {
|
||||
const draft = host.composer.trimmedDraft;
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
|
||||
await interruptTurn(host);
|
||||
return;
|
||||
}
|
||||
await sendMessage(host);
|
||||
}
|
||||
|
||||
async function sendMessage(host: ComposerSubmissionActionsHost): Promise<void> {
|
||||
const text = host.composer.trimmedDraft;
|
||||
if (!text) return;
|
||||
|
||||
await host.connection.ensureConnected();
|
||||
if (!host.connection.currentClient()) return;
|
||||
|
||||
const slashCommand = parseSlashCommand(text);
|
||||
if (slashCommand) {
|
||||
host.composer.setDraft("", { clearSuggestions: true });
|
||||
const result = await host.slashCommands.execute(slashCommand.command, slashCommand.args);
|
||||
if (result?.composerDraft !== undefined) {
|
||||
host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true });
|
||||
}
|
||||
if (result?.sendText) {
|
||||
await host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await host.turnSubmission.sendTurnText(text);
|
||||
}
|
||||
|
||||
async function interruptTurn(host: ComposerSubmissionActionsHost): Promise<void> {
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
const turnId = state.activeTurnId;
|
||||
const client = host.connection.currentClient();
|
||||
if (!client || !state.activeThreadId || !turnId) return;
|
||||
try {
|
||||
await client.interruptTurn(state.activeThreadId, turnId);
|
||||
host.status.setStatus("Interrupt requested.");
|
||||
} catch (error) {
|
||||
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { submissionStateSnapshot } from "../chat-state-selectors";
|
||||
import type { ChatStateStore } from "../chat-state";
|
||||
import { parseSlashCommand } from "../composer/suggestions";
|
||||
import type { SlashCommandExecutionResult } from "../slash-command-execution";
|
||||
import type { SlashCommandName } from "../composer/slash-commands";
|
||||
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
|
||||
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
|
||||
|
||||
interface ComposerDraftPort {
|
||||
readonly trimmedDraft: string;
|
||||
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
|
||||
}
|
||||
|
||||
interface ComposerSlashCommandPort {
|
||||
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
|
||||
}
|
||||
|
||||
interface ComposerTurnSubmissionPort {
|
||||
sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void>;
|
||||
}
|
||||
|
||||
interface ComposerConnectionPort {
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: () => AppServerClient | null;
|
||||
}
|
||||
|
||||
interface ComposerStatusPort {
|
||||
setStatus: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface ComposerSubmissionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
composer: ComposerDraftPort;
|
||||
slashCommands: ComposerSlashCommandPort;
|
||||
turnSubmission: ComposerTurnSubmissionPort;
|
||||
connection: ComposerConnectionPort;
|
||||
status: ComposerStatusPort;
|
||||
}
|
||||
|
||||
export class ComposerSubmissionController {
|
||||
constructor(private readonly host: ComposerSubmissionControllerHost) {}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const draft = this.host.composer.trimmedDraft;
|
||||
const state = submissionStateSnapshot(this.host.stateStore.getState());
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
|
||||
await this.interruptTurn();
|
||||
return;
|
||||
}
|
||||
await this.sendMessage();
|
||||
}
|
||||
|
||||
private async sendMessage(): Promise<void> {
|
||||
const text = this.host.composer.trimmedDraft;
|
||||
if (!text) return;
|
||||
|
||||
await this.host.connection.ensureConnected();
|
||||
if (!this.host.connection.currentClient()) return;
|
||||
|
||||
const slashCommand = parseSlashCommand(text);
|
||||
if (slashCommand) {
|
||||
this.host.composer.setDraft("", { clearSuggestions: true });
|
||||
const result = await this.host.slashCommands.execute(slashCommand.command, slashCommand.args);
|
||||
if (result?.composerDraft !== undefined) {
|
||||
this.host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true });
|
||||
}
|
||||
if (result?.sendText) {
|
||||
await this.host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.host.turnSubmission.sendTurnText(text);
|
||||
}
|
||||
|
||||
private async interruptTurn(): Promise<void> {
|
||||
const state = submissionStateSnapshot(this.host.stateStore.getState());
|
||||
const turnId = state.activeTurnId;
|
||||
const client = this.host.connection.currentClient();
|
||||
if (!client || !state.activeThreadId || !turnId) return;
|
||||
try {
|
||||
await client.interruptTurn(state.activeThreadId, turnId);
|
||||
this.host.status.setStatus("Interrupt requested.");
|
||||
} catch (error) {
|
||||
this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
|
||||
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
|
||||
import { ChatComposerController } from "../composer/controller";
|
||||
import { activeTurnId } from "../chat-state";
|
||||
import type { ChatReconnectActions } from "../session/reconnect-actions";
|
||||
import { PendingRequestController } from "../requests/pending-request-controller";
|
||||
import type { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
|
||||
import { ComposerSubmissionController } from "./composer-submission-controller";
|
||||
import type { ChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
|
||||
import { createComposerSubmissionActions } from "./composer-submission-actions";
|
||||
import { createPlanImplementationActions } from "./plan-implementation-actions";
|
||||
import { SlashCommandController } from "./slash-command-controller";
|
||||
import { createSlashCommandActions } from "./slash-command-actions";
|
||||
import { TurnSubmissionController } from "./turn-submission-controller";
|
||||
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
|
||||
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
|
||||
import type { ChatThreadActions } from "../threads/thread-actions";
|
||||
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
|
||||
import type { ThreadHistoryController } from "../threads/thread-history-controller";
|
||||
import type { ThreadRenameController } from "../threads/thread-rename-controller";
|
||||
import type { ChatInboundController } from "../inbound/controller";
|
||||
|
|
@ -21,12 +21,12 @@ export function createTurnControllerGroup(
|
|||
context: ChatPanelContext,
|
||||
refs: {
|
||||
controller: ChatInboundController;
|
||||
appServerThreads: ChatAppServerThreadController;
|
||||
runtimeSettings: ChatRuntimeSettingsController;
|
||||
threadActions: ChatThreadActionController;
|
||||
appServerThreads: ChatAppServerThreadActions;
|
||||
runtimeSettings: ChatRuntimeSettingsActions;
|
||||
threadActions: ChatThreadActions;
|
||||
threadRename: ThreadRenameController;
|
||||
reconnectActions: ChatReconnectActions;
|
||||
goals: ChatThreadGoalController;
|
||||
goals: ChatThreadGoalActions;
|
||||
history: ThreadHistoryController;
|
||||
},
|
||||
) {
|
||||
|
|
@ -97,7 +97,7 @@ export function createTurnControllerGroup(
|
|||
addSystemMessage: status.addSystemMessage,
|
||||
},
|
||||
});
|
||||
const slashCommands = new SlashCommandController({
|
||||
const slashCommands = createSlashCommandActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
codexInput: (text) => composerController.codexInput(text),
|
||||
|
|
@ -178,7 +178,7 @@ export function createTurnControllerGroup(
|
|||
renderPending: () => pendingRequests.renderNode(),
|
||||
},
|
||||
});
|
||||
const composerSubmission = new ComposerSubmissionController({
|
||||
const composerSubmission = createComposerSubmissionActions({
|
||||
stateStore,
|
||||
composer: composerController,
|
||||
slashCommands,
|
||||
|
|
|
|||
148
src/features/chat/turns/slash-command-actions.ts
Normal file
148
src/features/chat/turns/slash-command-actions.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import {
|
||||
referencedThreadInput as buildReferencedThreadInput,
|
||||
referencedThreadTurns,
|
||||
REFERENCED_THREAD_TURN_LIMIT,
|
||||
} from "../../../domain/threads/reference";
|
||||
import type { Thread } from "../../../generated/app-server/v2/Thread";
|
||||
import {
|
||||
executeSlashCommand as runSlashCommand,
|
||||
type SlashCommandExecutionResult,
|
||||
type ThreadReferenceInput,
|
||||
} from "../slash-command-execution";
|
||||
import type { SlashCommandName } from "../composer/slash-commands";
|
||||
import type { DisplayDetailSection } from "../display/types";
|
||||
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
|
||||
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
|
||||
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
|
||||
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
|
||||
import { submissionStateSnapshot } from "../chat-state-selectors";
|
||||
import type { ChatStateStore } from "../chat-state";
|
||||
|
||||
export interface SlashCommandThreadPort {
|
||||
startNewThread: () => Promise<void>;
|
||||
startThreadForGoal: (objective: string) => Promise<string | null>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
forkThread: (threadId: string) => Promise<void>;
|
||||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
compactThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string) => Promise<void>;
|
||||
renameThread: (threadId: string, name: string) => Promise<void>;
|
||||
reconnect: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SlashCommandRuntimePort {
|
||||
toggleFastMode: () => void | Promise<void>;
|
||||
toggleCollaborationMode: () => void | Promise<void>;
|
||||
toggleAutoReview: () => void | Promise<void>;
|
||||
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
|
||||
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
|
||||
}
|
||||
|
||||
export interface SlashCommandStatusPort {
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
|
||||
setStatus: (status: string) => void;
|
||||
statusSummaryLines: () => string[];
|
||||
connectionDiagnosticDetails: () => DisplayDetailSection[];
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
modelStatusLines: () => string[];
|
||||
effortStatusLines: () => string[];
|
||||
}
|
||||
|
||||
export interface SlashCommandGoalPort {
|
||||
activeGoal: () => ThreadGoal | null;
|
||||
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
|
||||
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
|
||||
clear: (threadId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SlashCommandActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
codexInput: (text: string) => UserInput[];
|
||||
threads: SlashCommandThreadPort;
|
||||
runtime: SlashCommandRuntimePort;
|
||||
goals: SlashCommandGoalPort;
|
||||
status: SlashCommandStatusPort;
|
||||
}
|
||||
|
||||
export interface SlashCommandActions {
|
||||
execute: (command: SlashCommandName, args: string) => Promise<SlashCommandExecutionResult | undefined>;
|
||||
}
|
||||
|
||||
export function createSlashCommandActions(host: SlashCommandActionsHost): SlashCommandActions {
|
||||
return {
|
||||
execute: (command, args) => executeSlashCommand(host, command, args),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSlashCommand(
|
||||
host: SlashCommandActionsHost,
|
||||
command: SlashCommandName,
|
||||
args: string,
|
||||
): Promise<SlashCommandExecutionResult | undefined> {
|
||||
const state = submissionStateSnapshot(host.stateStore.getState());
|
||||
const client = host.currentClient();
|
||||
if (!client && command !== "reconnect" && command !== "compact") return;
|
||||
return runSlashCommand(command, args, {
|
||||
activeThreadId: state.activeThreadId,
|
||||
listedThreads: state.listedThreads,
|
||||
startNewThread: () => host.threads.startNewThread(),
|
||||
startThreadForGoal: (objective) => host.threads.startThreadForGoal(objective),
|
||||
resumeThread: (threadId) => host.threads.resumeThread(threadId),
|
||||
reconnect: () => host.threads.reconnect(),
|
||||
referThread: (thread, message) => {
|
||||
if (!client) return Promise.resolve(null);
|
||||
return referencedThreadInput(host, client, thread, message);
|
||||
},
|
||||
forkThread: (threadId) => host.threads.forkThread(threadId),
|
||||
rollbackThread: (threadId) => host.threads.rollbackThread(threadId),
|
||||
compactThread: (threadId) => host.threads.compactThread(threadId),
|
||||
archiveThread: (threadId) => host.threads.archiveThread(threadId),
|
||||
renameThread: (threadId, name) => host.threads.renameThread(threadId, name),
|
||||
busy: state.busy,
|
||||
toggleFastMode: () => host.runtime.toggleFastMode(),
|
||||
toggleCollaborationMode: () => host.runtime.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => host.runtime.toggleAutoReview(),
|
||||
addSystemMessage: (text) => {
|
||||
host.status.addSystemMessage(text);
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
host.status.addStructuredSystemMessage(text, details);
|
||||
},
|
||||
setRequestedModel: (model) => host.runtime.setRequestedModel(model),
|
||||
setRequestedReasoningEffort: (effort) => host.runtime.setRequestedReasoningEffort(effort),
|
||||
activeGoal: () => host.goals.activeGoal(),
|
||||
setGoalObjective: (threadId, objective, tokenBudget) => host.goals.setObjective(threadId, objective, tokenBudget),
|
||||
setGoalStatus: (threadId, status) => host.goals.setStatus(threadId, status),
|
||||
clearGoal: (threadId) => host.goals.clear(threadId),
|
||||
statusSummaryLines: () => host.status.statusSummaryLines(),
|
||||
connectionDiagnosticDetails: () => host.status.connectionDiagnosticDetails(),
|
||||
mcpStatusLines: () => host.status.mcpStatusLines(),
|
||||
modelStatusLines: () => host.status.modelStatusLines(),
|
||||
effortStatusLines: () => host.status.effortStatusLines(),
|
||||
});
|
||||
}
|
||||
|
||||
async function referencedThreadInput(
|
||||
host: SlashCommandActionsHost,
|
||||
client: AppServerClient,
|
||||
thread: Thread,
|
||||
message: string,
|
||||
): Promise<ThreadReferenceInput | null> {
|
||||
try {
|
||||
const response = await client.threadTurnsList(thread.id, null, REFERENCED_THREAD_TURN_LIMIT);
|
||||
const turns = referencedThreadTurns(response.data);
|
||||
if (turns.length === 0) {
|
||||
host.status.addSystemMessage("Referenced thread has no readable conversation turns.");
|
||||
return null;
|
||||
}
|
||||
const reference = buildReferencedThreadInput(thread, turns, message, host.codexInput(message));
|
||||
host.status.setStatus(reference.status);
|
||||
return reference;
|
||||
} catch (error) {
|
||||
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import {
|
||||
referencedThreadInput as buildReferencedThreadInput,
|
||||
referencedThreadTurns,
|
||||
REFERENCED_THREAD_TURN_LIMIT,
|
||||
} from "../../../domain/threads/reference";
|
||||
import type { Thread } from "../../../generated/app-server/v2/Thread";
|
||||
import {
|
||||
executeSlashCommand as runSlashCommand,
|
||||
type SlashCommandExecutionResult,
|
||||
type ThreadReferenceInput,
|
||||
} from "../slash-command-execution";
|
||||
import type { SlashCommandName } from "../composer/slash-commands";
|
||||
import type { DisplayDetailSection } from "../display/types";
|
||||
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
|
||||
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
|
||||
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
|
||||
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
|
||||
import { submissionStateSnapshot } from "../chat-state-selectors";
|
||||
import type { ChatStateStore } from "../chat-state";
|
||||
|
||||
export interface SlashCommandThreadPort {
|
||||
startNewThread: () => Promise<void>;
|
||||
startThreadForGoal: (objective: string) => Promise<string | null>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
forkThread: (threadId: string) => Promise<void>;
|
||||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
compactThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string) => Promise<void>;
|
||||
renameThread: (threadId: string, name: string) => Promise<void>;
|
||||
reconnect: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SlashCommandRuntimePort {
|
||||
toggleFastMode: () => void | Promise<void>;
|
||||
toggleCollaborationMode: () => void | Promise<void>;
|
||||
toggleAutoReview: () => void | Promise<void>;
|
||||
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
|
||||
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
|
||||
}
|
||||
|
||||
export interface SlashCommandStatusPort {
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
|
||||
setStatus: (status: string) => void;
|
||||
statusSummaryLines: () => string[];
|
||||
connectionDiagnosticDetails: () => DisplayDetailSection[];
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
modelStatusLines: () => string[];
|
||||
effortStatusLines: () => string[];
|
||||
}
|
||||
|
||||
export interface SlashCommandGoalPort {
|
||||
activeGoal: () => ThreadGoal | null;
|
||||
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
|
||||
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
|
||||
clear: (threadId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SlashCommandControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
codexInput: (text: string) => UserInput[];
|
||||
threads: SlashCommandThreadPort;
|
||||
runtime: SlashCommandRuntimePort;
|
||||
goals: SlashCommandGoalPort;
|
||||
status: SlashCommandStatusPort;
|
||||
}
|
||||
|
||||
export class SlashCommandController {
|
||||
constructor(private readonly host: SlashCommandControllerHost) {}
|
||||
|
||||
async execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined> {
|
||||
const state = submissionStateSnapshot(this.host.stateStore.getState());
|
||||
const client = this.host.currentClient();
|
||||
if (!client && command !== "reconnect" && command !== "compact") return;
|
||||
return runSlashCommand(command, args, {
|
||||
activeThreadId: state.activeThreadId,
|
||||
listedThreads: state.listedThreads,
|
||||
startNewThread: () => this.host.threads.startNewThread(),
|
||||
startThreadForGoal: (objective) => this.host.threads.startThreadForGoal(objective),
|
||||
resumeThread: (threadId) => this.host.threads.resumeThread(threadId),
|
||||
reconnect: () => this.host.threads.reconnect(),
|
||||
referThread: (thread, message) => {
|
||||
if (!client) return Promise.resolve(null);
|
||||
return this.referencedThreadInput(client, thread, message);
|
||||
},
|
||||
forkThread: (threadId) => this.host.threads.forkThread(threadId),
|
||||
rollbackThread: (threadId) => this.host.threads.rollbackThread(threadId),
|
||||
compactThread: (threadId) => this.host.threads.compactThread(threadId),
|
||||
archiveThread: (threadId) => this.host.threads.archiveThread(threadId),
|
||||
renameThread: (threadId, name) => this.host.threads.renameThread(threadId, name),
|
||||
busy: state.busy,
|
||||
toggleFastMode: () => this.host.runtime.toggleFastMode(),
|
||||
toggleCollaborationMode: () => this.host.runtime.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => this.host.runtime.toggleAutoReview(),
|
||||
addSystemMessage: (text) => {
|
||||
this.host.status.addSystemMessage(text);
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
this.host.status.addStructuredSystemMessage(text, details);
|
||||
},
|
||||
setRequestedModel: (model) => this.host.runtime.setRequestedModel(model),
|
||||
setRequestedReasoningEffort: (effort) => this.host.runtime.setRequestedReasoningEffort(effort),
|
||||
activeGoal: () => this.host.goals.activeGoal(),
|
||||
setGoalObjective: (threadId, objective, tokenBudget) => this.host.goals.setObjective(threadId, objective, tokenBudget),
|
||||
setGoalStatus: (threadId, status) => this.host.goals.setStatus(threadId, status),
|
||||
clearGoal: (threadId) => this.host.goals.clear(threadId),
|
||||
statusSummaryLines: () => this.host.status.statusSummaryLines(),
|
||||
connectionDiagnosticDetails: () => this.host.status.connectionDiagnosticDetails(),
|
||||
mcpStatusLines: () => this.host.status.mcpStatusLines(),
|
||||
modelStatusLines: () => this.host.status.modelStatusLines(),
|
||||
effortStatusLines: () => this.host.status.effortStatusLines(),
|
||||
});
|
||||
}
|
||||
|
||||
private async referencedThreadInput(client: AppServerClient, thread: Thread, message: string): Promise<ThreadReferenceInput | null> {
|
||||
try {
|
||||
const response = await client.threadTurnsList(thread.id, null, REFERENCED_THREAD_TURN_LIMIT);
|
||||
const turns = referencedThreadTurns(response.data);
|
||||
if (turns.length === 0) {
|
||||
this.host.status.addSystemMessage("Referenced thread has no readable conversation turns.");
|
||||
return null;
|
||||
}
|
||||
const reference = buildReferencedThreadInput(thread, turns, message, this.host.codexInput(message));
|
||||
this.host.status.setStatus(reference.status);
|
||||
return reference;
|
||||
} catch (error) {
|
||||
this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../src/app-server/client";
|
||||
import { ChatAppServerDiagnosticsController } from "../../../src/features/chat/app-server/diagnostics-controller";
|
||||
import { ChatAppServerMetadataController } from "../../../src/features/chat/app-server/metadata-controller";
|
||||
import { ChatAppServerThreadController } from "../../../src/features/chat/app-server/thread-controller";
|
||||
import { createChatAppServerDiagnosticsActions } from "../../../src/features/chat/app-server/diagnostics-actions";
|
||||
import { createChatAppServerMetadataActions } from "../../../src/features/chat/app-server/metadata-actions";
|
||||
import { createChatAppServerThreadActions } from "../../../src/features/chat/app-server/thread-actions";
|
||||
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
|
||||
import type { Model } from "../../../src/generated/app-server/v2/Model";
|
||||
import type { McpServerStatus } from "../../../src/generated/app-server/v2/McpServerStatus";
|
||||
|
|
@ -34,7 +34,7 @@ describe("chat app-server controllers", () => {
|
|||
}),
|
||||
} as unknown as AppServerClient;
|
||||
|
||||
const controller = new ChatAppServerThreadController({
|
||||
const controller = createChatAppServerThreadActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -68,7 +68,7 @@ describe("chat app-server controllers", () => {
|
|||
}),
|
||||
} as unknown as AppServerClient;
|
||||
|
||||
const controller = new ChatAppServerThreadController({
|
||||
const controller = createChatAppServerThreadActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -100,7 +100,7 @@ describe("chat app-server controllers", () => {
|
|||
}),
|
||||
} as unknown as AppServerClient;
|
||||
|
||||
const controller = new ChatAppServerThreadController({
|
||||
const controller = createChatAppServerThreadActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -134,13 +134,13 @@ describe("chat app-server controllers", () => {
|
|||
readModelProviderCapabilities: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as AppServerClient;
|
||||
|
||||
const metadata = new ChatAppServerMetadataController({
|
||||
const metadata = createChatAppServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
publishAppServerMetadata: () => undefined,
|
||||
});
|
||||
const diagnostics = new ChatAppServerDiagnosticsController({
|
||||
const diagnostics = createChatAppServerDiagnosticsActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -181,7 +181,7 @@ describe("chat app-server controllers", () => {
|
|||
const client = {
|
||||
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: rateLimit, rateLimitsByLimitId: null }),
|
||||
} as unknown as AppServerClient;
|
||||
const controller = new ChatAppServerMetadataController({
|
||||
const controller = createChatAppServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -206,7 +206,7 @@ describe("chat app-server controllers", () => {
|
|||
const client = {
|
||||
readAccountRateLimits: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
} as unknown as AppServerClient;
|
||||
const controller = new ChatAppServerMetadataController({
|
||||
const controller = createChatAppServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -228,13 +228,13 @@ describe("chat app-server controllers", () => {
|
|||
const client = {
|
||||
listMcpServerStatus,
|
||||
} as unknown as AppServerClient;
|
||||
const metadata = new ChatAppServerMetadataController({
|
||||
const metadata = createChatAppServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
publishAppServerMetadata: () => undefined,
|
||||
});
|
||||
const controller = new ChatAppServerDiagnosticsController({
|
||||
const controller = createChatAppServerDiagnosticsActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import { ToolbarPanelController } from "../../../../src/features/chat/panel/toolbar-controller";
|
||||
import type { ChatThreadActionController } from "../../../../src/features/chat/threads/thread-actions-controller";
|
||||
import type { ChatThreadActions } from "../../../../src/features/chat/threads/thread-actions";
|
||||
|
||||
describe("ToolbarPanelController", () => {
|
||||
it("tracks archive confirmation and delegates archive actions", async () => {
|
||||
|
|
@ -13,7 +13,7 @@ describe("ToolbarPanelController", () => {
|
|||
const scheduleRender = vi.fn();
|
||||
const controller = new ToolbarPanelController({
|
||||
stateStore,
|
||||
threadActions: { archiveThread } as unknown as ChatThreadActionController,
|
||||
threadActions: { archiveThread } as unknown as ChatThreadActions,
|
||||
scheduleRender,
|
||||
});
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ describe("ToolbarPanelController", () => {
|
|||
const scheduleRender = vi.fn();
|
||||
const controller = new ToolbarPanelController({
|
||||
stateStore,
|
||||
threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActionController,
|
||||
threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActions,
|
||||
scheduleRender,
|
||||
});
|
||||
controller.toggleHistory();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatRuntimeSettingsController } from "../../../../src/features/chat/runtime/runtime-settings-controller";
|
||||
import {
|
||||
createChatRuntimeSettingsActions,
|
||||
type ChatRuntimeSettingsActions,
|
||||
} from "../../../../src/features/chat/runtime/runtime-settings-actions";
|
||||
import { createChatState, createChatStateStore, type ActiveThreadSettingsAppliedAction } from "../../../../src/features/chat/chat-state";
|
||||
import type { AppServerClient } from "../../../../src/app-server/client";
|
||||
import type { Model } from "../../../../src/generated/app-server/v2/Model";
|
||||
|
||||
describe("ChatRuntimeSettingsController", () => {
|
||||
describe("createChatRuntimeSettingsActions", () => {
|
||||
it("applies pending runtime overrides through thread settings and commits them", async () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread";
|
||||
const store = createChatStateStore(state);
|
||||
const client = clientFixture();
|
||||
const messages: string[] = [];
|
||||
const controller = new ChatRuntimeSettingsController({
|
||||
const controller = createChatRuntimeSettingsActions({
|
||||
stateStore: store,
|
||||
currentClient: () => client as AppServerClient,
|
||||
runtimeSnapshot: () => ({
|
||||
|
|
@ -107,8 +110,8 @@ function runtimeControllerFixture(
|
|||
store: ReturnType<typeof createChatStateStore>,
|
||||
client: Pick<AppServerClient, "updateThreadSettings">,
|
||||
messages: string[],
|
||||
): ChatRuntimeSettingsController {
|
||||
return new ChatRuntimeSettingsController({
|
||||
): ChatRuntimeSettingsActions {
|
||||
return createChatRuntimeSettingsActions({
|
||||
stateStore: store,
|
||||
currentClient: () => client as AppServerClient,
|
||||
runtimeSnapshot: () => {
|
||||
|
|
@ -3,10 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import type { AppServerClient } from "../../../src/app-server/client";
|
||||
import type { ArchiveExportAdapter } from "../../../src/domain/threads/export";
|
||||
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
|
||||
import {
|
||||
ChatThreadActionController,
|
||||
type ChatThreadActionControllerHost,
|
||||
} from "../../../src/features/chat/threads/thread-actions-controller";
|
||||
import { createChatThreadActions, type ChatThreadActionsHost } from "../../../src/features/chat/threads/thread-actions";
|
||||
import type { DisplayItem } from "../../../src/features/chat/display/types";
|
||||
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
|
|
@ -18,7 +15,7 @@ type MockArchiveExportAdapter = ArchiveExportAdapter & {
|
|||
write: ReturnType<typeof vi.fn<ArchiveExportAdapter["write"]>>;
|
||||
};
|
||||
|
||||
describe("ChatThreadActionController", () => {
|
||||
describe("createChatThreadActions", () => {
|
||||
beforeEach(() => {
|
||||
notices.length = 0;
|
||||
});
|
||||
|
|
@ -26,7 +23,7 @@ describe("ChatThreadActionController", () => {
|
|||
it("requests thread compaction and reports the shared status", async () => {
|
||||
const client = clientMock();
|
||||
const host = hostMock({ client, displayItems: [] });
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.compactThread("source");
|
||||
|
||||
|
|
@ -50,7 +47,7 @@ describe("ChatThreadActionController", () => {
|
|||
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
|
||||
},
|
||||
});
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.archiveThread("source");
|
||||
|
||||
|
|
@ -80,7 +77,7 @@ describe("ChatThreadActionController", () => {
|
|||
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
|
||||
},
|
||||
});
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.archiveThread("source");
|
||||
|
||||
|
|
@ -93,7 +90,7 @@ describe("ChatThreadActionController", () => {
|
|||
it("forks from a selected turn by dropping later turns on the fork", async () => {
|
||||
const client = clientMock();
|
||||
const host = hostMock({ client, displayItems: turnItems() });
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.forkThreadFromTurn("source", "turn-1", false);
|
||||
|
||||
|
|
@ -107,7 +104,7 @@ describe("ChatThreadActionController", () => {
|
|||
it("does not open the fork in a new panel before archiving the source", async () => {
|
||||
const client = clientMock();
|
||||
const host = hostMock({ client, displayItems: turnItems() });
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.forkThreadFromTurn("source", "turn-2", true);
|
||||
|
||||
|
|
@ -131,7 +128,7 @@ describe("ChatThreadActionController", () => {
|
|||
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
|
||||
},
|
||||
});
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.forkThreadFromTurn("source", "turn-3", true);
|
||||
|
||||
|
|
@ -150,7 +147,7 @@ describe("ChatThreadActionController", () => {
|
|||
const client = clientMock();
|
||||
client.archiveThread.mockRejectedValue(new Error("archive failed"));
|
||||
const host = hostMock({ client, displayItems: turnItems() });
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.forkThreadFromTurn("source", "turn-3", true);
|
||||
|
||||
|
|
@ -164,7 +161,7 @@ describe("ChatThreadActionController", () => {
|
|||
it("replaces the source panel before notifying surfaces after fork and archive succeeds", async () => {
|
||||
const client = clientMock();
|
||||
const host = hostMock({ client, displayItems: turnItems() });
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.forkThreadFromTurn("source", "turn-3", true);
|
||||
|
||||
|
|
@ -181,7 +178,7 @@ describe("ChatThreadActionController", () => {
|
|||
const client = clientMock();
|
||||
const host = hostMock({ client, displayItems: turnItems() });
|
||||
host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed"));
|
||||
const controller = new ChatThreadActionController(host);
|
||||
const controller = createChatThreadActions(host);
|
||||
|
||||
await controller.forkThreadFromTurn("source", "turn-3", true);
|
||||
|
||||
|
|
@ -268,7 +265,7 @@ function hostMock({
|
|||
notifyActiveThreadIdentityChanged: vi.fn(),
|
||||
refreshThreads: vi.fn().mockResolvedValue(undefined),
|
||||
refreshSharedThreadListFromOpenSurface: vi.fn(),
|
||||
} satisfies ChatThreadActionControllerHost;
|
||||
} satisfies ChatThreadActionsHost;
|
||||
}
|
||||
|
||||
function archiveAdapterMock(overrides: Partial<MockArchiveExportAdapter> = {}): MockArchiveExportAdapter {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../../src/app-server/client";
|
||||
import { ChatThreadGoalController } from "../../../../src/features/chat/threads/thread-goal-controller";
|
||||
import { createChatThreadGoalActions } from "../../../../src/features/chat/threads/thread-goal-actions";
|
||||
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import type { ThreadGoal } from "../../../../src/generated/app-server/v2/ThreadGoal";
|
||||
|
||||
describe("ChatThreadGoalController", () => {
|
||||
describe("createChatThreadGoalActions", () => {
|
||||
it("syncs the active thread goal into chat state", async () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread";
|
||||
|
|
@ -14,7 +14,7 @@ describe("ChatThreadGoalController", () => {
|
|||
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient;
|
||||
const render = vi.fn();
|
||||
const refreshLiveState = vi.fn();
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -37,7 +37,7 @@ describe("ChatThreadGoalController", () => {
|
|||
const stateStore = createChatStateStore(state);
|
||||
const addSystemMessage = vi.fn();
|
||||
const client = { getThreadGoal: vi.fn().mockRejectedValue(new Error("offline")) } as unknown as AppServerClient;
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -69,7 +69,7 @@ describe("ChatThreadGoalController", () => {
|
|||
} as unknown as AppServerClient;
|
||||
const addSystemMessage = vi.fn();
|
||||
const addGoalEvent = vi.fn();
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -102,7 +102,7 @@ describe("ChatThreadGoalController", () => {
|
|||
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
|
||||
const addSystemMessage = vi.fn();
|
||||
const addGoalEvent = vi.fn();
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -142,7 +142,7 @@ describe("ChatThreadGoalController", () => {
|
|||
const injectThreadItems = vi.fn().mockResolvedValue({});
|
||||
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
|
||||
const addGoalEvent = vi.fn();
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -167,7 +167,7 @@ describe("ChatThreadGoalController", () => {
|
|||
const client = { setThreadGoal } as unknown as AppServerClient;
|
||||
const addSystemMessage = vi.fn();
|
||||
const addGoalEvent = vi.fn();
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -190,7 +190,7 @@ describe("ChatThreadGoalController", () => {
|
|||
const currentGoal = goal();
|
||||
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient;
|
||||
const addSystemMessage = vi.fn();
|
||||
const controller = new ChatThreadGoalController({
|
||||
const controller = createChatThreadGoalActions({
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { AppServerClient } from "../../../../src/app-server/client";
|
||||
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import { ComposerSubmissionController } from "../../../../src/features/chat/turns/composer-submission-controller";
|
||||
import { createComposerSubmissionActions } from "../../../../src/features/chat/turns/composer-submission-actions";
|
||||
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
|
||||
|
||||
function thread(id: string): Thread {
|
||||
|
|
@ -37,7 +37,7 @@ function createController(draft: string) {
|
|||
const setDraft = vi.fn();
|
||||
const sendTurnText = vi.fn().mockResolvedValue(undefined);
|
||||
const execute = vi.fn().mockResolvedValue(undefined);
|
||||
const controller = new ComposerSubmissionController({
|
||||
const controller = createComposerSubmissionActions({
|
||||
stateStore,
|
||||
composer: {
|
||||
get trimmedDraft() {
|
||||
|
|
@ -59,7 +59,7 @@ function createController(draft: string) {
|
|||
return { controller, execute, interruptTurn, sendTurnText, setDraft, stateStore };
|
||||
}
|
||||
|
||||
describe("ComposerSubmissionController", () => {
|
||||
describe("createComposerSubmissionActions", () => {
|
||||
it("sends plain drafts as turn text", async () => {
|
||||
const { controller, sendTurnText } = createController("hello");
|
||||
|
||||
|
|
@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import type { AppServerClient } from "../../../../src/app-server/client";
|
||||
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import {
|
||||
SlashCommandController,
|
||||
type SlashCommandControllerHost,
|
||||
createSlashCommandActions,
|
||||
type SlashCommandActionsHost,
|
||||
type SlashCommandGoalPort,
|
||||
type SlashCommandRuntimePort,
|
||||
type SlashCommandStatusPort,
|
||||
type SlashCommandThreadPort,
|
||||
} from "../../../../src/features/chat/turns/slash-command-controller";
|
||||
} from "../../../../src/features/chat/turns/slash-command-actions";
|
||||
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
|
||||
import type { UserInput } from "../../../../src/generated/app-server/v2/UserInput";
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ function thread(id: string, name: string | null = null): Thread {
|
|||
};
|
||||
}
|
||||
|
||||
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandControllerHost, "threads" | "runtime" | "goals" | "status">> {
|
||||
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandActionsHost, "threads" | "runtime" | "goals" | "status">> {
|
||||
threads?: Partial<SlashCommandThreadPort>;
|
||||
runtime?: Partial<SlashCommandRuntimePort>;
|
||||
goals?: Partial<SlashCommandGoalPort>;
|
||||
|
|
@ -97,7 +97,7 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
|
|||
clear: vi.fn().mockResolvedValue(true),
|
||||
...goalOverrides,
|
||||
};
|
||||
const host: SlashCommandControllerHost = {
|
||||
const host: SlashCommandActionsHost = {
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
codexInput: vi.fn((text: string) => textInput(text)),
|
||||
|
|
@ -110,10 +110,10 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
|
|||
return { compactThread, host, stateStore, threadTurnsList };
|
||||
}
|
||||
|
||||
describe("SlashCommandController", () => {
|
||||
describe("createSlashCommandActions", () => {
|
||||
it("executes slash commands against the current chat state", async () => {
|
||||
const { host } = createHost();
|
||||
const controller = new SlashCommandController(host);
|
||||
const controller = createSlashCommandActions(host);
|
||||
|
||||
const result = await controller.execute("clear", "");
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ describe("SlashCommandController", () => {
|
|||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
const controller = new SlashCommandController(host);
|
||||
const controller = createSlashCommandActions(host);
|
||||
|
||||
await controller.execute("compact", "");
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ describe("SlashCommandController", () => {
|
|||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
const controller = new SlashCommandController(host);
|
||||
const controller = createSlashCommandActions(host);
|
||||
|
||||
await controller.execute("compact", "");
|
||||
|
||||
|
|
@ -167,7 +167,7 @@ describe("SlashCommandController", () => {
|
|||
|
||||
it("starts an empty panel before setting a slash command goal", async () => {
|
||||
const { host } = createHost();
|
||||
const controller = new SlashCommandController(host);
|
||||
const controller = createSlashCommandActions(host);
|
||||
|
||||
await controller.execute("goal", "set Ship this");
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ describe("SlashCommandController", () => {
|
|||
|
||||
it("runs reconnect even when there is no current app-server client", async () => {
|
||||
const { host } = createHost({ currentClient: () => null });
|
||||
const controller = new SlashCommandController(host);
|
||||
const controller = createSlashCommandActions(host);
|
||||
|
||||
await controller.execute("reconnect", "");
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ describe("SlashCommandController", () => {
|
|||
type: "thread-list/applied",
|
||||
threads: [thread("other", "Other")],
|
||||
});
|
||||
const controller = new SlashCommandController(host);
|
||||
const controller = createSlashCommandActions(host);
|
||||
|
||||
const result = await controller.execute("refer", "Other summarize");
|
||||
|
||||
Loading…
Reference in a new issue