mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Continue state-machine cleanup
This commit is contained in:
parent
b1ddd7891b
commit
0aa20089a8
6 changed files with 264 additions and 80 deletions
|
|
@ -100,17 +100,20 @@ interface ClientResponseByMethod {
|
|||
|
||||
type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
||||
|
||||
type AppServerClientLifecycleState =
|
||||
| { kind: "disconnected" }
|
||||
| { kind: "starting"; transport: AppServerTransport }
|
||||
| { kind: "initialized"; transport: AppServerTransport; initializeResponse: InitializeResponse };
|
||||
|
||||
function toUserInput(input: string | UserInput[]): UserInput[] {
|
||||
if (typeof input !== "string") return input;
|
||||
return [{ type: "text", text: input, text_elements: [] }];
|
||||
}
|
||||
|
||||
export class AppServerClient {
|
||||
private transport: AppServerTransport | null = null;
|
||||
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
|
||||
private nextId = 1;
|
||||
private pending = new Map<RequestId, PendingRequest>();
|
||||
private initialized = false;
|
||||
private initResponse: InitializeResponse | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly codexPath: string,
|
||||
|
|
@ -121,7 +124,7 @@ export class AppServerClient {
|
|||
) {}
|
||||
|
||||
async connect(): Promise<InitializeResponse> {
|
||||
if (this.transport?.isRunning()) {
|
||||
if (this.activeTransport()?.isRunning()) {
|
||||
throw new Error("Codex app-server is already running.");
|
||||
}
|
||||
|
||||
|
|
@ -134,16 +137,16 @@ export class AppServerClient {
|
|||
this.rejectAll(error);
|
||||
},
|
||||
onExit: (code, signal) => {
|
||||
this.initialized = false;
|
||||
this.initResponse = null;
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
|
||||
this.handlers.onExit(code, signal);
|
||||
},
|
||||
};
|
||||
this.transport = this.transportFactory
|
||||
const transport = this.transportFactory
|
||||
? this.transportFactory(transportHandlers)
|
||||
: new StdioAppServerTransport(this.codexPath, this.cwd, transportHandlers);
|
||||
this.transport.start();
|
||||
this.lifecycle = { kind: "starting", transport };
|
||||
transport.start();
|
||||
|
||||
const init = await this.request("initialize", {
|
||||
clientInfo: {
|
||||
|
|
@ -157,26 +160,23 @@ export class AppServerClient {
|
|||
},
|
||||
});
|
||||
this.notify({ method: "initialized" });
|
||||
this.initialized = true;
|
||||
this.initResponse = init;
|
||||
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
|
||||
return init;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.initialized = false;
|
||||
this.initResponse = null;
|
||||
this.transport?.stop();
|
||||
this.transport = null;
|
||||
this.activeTransport()?.stop();
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rejectAll(new Error("Codex app-server disconnected."));
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.initialized && this.transport?.isRunning() === true;
|
||||
return this.lifecycle.kind === "initialized" && this.lifecycle.transport.isRunning();
|
||||
}
|
||||
|
||||
get initializeResponse(): InitializeResponse {
|
||||
if (!this.initResponse) throw new Error("Codex app-server has not initialized.");
|
||||
return this.initResponse;
|
||||
if (this.lifecycle.kind !== "initialized") throw new Error("Codex app-server has not initialized.");
|
||||
return this.lifecycle.initializeResponse;
|
||||
}
|
||||
|
||||
readEffectiveConfig(cwd: string): Promise<ConfigReadResponse> {
|
||||
|
|
@ -438,10 +438,11 @@ export class AppServerClient {
|
|||
}
|
||||
|
||||
private send(message: RpcOutboundMessage): void {
|
||||
if (!this.transport?.isRunning()) {
|
||||
const transport = this.activeTransport();
|
||||
if (!transport?.isRunning()) {
|
||||
throw new Error("Codex app-server is not running.");
|
||||
}
|
||||
this.transport.send(message);
|
||||
transport.send(message);
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
|
|
@ -492,4 +493,8 @@ export class AppServerClient {
|
|||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private activeTransport(): AppServerTransport | null {
|
||||
return this.lifecycle.kind === "disconnected" ? null : this.lifecycle.transport;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,13 +44,17 @@ interface SelectionRewriteElements {
|
|||
type SelectionRewriteGenerationRunState = { kind: "idle" } | { kind: "running"; abortController: AbortController };
|
||||
type ActiveSelectionRewriteGenerationRun = Extract<SelectionRewriteGenerationRunState, { kind: "running" }>;
|
||||
|
||||
interface SelectionRewritePopoverStatusState {
|
||||
text: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class SelectionRewritePopover {
|
||||
private elements: SelectionRewriteElements | null = null;
|
||||
private generationRun: SelectionRewriteGenerationRunState = { kind: "idle" };
|
||||
private readonly cleanups: Cleanup[] = [];
|
||||
private instructionDraft: string;
|
||||
private statusText = "";
|
||||
private statusActive = false;
|
||||
private status: SelectionRewritePopoverStatusState = { text: "", active: false };
|
||||
|
||||
constructor(private readonly options: SelectionRewritePopoverOptions) {
|
||||
this.instructionDraft = options.state.instruction;
|
||||
|
|
@ -218,8 +222,7 @@ export class SelectionRewritePopover {
|
|||
}
|
||||
|
||||
private setStatus(text: string, options: { active?: boolean } = {}): void {
|
||||
this.statusText = text;
|
||||
this.statusActive = Boolean(options.active);
|
||||
this.status = { text, active: Boolean(options.active) };
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
|
|
@ -298,8 +301,7 @@ export class SelectionRewritePopover {
|
|||
event.preventDefault();
|
||||
this.apply();
|
||||
}}
|
||||
statusActive={this.statusActive}
|
||||
statusText={this.statusText}
|
||||
status={this.status}
|
||||
streamPreview={state.streamText.trim()}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -345,8 +347,7 @@ interface SelectionRewritePopoverViewProps {
|
|||
onGenerate: () => void;
|
||||
onInstructionInput: (value: string) => void;
|
||||
onInstructionKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
statusActive: boolean;
|
||||
statusText: string;
|
||||
status: SelectionRewritePopoverStatusState;
|
||||
streamPreview: string;
|
||||
}
|
||||
|
||||
|
|
@ -365,8 +366,7 @@ function SelectionRewritePopoverView({
|
|||
onGenerate,
|
||||
onInstructionInput,
|
||||
onInstructionKeyDown,
|
||||
statusActive,
|
||||
statusText,
|
||||
status,
|
||||
streamPreview,
|
||||
}: SelectionRewritePopoverViewProps): ReactNode {
|
||||
return (
|
||||
|
|
@ -399,7 +399,7 @@ function SelectionRewritePopoverView({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectionRewriteStatus text={statusText} active={statusActive} />
|
||||
<SelectionRewriteStatus status={status} />
|
||||
<pre className={`codex-panel-selection-rewrite__stream-preview${streamPreview ? "" : " is-hidden"}`}>{streamPreview}</pre>
|
||||
<div className={`codex-panel-selection-rewrite__result-row${hasReplacement ? "" : " is-hidden"}`}>
|
||||
<div className="codex-panel-selection-rewrite__diff">{diff ? <SelectionRewriteDiff diff={diff} /> : null}</div>
|
||||
|
|
@ -425,7 +425,8 @@ function SelectionRewritePopoverView({
|
|||
);
|
||||
}
|
||||
|
||||
function SelectionRewriteStatus({ text, active }: { text: string; active: boolean }): ReactNode {
|
||||
function SelectionRewriteStatus({ status }: { status: SelectionRewritePopoverStatusState }): ReactNode {
|
||||
const { text, active } = status;
|
||||
if (!text && !active) return <div className="codex-panel-selection-rewrite__status" />;
|
||||
return (
|
||||
<div className={`codex-panel-selection-rewrite__status${active ? " is-active" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ export type SettingsDataRefreshLifecycleState = { kind: "idle" } | { kind: "load
|
|||
|
||||
export type SettingsDataRefreshLifecycleEvent = { type: "started" } | { type: "completed"; failedCount: number };
|
||||
|
||||
export type SettingsDynamicSectionLifecycleState =
|
||||
| { kind: "idle"; status: "" }
|
||||
| { kind: "loading"; status: string; operationId: number }
|
||||
| { kind: "loaded"; status: string; operationId: number }
|
||||
| { kind: "failed"; status: string; operationId: number };
|
||||
|
||||
export type SettingsDynamicSectionLifecycleEvent =
|
||||
| { type: "started"; status: string; operationId: number }
|
||||
| { type: "loaded"; status: string; operationId: number }
|
||||
| { type: "failed"; status: string; operationId: number }
|
||||
| { type: "reset" };
|
||||
|
||||
export function transitionSettingsDataRefreshLifecycle(
|
||||
state: SettingsDataRefreshLifecycleState,
|
||||
event: SettingsDataRefreshLifecycleEvent,
|
||||
|
|
@ -48,6 +60,32 @@ export function settingsDataRefreshLoading(state: SettingsDataRefreshLifecycleSt
|
|||
return state.kind === "loading";
|
||||
}
|
||||
|
||||
export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState {
|
||||
return { kind: "idle", status: "" };
|
||||
}
|
||||
|
||||
export function transitionSettingsDynamicSectionLifecycle(
|
||||
state: SettingsDynamicSectionLifecycleState,
|
||||
event: SettingsDynamicSectionLifecycleEvent,
|
||||
): SettingsDynamicSectionLifecycleState {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
return { kind: "loading", status: event.status, operationId: event.operationId };
|
||||
case "loaded":
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
|
||||
return { kind: "loaded", status: event.status, operationId: event.operationId };
|
||||
case "failed":
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
|
||||
return { kind: "failed", status: event.status, operationId: event.operationId };
|
||||
case "reset":
|
||||
return createSettingsDynamicSectionLifecycle();
|
||||
}
|
||||
}
|
||||
|
||||
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationId: number): boolean {
|
||||
return "operationId" in state && state.operationId > operationId;
|
||||
}
|
||||
|
||||
export async function loadSettingsData(client: AppServerClient, cwd: string): Promise<SettingsDataLoad> {
|
||||
const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([
|
||||
client.listModels(false),
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supporte
|
|||
import { archivedThreadDisplayTitle } from "../domain/threads/model";
|
||||
import { errorMessage } from "../utils";
|
||||
import {
|
||||
createSettingsDynamicSectionLifecycle,
|
||||
loadHookData,
|
||||
loadSettingsData,
|
||||
settingsDataRefreshLoading,
|
||||
transitionSettingsDynamicSectionLifecycle,
|
||||
transitionSettingsDataRefreshLifecycle,
|
||||
type SettingsDataRefreshLifecycleState,
|
||||
} from "./data";
|
||||
|
|
@ -36,39 +38,18 @@ function settingsDataRefreshStatus(state: SettingsDataRefreshLifecycleState): st
|
|||
return "";
|
||||
}
|
||||
|
||||
type SettingsDynamicSectionLifecycleState =
|
||||
| { kind: "idle"; status: "" }
|
||||
| { kind: "loading"; status: string }
|
||||
| { kind: "loaded"; status: string }
|
||||
| { kind: "failed"; status: string };
|
||||
|
||||
function idleSettingsDynamicSection(): SettingsDynamicSectionLifecycleState {
|
||||
return { kind: "idle", status: "" };
|
||||
}
|
||||
|
||||
function loadingSettingsDynamicSection(status: string): SettingsDynamicSectionLifecycleState {
|
||||
return { kind: "loading", status };
|
||||
}
|
||||
|
||||
function loadedSettingsDynamicSection(status: string): SettingsDynamicSectionLifecycleState {
|
||||
return { kind: "loaded", status };
|
||||
}
|
||||
|
||||
function failedSettingsDynamicSection(status: string): SettingsDynamicSectionLifecycleState {
|
||||
return { kind: "failed", status };
|
||||
}
|
||||
|
||||
export class CodexPanelSettingTab extends PluginSettingTab {
|
||||
private settingsDataAutoLoadStarted = false;
|
||||
private settingsDynamicOperationId = 0;
|
||||
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
|
||||
private archivedThreads: Thread[] = [];
|
||||
private archivedThreadsLifecycle = idleSettingsDynamicSection();
|
||||
private archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle();
|
||||
private hooks: HookMetadata[] = [];
|
||||
private hookWarnings: string[] = [];
|
||||
private hookErrors: string[] = [];
|
||||
private hooksLifecycle = idleSettingsDynamicSection();
|
||||
private hooksLifecycle = createSettingsDynamicSectionLifecycle();
|
||||
private models: Model[] = [];
|
||||
private modelsLifecycle = idleSettingsDynamicSection();
|
||||
private modelsLifecycle = createSettingsDynamicSectionLifecycle();
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
|
|
@ -260,10 +241,23 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private async refreshSettingsData(): Promise<void> {
|
||||
const operationId = this.nextSettingsDynamicOperationId();
|
||||
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, { type: "started" });
|
||||
this.modelsLifecycle = loadingSettingsDynamicSection("Loading models...");
|
||||
this.archivedThreadsLifecycle = loadingSettingsDynamicSection("Loading archived threads...");
|
||||
this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks...");
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading models...",
|
||||
operationId,
|
||||
});
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading archived threads...",
|
||||
operationId,
|
||||
});
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading hooks...",
|
||||
operationId,
|
||||
});
|
||||
this.display();
|
||||
|
||||
let failedCount = 0;
|
||||
|
|
@ -273,35 +267,71 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
if (result.models.ok) {
|
||||
this.models = result.models.data;
|
||||
this.plugin.publishModels(result.models.data);
|
||||
this.modelsLifecycle = loadedSettingsDynamicSection(result.models.status);
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "loaded",
|
||||
status: result.models.status,
|
||||
operationId,
|
||||
});
|
||||
} else {
|
||||
failedCount += 1;
|
||||
this.modelsLifecycle = failedSettingsDynamicSection(result.models.status);
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "failed",
|
||||
status: result.models.status,
|
||||
operationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.hooks.ok) {
|
||||
this.hooks = result.hooks.data.hooks;
|
||||
this.hookWarnings = result.hooks.data.warnings;
|
||||
this.hookErrors = result.hooks.data.errors;
|
||||
this.hooksLifecycle = loadedSettingsDynamicSection(result.hooks.status);
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: result.hooks.status,
|
||||
operationId,
|
||||
});
|
||||
} else {
|
||||
failedCount += 1;
|
||||
this.hooksLifecycle = failedSettingsDynamicSection(result.hooks.status);
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: result.hooks.status,
|
||||
operationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.archivedThreads.ok) {
|
||||
this.archivedThreads = result.archivedThreads.data;
|
||||
this.archivedThreadsLifecycle = loadedSettingsDynamicSection(result.archivedThreads.status);
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "loaded",
|
||||
status: result.archivedThreads.status,
|
||||
operationId,
|
||||
});
|
||||
} else {
|
||||
failedCount += 1;
|
||||
this.archivedThreadsLifecycle = failedSettingsDynamicSection(result.archivedThreads.status);
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "failed",
|
||||
status: result.archivedThreads.status,
|
||||
operationId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failedCount = 3;
|
||||
const message = errorMessage(error);
|
||||
this.modelsLifecycle = failedSettingsDynamicSection(`Could not load models: ${message}`);
|
||||
this.hooksLifecycle = failedSettingsDynamicSection(`Could not load hooks: ${message}`);
|
||||
this.archivedThreadsLifecycle = failedSettingsDynamicSection(`Could not load archived threads: ${message}`);
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load models: ${message}`,
|
||||
operationId,
|
||||
});
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load hooks: ${message}`,
|
||||
operationId,
|
||||
});
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load archived threads: ${message}`,
|
||||
operationId,
|
||||
});
|
||||
} finally {
|
||||
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
|
||||
type: "completed",
|
||||
|
|
@ -319,16 +349,29 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private async loadHooks(): Promise<void> {
|
||||
this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks...");
|
||||
const operationId = this.nextSettingsDynamicOperationId();
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading hooks...",
|
||||
operationId,
|
||||
});
|
||||
this.display();
|
||||
try {
|
||||
const hooks = await this.withSettingsConnection((client) => loadHookData(client, this.plugin.vaultPath));
|
||||
this.hooks = hooks.hooks;
|
||||
this.hookWarnings = hooks.warnings;
|
||||
this.hookErrors = hooks.errors;
|
||||
this.hooksLifecycle = loadedSettingsDynamicSection(hooks.status);
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: hooks.status,
|
||||
operationId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.hooksLifecycle = failedSettingsDynamicSection(`Could not load hooks: ${errorMessage(error)}`);
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load hooks: ${errorMessage(error)}`,
|
||||
operationId,
|
||||
});
|
||||
new Notice("Could not load Codex hooks.");
|
||||
} finally {
|
||||
this.display();
|
||||
|
|
@ -336,28 +379,54 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private async trustHook(hook: HookMetadata): Promise<void> {
|
||||
this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks...");
|
||||
const operationId = this.nextSettingsDynamicOperationId();
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading hooks...",
|
||||
operationId,
|
||||
});
|
||||
this.display();
|
||||
try {
|
||||
await this.withSettingsConnection((client) => client.trustHook(hook));
|
||||
this.hooksLifecycle = loadedSettingsDynamicSection("Trusted hook definition.");
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: "Trusted hook definition.",
|
||||
operationId,
|
||||
});
|
||||
await this.loadHooks();
|
||||
} catch (error) {
|
||||
this.hooksLifecycle = failedSettingsDynamicSection(`Could not trust hook: ${errorMessage(error)}`);
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not trust hook: ${errorMessage(error)}`,
|
||||
operationId,
|
||||
});
|
||||
new Notice("Could not trust Codex hook.");
|
||||
this.display();
|
||||
}
|
||||
}
|
||||
|
||||
private async setHookEnabled(hook: HookMetadata, enabled: boolean): Promise<void> {
|
||||
this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks...");
|
||||
const operationId = this.nextSettingsDynamicOperationId();
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading hooks...",
|
||||
operationId,
|
||||
});
|
||||
this.display();
|
||||
try {
|
||||
await this.withSettingsConnection((client) => client.setHookEnabled(hook, enabled));
|
||||
this.hooksLifecycle = loadedSettingsDynamicSection(enabled ? "Enabled hook." : "Disabled hook.");
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: enabled ? "Enabled hook." : "Disabled hook.",
|
||||
operationId,
|
||||
});
|
||||
await this.loadHooks();
|
||||
} catch (error) {
|
||||
this.hooksLifecycle = failedSettingsDynamicSection(`Could not update hook: ${errorMessage(error)}`);
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not update hook: ${errorMessage(error)}`,
|
||||
operationId,
|
||||
});
|
||||
new Notice("Could not update Codex hook.");
|
||||
this.display();
|
||||
}
|
||||
|
|
@ -385,15 +454,28 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private async restoreArchivedThread(threadId: string): Promise<void> {
|
||||
this.archivedThreadsLifecycle = loadingSettingsDynamicSection("Loading archived threads...");
|
||||
const operationId = this.nextSettingsDynamicOperationId();
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading archived threads...",
|
||||
operationId,
|
||||
});
|
||||
this.display();
|
||||
try {
|
||||
const response = await this.withSettingsConnection((client) => client.unarchiveThread(threadId));
|
||||
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
|
||||
this.archivedThreadsLifecycle = loadedSettingsDynamicSection(`Restored "${archivedThreadDisplayTitle(response.thread)}".`);
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "loaded",
|
||||
status: `Restored "${archivedThreadDisplayTitle(response.thread)}".`,
|
||||
operationId,
|
||||
});
|
||||
this.plugin.refreshSharedThreadListFromOpenSurface();
|
||||
} catch (error) {
|
||||
this.archivedThreadsLifecycle = failedSettingsDynamicSection(`Could not restore archived thread: ${errorMessage(error)}`);
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not restore archived thread: ${errorMessage(error)}`,
|
||||
operationId,
|
||||
});
|
||||
new Notice("Could not restore archived Codex thread.");
|
||||
} finally {
|
||||
this.display();
|
||||
|
|
@ -404,6 +486,11 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
return withAppServerConnection(this.plugin.settings.codexPath, this.plugin.vaultPath, operation);
|
||||
}
|
||||
|
||||
private nextSettingsDynamicOperationId(): number {
|
||||
this.settingsDynamicOperationId += 1;
|
||||
return this.settingsDynamicOperationId;
|
||||
}
|
||||
|
||||
private modelOptions(): Model[] {
|
||||
return sortedAvailableModels(this.models);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ class FakeTransport implements AppServerTransport {
|
|||
emitLine(message: unknown): void {
|
||||
this.handlers.onLine(JSON.stringify(message));
|
||||
}
|
||||
|
||||
emitExit(code: number | null = 0, signal: NodeJS.Signals | null = null): void {
|
||||
this.running = false;
|
||||
this.handlers.onExit(code, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> {
|
||||
|
|
@ -148,6 +153,29 @@ describe("AppServerClient", () => {
|
|||
expect(serverRequests[0]?.method).toBe("item/commandExecution/requestApproval");
|
||||
});
|
||||
|
||||
it("exposes initialized state through a single connection lifecycle", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
expect(client.isConnected()).toBe(true);
|
||||
expect(client.initializeResponse).toMatchObject({ codexHome: "/tmp/codex" });
|
||||
|
||||
transport.emitExit(0);
|
||||
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(() => client.initializeResponse).toThrow("Codex app-server has not initialized.");
|
||||
});
|
||||
|
||||
it("clears initialized state and rejects pending requests on disconnect", async () => {
|
||||
const { client } = await connectedClient();
|
||||
const listing = client.listModels();
|
||||
|
||||
client.disconnect();
|
||||
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(() => client.initializeResponse).toThrow("Codex app-server has not initialized.");
|
||||
await expect(listing).rejects.toThrow("Codex app-server disconnected.");
|
||||
});
|
||||
|
||||
it("sends typed turn steering requests", async () => {
|
||||
let transport: FakeTransport;
|
||||
const getTransport = () => transport;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { AppServerClient } from "../../src/app-server/client";
|
||||
import {
|
||||
createSettingsDynamicSectionLifecycle,
|
||||
loadHookData,
|
||||
loadSettingsData,
|
||||
settingsDataRefreshLoading,
|
||||
transitionSettingsDynamicSectionLifecycle,
|
||||
transitionSettingsDataRefreshLifecycle,
|
||||
} from "../../src/settings/data";
|
||||
|
||||
|
|
@ -22,6 +24,29 @@ describe("settings data", () => {
|
|||
expect(settingsDataRefreshLoading(completed)).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks dynamic section lifecycle", () => {
|
||||
const idle = createSettingsDynamicSectionLifecycle();
|
||||
expect(idle).toEqual({ kind: "idle", status: "" });
|
||||
|
||||
const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationId: 1 });
|
||||
expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationId: 1 });
|
||||
|
||||
expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationId: 0 })).toBe(loading);
|
||||
|
||||
const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationId: 1 });
|
||||
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationId: 1 });
|
||||
|
||||
const failed = transitionSettingsDynamicSectionLifecycle(loaded, { type: "failed", status: "Could not load hooks.", operationId: 1 });
|
||||
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationId: 1 });
|
||||
|
||||
const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationId: 2 });
|
||||
expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationId: 1 })).toBe(
|
||||
laterLoaded,
|
||||
);
|
||||
|
||||
expect(transitionSettingsDynamicSectionLifecycle(failed, { type: "reset" })).toEqual(idle);
|
||||
});
|
||||
|
||||
it("uses only hook rows for the requested cwd", async () => {
|
||||
const client = {
|
||||
listHooks: vi.fn().mockResolvedValue({
|
||||
|
|
|
|||
Loading…
Reference in a new issue