mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Extract chat render snapshots
This commit is contained in:
parent
770c280147
commit
428d435d01
3 changed files with 209 additions and 124 deletions
139
src/features/chat/view-snapshot.ts
Normal file
139
src/features/chat/view-snapshot.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { Model } from "../../generated/app-server/v2/Model";
|
||||
import type { Thread } from "../../generated/app-server/v2/Thread";
|
||||
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
|
||||
import { currentModel } from "../../runtime/state";
|
||||
import { readRuntimeConfig } from "../../runtime/config";
|
||||
import type { ChatPanelSlotSnapshot } from "./ui/shell";
|
||||
import { activeTurnId, chatTurnBusy, type ChatState } from "./chat-state";
|
||||
import type { DisplayItem } from "./display/types";
|
||||
import { runtimeSnapshotForChatState } from "./view-model";
|
||||
import type { RestoredThreadState } from "./view-lifecycle";
|
||||
|
||||
export function openPanelTurnLifecycle(state: ChatState["turnLifecycle"]): OpenCodexPanelSnapshot["turnLifecycle"] {
|
||||
if (state.kind === "running") return { kind: "running", turnId: state.turnId };
|
||||
if (state.kind === "starting") return { kind: "starting" };
|
||||
return { kind: "idle" };
|
||||
}
|
||||
|
||||
export function latestProposedPlanItem(items: readonly DisplayItem[]): DisplayItem | null {
|
||||
return [...items].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ?? null;
|
||||
}
|
||||
|
||||
export function toolbarSlotSnapshot(state: ChatState, connected: boolean): ChatPanelSlotSnapshot {
|
||||
return signatureParts(
|
||||
state.status,
|
||||
chatTurnBusy(state),
|
||||
state.activeThreadId,
|
||||
activeTurnId(state),
|
||||
state.activeModel,
|
||||
state.activeReasoningEffort,
|
||||
state.activeCollaborationMode,
|
||||
state.activeServiceTier,
|
||||
state.activeApprovalPolicy,
|
||||
state.activeApprovalsReviewer,
|
||||
state.activePermissionProfile,
|
||||
state.requestedCollaborationMode,
|
||||
state.requestedServiceTier,
|
||||
state.requestedApprovalsReviewer,
|
||||
state.requestedModel,
|
||||
state.requestedReasoningEffort,
|
||||
state.runtimePicker,
|
||||
openDetailsSignature(state.openDetails),
|
||||
state.threadsLoaded,
|
||||
threadListSignature(state.listedThreads),
|
||||
modelsSignature(state.availableModels),
|
||||
state.effectiveConfig,
|
||||
state.rateLimit,
|
||||
state.tokenUsage,
|
||||
state.appServerDiagnostics,
|
||||
connected,
|
||||
);
|
||||
}
|
||||
|
||||
export function messagesSlotSnapshot(state: ChatState, pendingRequestsSignature: string): ChatPanelSlotSnapshot {
|
||||
return signatureParts(
|
||||
state.activeThreadId,
|
||||
activeTurnId(state),
|
||||
state.activeThreadCwd,
|
||||
state.historyCursor,
|
||||
state.loadingHistory,
|
||||
chatTurnBusy(state),
|
||||
state.messagesPinnedToBottom,
|
||||
state.composerDraft,
|
||||
state.requestedCollaborationMode,
|
||||
displayItemsSignature(state.displayItems),
|
||||
turnDiffsSignature(state.turnDiffs),
|
||||
openDetailsSignature(state.openDetails),
|
||||
pendingRequestsSignature,
|
||||
);
|
||||
}
|
||||
|
||||
export function composerSlotSnapshot(state: ChatState, activeComposerThreadName: string | null): ChatPanelSlotSnapshot {
|
||||
return signatureParts(
|
||||
state.composerDraft,
|
||||
chatTurnBusy(state),
|
||||
state.activeThreadId,
|
||||
activeTurnId(state),
|
||||
currentModel(runtimeSnapshotForChatState({ state }), readRuntimeConfig(state.effectiveConfig)),
|
||||
state.availableSkills.length,
|
||||
skillsSignature(state.availableSkills),
|
||||
modelsSignature(state.availableModels),
|
||||
threadListSignature(state.listedThreads),
|
||||
activeComposerThreadName,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseRestoredThreadState(state: unknown): RestoredThreadState | null {
|
||||
if (!state || typeof state !== "object") return null;
|
||||
const record = state as Record<string, unknown>;
|
||||
const threadId = record["threadId"];
|
||||
if (typeof threadId !== "string" || threadId.trim().length === 0) return null;
|
||||
const title = record["threadTitle"];
|
||||
return {
|
||||
threadId,
|
||||
title: typeof title === "string" && title.trim().length > 0 ? title : null,
|
||||
explicitName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function signatureParts(...values: unknown[]): string {
|
||||
return values.map((value) => stableSignature(value)).join("\u001f");
|
||||
}
|
||||
|
||||
function stableSignature(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function openDetailsSignature(openDetails: ReadonlySet<string>): string {
|
||||
return [...openDetails].sort().join("\n");
|
||||
}
|
||||
|
||||
function turnDiffsSignature(turnDiffs: ReadonlyMap<string, string>): string {
|
||||
return [...turnDiffs]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([turnId, diff]) => `${turnId}:${diff}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function displayItemsSignature(items: readonly DisplayItem[]): string {
|
||||
return stableSignature(items);
|
||||
}
|
||||
|
||||
function threadListSignature(threads: readonly Thread[]): string {
|
||||
return threads
|
||||
.map((thread) =>
|
||||
signatureParts(thread.id, thread.name, thread.preview, thread.updatedAt, thread.cliVersion, thread.status, thread.gitInfo),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function modelsSignature(models: readonly Model[]): string {
|
||||
return models.map((model) => stableSignature(model)).join("\n");
|
||||
}
|
||||
|
||||
function skillsSignature(skills: ChatState["availableSkills"]): string {
|
||||
return skills.map((skill) => stableSignature(skill)).join("\n");
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ import {
|
|||
} from "../../domain/threads/reference";
|
||||
import { pendingRequestMessageNode } from "./ui/pending-request-message";
|
||||
import { renderToolbar, type ToolbarChoice, type ToolbarViewModel } from "./ui/toolbar";
|
||||
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelSlotSnapshot } from "./ui/shell";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "./ui/shell";
|
||||
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
|
||||
import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer";
|
||||
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
|
||||
|
|
@ -80,6 +80,14 @@ import {
|
|||
statusSummaryLines as buildStatusSummaryLines,
|
||||
toolbarViewModel as buildToolbarViewModel,
|
||||
} from "./view-model";
|
||||
import {
|
||||
composerSlotSnapshot,
|
||||
latestProposedPlanItem,
|
||||
messagesSlotSnapshot,
|
||||
openPanelTurnLifecycle,
|
||||
parseRestoredThreadState,
|
||||
toolbarSlotSnapshot,
|
||||
} from "./view-snapshot";
|
||||
import {
|
||||
ChatViewDeferredTasks,
|
||||
transitionChatConnectionLifecycle,
|
||||
|
|
@ -1219,66 +1227,11 @@ export class CodexChatView extends ItemView {
|
|||
this.renderComposer(parent);
|
||||
};
|
||||
|
||||
private readonly toolbarSnapshot = (state: ChatState): ChatPanelSlotSnapshot =>
|
||||
signatureParts(
|
||||
state.status,
|
||||
chatTurnBusy(state),
|
||||
state.activeThreadId,
|
||||
activeTurnId(state),
|
||||
state.activeModel,
|
||||
state.activeReasoningEffort,
|
||||
state.activeCollaborationMode,
|
||||
state.activeServiceTier,
|
||||
state.activeApprovalPolicy,
|
||||
state.activeApprovalsReviewer,
|
||||
state.activePermissionProfile,
|
||||
state.requestedCollaborationMode,
|
||||
state.requestedServiceTier,
|
||||
state.requestedApprovalsReviewer,
|
||||
state.requestedModel,
|
||||
state.requestedReasoningEffort,
|
||||
state.runtimePicker,
|
||||
openDetailsSignature(state.openDetails),
|
||||
state.threadsLoaded,
|
||||
threadListSignature(state.listedThreads),
|
||||
modelsSignature(state.availableModels),
|
||||
state.effectiveConfig,
|
||||
state.rateLimit,
|
||||
state.tokenUsage,
|
||||
state.appServerDiagnostics,
|
||||
this.connection.isConnected(),
|
||||
);
|
||||
private readonly toolbarSnapshot = (state: ChatState) => toolbarSlotSnapshot(state, this.connection.isConnected());
|
||||
|
||||
private readonly messagesSnapshot = (state: ChatState): ChatPanelSlotSnapshot =>
|
||||
signatureParts(
|
||||
state.activeThreadId,
|
||||
activeTurnId(state),
|
||||
state.activeThreadCwd,
|
||||
state.historyCursor,
|
||||
state.loadingHistory,
|
||||
chatTurnBusy(state),
|
||||
state.messagesPinnedToBottom,
|
||||
state.composerDraft,
|
||||
state.requestedCollaborationMode,
|
||||
displayItemsSignature(state.displayItems),
|
||||
turnDiffsSignature(state.turnDiffs),
|
||||
openDetailsSignature(state.openDetails),
|
||||
this.pendingRequestsSignature(),
|
||||
);
|
||||
private readonly messagesSnapshot = (state: ChatState) => messagesSlotSnapshot(state, this.pendingRequestsSignature());
|
||||
|
||||
private readonly composerSnapshot = (state: ChatState): ChatPanelSlotSnapshot =>
|
||||
signatureParts(
|
||||
state.composerDraft,
|
||||
chatTurnBusy(state),
|
||||
state.activeThreadId,
|
||||
activeTurnId(state),
|
||||
currentModel(this.runtimeSnapshotForState(state), readRuntimeConfig(state.effectiveConfig)),
|
||||
state.availableSkills.length,
|
||||
skillsSignature(state.availableSkills),
|
||||
modelsSignature(state.availableModels),
|
||||
threadListSignature(state.listedThreads),
|
||||
this.activeComposerThreadName(),
|
||||
);
|
||||
private readonly composerSnapshot = (state: ChatState) => composerSlotSnapshot(state, this.activeComposerThreadName());
|
||||
|
||||
private render(options: ChatViewRenderScheduleOptions = {}): void {
|
||||
this.deferredTasks.clearRender();
|
||||
|
|
@ -1646,68 +1599,3 @@ export class CodexChatView extends ItemView {
|
|||
this.composerController.render(parent);
|
||||
}
|
||||
}
|
||||
|
||||
function openPanelTurnLifecycle(state: ChatState["turnLifecycle"]): OpenCodexPanelSnapshot["turnLifecycle"] {
|
||||
if (state.kind === "running") return { kind: "running", turnId: state.turnId };
|
||||
if (state.kind === "starting") return { kind: "starting" };
|
||||
return { kind: "idle" };
|
||||
}
|
||||
|
||||
function latestProposedPlanItem(items: readonly DisplayItem[]): DisplayItem | null {
|
||||
return [...items].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ?? null;
|
||||
}
|
||||
|
||||
function signatureParts(...values: unknown[]): string {
|
||||
return values.map((value) => stableSignature(value)).join("\u001f");
|
||||
}
|
||||
|
||||
function stableSignature(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function openDetailsSignature(openDetails: ReadonlySet<string>): string {
|
||||
return [...openDetails].sort().join("\n");
|
||||
}
|
||||
|
||||
function turnDiffsSignature(turnDiffs: ReadonlyMap<string, string>): string {
|
||||
return [...turnDiffs]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([turnId, diff]) => `${turnId}:${diff}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function displayItemsSignature(items: readonly DisplayItem[]): string {
|
||||
return stableSignature(items);
|
||||
}
|
||||
|
||||
function threadListSignature(threads: readonly Thread[]): string {
|
||||
return threads
|
||||
.map((thread) =>
|
||||
signatureParts(thread.id, thread.name, thread.preview, thread.updatedAt, thread.cliVersion, thread.status, thread.gitInfo),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function modelsSignature(models: readonly Model[]): string {
|
||||
return models.map((model) => stableSignature(model)).join("\n");
|
||||
}
|
||||
|
||||
function skillsSignature(skills: ChatState["availableSkills"]): string {
|
||||
return skills.map((skill) => stableSignature(skill)).join("\n");
|
||||
}
|
||||
|
||||
function parseRestoredThreadState(state: unknown): RestoredThreadState | null {
|
||||
if (!state || typeof state !== "object") return null;
|
||||
const record = state as Record<string, unknown>;
|
||||
const threadId = record["threadId"];
|
||||
if (typeof threadId !== "string" || threadId.trim().length === 0) return null;
|
||||
const title = record["threadTitle"];
|
||||
return {
|
||||
threadId,
|
||||
title: typeof title === "string" && title.trim().length > 0 ? title : null,
|
||||
explicitName: null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
58
tests/features/chat/view-snapshot.test.ts
Normal file
58
tests/features/chat/view-snapshot.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createChatState } from "../../../src/features/chat/chat-state";
|
||||
import {
|
||||
composerSlotSnapshot,
|
||||
latestProposedPlanItem,
|
||||
messagesSlotSnapshot,
|
||||
openPanelTurnLifecycle,
|
||||
parseRestoredThreadState,
|
||||
toolbarSlotSnapshot,
|
||||
} from "../../../src/features/chat/view-snapshot";
|
||||
|
||||
describe("chat view snapshots", () => {
|
||||
it("projects open panel turn lifecycle without exposing full chat state", () => {
|
||||
expect(openPanelTurnLifecycle({ kind: "idle" })).toEqual({ kind: "idle" });
|
||||
expect(openPanelTurnLifecycle({ kind: "starting", pendingTurnStart: { anchorItemId: "local", promptSubmitHookItemIds: [] } })).toEqual({
|
||||
kind: "starting",
|
||||
});
|
||||
expect(openPanelTurnLifecycle({ kind: "running", turnId: "turn" })).toEqual({ kind: "running", turnId: "turn" });
|
||||
});
|
||||
|
||||
it("finds the latest proposed plan item", () => {
|
||||
expect(
|
||||
latestProposedPlanItem([
|
||||
{ id: "first", kind: "message", role: "assistant", text: "plan", proposedPlan: true },
|
||||
{ id: "user", kind: "message", role: "user", text: "ok" },
|
||||
{ id: "latest", kind: "message", role: "assistant", text: "plan", proposedPlan: true },
|
||||
])?.id,
|
||||
).toBe("latest");
|
||||
});
|
||||
|
||||
it("changes slot snapshots only for state each slot cares about", () => {
|
||||
const state = createChatState();
|
||||
const toolbar = toolbarSlotSnapshot(state, false);
|
||||
const messages = messagesSlotSnapshot(state, "");
|
||||
const composer = composerSlotSnapshot(state, null);
|
||||
|
||||
const changedDraft = { ...state, composerDraft: "hello" };
|
||||
expect(toolbarSlotSnapshot(changedDraft, false)).toBe(toolbar);
|
||||
expect(messagesSlotSnapshot(changedDraft, "")).not.toBe(messages);
|
||||
expect(composerSlotSnapshot(changedDraft, null)).not.toBe(composer);
|
||||
|
||||
const changedStatus = { ...state, status: "Connected." };
|
||||
expect(toolbarSlotSnapshot(changedStatus, false)).not.toBe(toolbar);
|
||||
expect(messagesSlotSnapshot(changedStatus, "")).toBe(messages);
|
||||
expect(composerSlotSnapshot(changedStatus, null)).toBe(composer);
|
||||
});
|
||||
|
||||
it("parses restored thread view state defensively", () => {
|
||||
expect(parseRestoredThreadState({ threadId: "thread", threadTitle: "Title" })).toEqual({
|
||||
threadId: "thread",
|
||||
title: "Title",
|
||||
explicitName: null,
|
||||
});
|
||||
expect(parseRestoredThreadState({ threadId: "" })).toBeNull();
|
||||
expect(parseRestoredThreadState(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue