From 32fb1d629c39212c1269bc26d47e15e11b917a14 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 27 Jun 2026 20:40:42 +0900 Subject: [PATCH] Separate workspace panel live-state contracts --- biome.jsonc | 8 +++ ...no-feature-workspace-boundary-imports.grit | 16 +++++ .../no-workspace-chat-internal-imports.grit | 18 +++++ .../threads/restored-thread-lifecycle.ts | 13 ++++ src/features/chat/host/contracts.ts | 15 ++++- src/features/chat/host/session.ts | 12 ++-- .../chat/host/workspace-panel-snapshot.ts | 8 +++ src/features/chat/panel/snapshot.ts | 34 ---------- src/features/threads-view/session.ts | 6 +- src/features/threads-view/state.ts | 51 +++++++------- src/plugin-runtime.ts | 13 +++- src/workspace/panel-coordinator.ts | 19 +++--- .../threads/restored-thread-lifecycle.test.ts | 15 ++++- .../host/workspace-panel-snapshot.test.ts | 13 ++++ tests/features/chat/panel/snapshot.test.ts | 23 ------- tests/features/threads-view/shell.test.tsx | 61 ++++------------- tests/features/threads-view/state.test.ts | 18 ++--- tests/features/threads-view/view.test.ts | 2 +- tests/main.test.ts | 66 +++++++------------ tests/scripts/grit-policy.test.mjs | 63 +++++++++++++++--- 20 files changed, 257 insertions(+), 217 deletions(-) create mode 100644 scripts/lint/no-feature-workspace-boundary-imports.grit create mode 100644 scripts/lint/no-workspace-chat-internal-imports.grit create mode 100644 src/features/chat/host/workspace-panel-snapshot.ts delete mode 100644 src/features/chat/panel/snapshot.ts create mode 100644 tests/features/chat/host/workspace-panel-snapshot.test.ts delete mode 100644 tests/features/chat/panel/snapshot.test.ts diff --git a/biome.jsonc b/biome.jsonc index ed718cc0..35d9a7a0 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -67,6 +67,14 @@ "path": "./scripts/lint/no-chat-workspace-boundary-imports.grit", "includes": ["**/src/features/chat/**/*.ts", "**/src/features/chat/**/*.tsx"] }, + { + "path": "./scripts/lint/no-feature-workspace-boundary-imports.grit", + "includes": ["**/src/features/**/*.ts", "**/src/features/**/*.tsx", "!**/src/features/chat/**"] + }, + { + "path": "./scripts/lint/no-workspace-chat-internal-imports.grit", + "includes": ["**/src/workspace/**/*.ts"] + }, { "path": "./scripts/lint/no-chat-panel-runtime-boundary-imports.grit", "includes": ["**/src/features/chat/panel/**/*.ts", "**/src/features/chat/panel/**/*.tsx"] diff --git a/scripts/lint/no-feature-workspace-boundary-imports.grit b/scripts/lint/no-feature-workspace-boundary-imports.grit new file mode 100644 index 00000000..900d63c2 --- /dev/null +++ b/scripts/lint/no-feature-workspace-boundary-imports.grit @@ -0,0 +1,16 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { $source <: r"^[\"'](?:(?:\.\./)+workspace|src/workspace)(?:/.*)?[\"']$" }, + register_diagnostic(span=$stmt, message="Feature modules must not import workspace modules; pass workspace capabilities through feature host contracts.", severity="error") +} diff --git a/scripts/lint/no-workspace-chat-internal-imports.grit b/scripts/lint/no-workspace-chat-internal-imports.grit new file mode 100644 index 00000000..1131b382 --- /dev/null +++ b/scripts/lint/no-workspace-chat-internal-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:\.\./features/chat/(?:application|app-server|domain|panel|presentation|ui)|src/features/chat/(?:application|app-server|domain|panel|presentation|ui))(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Workspace modules may coordinate chat only through chat host contracts and Obsidian views.", severity="error") +} diff --git a/src/features/chat/application/threads/restored-thread-lifecycle.ts b/src/features/chat/application/threads/restored-thread-lifecycle.ts index 8217a505..bceb7927 100644 --- a/src/features/chat/application/threads/restored-thread-lifecycle.ts +++ b/src/features/chat/application/threads/restored-thread-lifecycle.ts @@ -35,3 +35,16 @@ export function transitionRestoredThreadLifecycle( return state.kind === "idle" ? state : { kind: "idle" }; } } + +export function parseRestoredThreadState(state: unknown): RestoredThreadState | null { + if (!state || typeof state !== "object") return null; + const record = state as Record; + 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, + }; +} diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index aa4547a1..4dcb3e32 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -5,11 +5,11 @@ import type { AppServerQueryContext } from "../../../app-server/query/keys"; import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../../../app-server/query/thread-catalog"; import type { ArchiveExportDestination } from "../../../app-server/services/thread-archive-markdown"; import type { ModelMetadata } from "../../../domain/catalog/metadata"; +import type { PendingRequestCounts } from "../../../domain/pending-requests/aggregate"; import type { SharedServerMetadata } from "../../../domain/server/metadata"; import type { CodexPanelSettings } from "../../../settings/model"; import type { ObservedResultListener } from "../../../shared/query/observed-result"; import type { ChatTurnDiffViewState } from "../domain/turn-diff"; -import type { ChatPanelSnapshot } from "../panel/snapshot"; export interface CodexChatHost { readonly settingsRef: PluginSettingsRef; @@ -70,8 +70,19 @@ export interface ChatViewLifecycleSurface { refreshSettings(): void; } +export type ChatWorkspacePanelTurnLifecycle = { kind: "idle" } | { kind: "starting" } | { kind: "running"; turnId: string }; + +export interface ChatWorkspacePanelSnapshot { + viewId: string; + threadId: string | null; + turnLifecycle: ChatWorkspacePanelTurnLifecycle; + pendingRequests: PendingRequestCounts; + hasComposerDraft: boolean; + connected: boolean; +} + export interface ChatWorkspacePanelSurface { - openPanelSnapshot(): ChatPanelSnapshot; + openPanelSnapshot(): ChatWorkspacePanelSnapshot; openThread(threadId: string): Promise; focusThread(threadId?: string | null): Promise; hydrateRestoredThread(): Promise; diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 7fc2242d..0f57a97d 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -5,14 +5,14 @@ import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/thread import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; import type { ChatState } from "../application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../application/state/store"; -import type { RestoredThreadPlaceholderState } from "../application/threads/restored-thread-lifecycle"; +import { parseRestoredThreadState, type RestoredThreadPlaceholderState } from "../application/threads/restored-thread-lifecycle"; import { ChatResumeWorkTracker } from "../application/threads/resume-work"; import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom"; -import { type ChatPanelSnapshot, openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot"; import { type ChatMessageScrollController, createChatMessageScrollController } from "../panel/surface/message-stream-scroll"; -import type { ChatPanelEnvironment, ChatPanelHandle } from "./contracts"; +import type { ChatPanelEnvironment, ChatPanelHandle, ChatWorkspacePanelSnapshot } from "./contracts"; import { type ChatViewDeferredTasks, createChatViewDeferredTasks } from "./deferred-work"; import { type ChatPanelSessionGraph, createChatPanelSessionGraph } from "./session-graph"; +import { openPanelTurnLifecycle } from "./workspace-panel-snapshot"; export class ChatPanelSession implements ChatPanelHandle { private readonly stateStore: ChatStateStore = createChatStateStore(); @@ -100,15 +100,13 @@ export class ChatPanelSession implements ChatPanelHandle { return result; } - openPanelSnapshot(): ChatPanelSnapshot { + openPanelSnapshot(): ChatWorkspacePanelSnapshot { const pendingRequests = pendingRequestCountsFromQueues(this.state.requests); return { viewId: this.environment.obsidian.viewId, threadId: this.closing ? null : this.panelThreadId(), turnLifecycle: openPanelTurnLifecycle(this.state.turn.lifecycle), - pendingApprovals: pendingRequests.approvals, - pendingUserInputs: pendingRequests.userInputs, - pendingMcpElicitations: pendingRequests.mcpElicitations, + pendingRequests, hasComposerDraft: this.state.composer.draft.trim().length > 0, connected: this.graph.connection.manager.isConnected(), }; diff --git a/src/features/chat/host/workspace-panel-snapshot.ts b/src/features/chat/host/workspace-panel-snapshot.ts new file mode 100644 index 00000000..02d53cce --- /dev/null +++ b/src/features/chat/host/workspace-panel-snapshot.ts @@ -0,0 +1,8 @@ +import type { ChatState } from "../application/state/root-reducer"; +import type { ChatWorkspacePanelTurnLifecycle } from "./contracts"; + +export function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): ChatWorkspacePanelTurnLifecycle { + if (state.kind === "running") return { kind: "running", turnId: state.turnId }; + if (state.kind === "starting") return { kind: "starting" }; + return { kind: "idle" }; +} diff --git a/src/features/chat/panel/snapshot.ts b/src/features/chat/panel/snapshot.ts deleted file mode 100644 index a0ea7635..00000000 --- a/src/features/chat/panel/snapshot.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ChatState } from "../application/state/root-reducer"; -import type { RestoredThreadState } from "../application/threads/restored-thread-lifecycle"; - -type OpenCodexPanelTurnLifecycle = { kind: "idle" } | { kind: "starting" } | { kind: "running"; turnId: string }; - -export interface ChatPanelSnapshot { - viewId: string; - threadId: string | null; - turnLifecycle: OpenCodexPanelTurnLifecycle; - pendingApprovals: number; - pendingUserInputs: number; - pendingMcpElicitations: number; - hasComposerDraft: boolean; - connected: boolean; -} - -export function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): ChatPanelSnapshot["turnLifecycle"] { - if (state.kind === "running") return { kind: "running", turnId: state.turnId }; - if (state.kind === "starting") return { kind: "starting" }; - return { kind: "idle" }; -} - -export function parseRestoredThreadState(state: unknown): RestoredThreadState | null { - if (!state || typeof state !== "object") return null; - const record = state as Record; - 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, - }; -} diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index dec83d31..66147258 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -9,7 +9,6 @@ import type { ArchiveExportSettings } from "../../domain/threads/archive-markdow import type { Thread } from "../../domain/threads/model"; import type { ObservedResult } from "../../shared/query/observed-result"; import { observedInitialError, observedInitialLoading, observedValue } from "../../shared/query/observed-result"; -import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations"; import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service"; import { isThreadsArchiveConfirmPointer, renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom"; @@ -17,6 +16,7 @@ import { type ThreadsGeneratingRenameState, type ThreadsRenameLifecycleEvent, type ThreadsRenameState, + type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState, } from "./state"; @@ -35,7 +35,7 @@ export interface ThreadsViewHost { readonly threadCatalog: ThreadsViewThreadCatalog; openNewPanel(): Promise; openThreadInAvailableView(threadId: string): Promise; - getOpenPanelSnapshots(): OpenCodexPanelSnapshot[]; + openPanelActivities(): readonly ThreadsViewPanelActivity[]; } type ThreadsViewThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink; @@ -210,7 +210,7 @@ export class ThreadsViewSession { loading: this.refreshLifecycle.kind === "loading", rows: threadRows( this.threads, - this.host.getOpenPanelSnapshots(), + this.host.openPanelActivities(), this.renameStates, this.archiveConfirmThreadId, this.host.settings.archiveExportEnabled(), diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index 1f971860..3dadc79f 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -1,6 +1,4 @@ -import { hasPendingRequests, pendingRequestCounts } from "../../domain/pending-requests/aggregate"; import { type Thread, threadRecencyAt } from "../../domain/threads/model"; -import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; import { initialThreadRenameLifecycleState, type ThreadRenameLifecycleEvent as SharedThreadRenameLifecycleEvent, @@ -17,6 +15,13 @@ interface ThreadsLiveState { status: ThreadsLiveStatus; } +export interface ThreadsViewPanelActivity { + threadId: string | null; + selected: boolean; + pending: boolean; + running: boolean; +} + export interface ThreadsRowModel extends ThreadRowCoreProjection { live: ThreadsLiveState | null; } @@ -40,20 +45,20 @@ const STATUS_PRIORITY: Record = { export function threadRows( threads: readonly Thread[], - snapshots: OpenCodexPanelSnapshot[], + panelActivities: readonly ThreadsViewPanelActivity[], renameStates: ReadonlyMap, archiveConfirmThreadId: string | null = null, defaultArchiveSaveMarkdown = false, ): ThreadsRowModel[] { - const snapshotsByThread = snapshotsForThreads(snapshots); + const panelActivitiesByThread = panelActivitiesForThreads(panelActivities); return [...threads] .sort((a, b) => threadRecencyAt(b) - threadRecencyAt(a)) .map((thread) => { - const threadSnapshots = snapshotsByThread.get(thread.id) ?? []; - const live = liveStateForSnapshots(threadSnapshots); + const threadPanelActivities = panelActivitiesByThread.get(thread.id) ?? []; + const live = liveStateForPanelActivities(threadPanelActivities); const core = threadRowCoreProjection({ thread, - selected: threadSnapshots.some((snapshot) => snapshot.threadId !== null && snapshot.lastFocused), + selected: threadPanelActivities.some((activity) => activity.threadId !== null && activity.selected), renameState: renameStates.get(thread.id), archiveConfirmActive: archiveConfirmThreadId === thread.id, defaultArchiveSaveMarkdown, @@ -65,12 +70,14 @@ export function threadRows( }); } -function liveStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): ThreadsLiveState | null { - const liveSnapshots = snapshots.filter((snapshot) => snapshot.threadId !== null); - if (liveSnapshots.length === 0) return null; - const winner = [...liveSnapshots].sort((a, b) => STATUS_PRIORITY[snapshotStatus(b)] - STATUS_PRIORITY[snapshotStatus(a)]).at(0); +function liveStateForPanelActivities(panelActivities: ThreadsViewPanelActivity[]): ThreadsLiveState | null { + const livePanelActivities = panelActivities.filter((activity) => activity.threadId !== null); + if (livePanelActivities.length === 0) return null; + const winner = [...livePanelActivities] + .sort((a, b) => STATUS_PRIORITY[panelActivityStatus(b)] - STATUS_PRIORITY[panelActivityStatus(a)]) + .at(0); if (!winner) return null; - const status = snapshotStatus(winner); + const status = panelActivityStatus(winner); return { status, }; @@ -106,19 +113,19 @@ function activeThreadsRenameState(state: SharedThreadRenameLifecycleState): Thre return state.kind === "idle" ? undefined : state; } -function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map { - const map = new Map(); - for (const snapshot of snapshots) { - if (!snapshot.threadId) continue; - const existing = map.get(snapshot.threadId) ?? []; - existing.push(snapshot); - map.set(snapshot.threadId, existing); +function panelActivitiesForThreads(panelActivities: readonly ThreadsViewPanelActivity[]): Map { + const map = new Map(); + for (const activity of panelActivities) { + if (!activity.threadId) continue; + const existing = map.get(activity.threadId) ?? []; + existing.push(activity); + map.set(activity.threadId, existing); } return map; } -function snapshotStatus(snapshot: OpenCodexPanelSnapshot): ThreadsLiveStatus { - if (hasPendingRequests(pendingRequestCounts(snapshot))) return "pending"; - if (snapshot.turnLifecycle.kind !== "idle") return "running"; +function panelActivityStatus(activity: ThreadsViewPanelActivity): ThreadsLiveStatus { + if (activity.pending) return "pending"; + if (activity.running) return "running"; return "open"; } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 9128daf3..5a464fd2 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -7,6 +7,7 @@ import { type AppServerQueryContext, appServerQueryContextIsComplete } from "./a import { AppServerSharedQueries } from "./app-server/query/shared-queries"; import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./app-server/query/thread-catalog"; import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; +import { hasPendingRequests } from "./domain/pending-requests/aggregate"; import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff"; import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff"; import type { @@ -20,6 +21,7 @@ import type { import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view.obsidian"; import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal.obsidian"; import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session"; +import type { ThreadsViewPanelActivity } from "./features/threads-view/state"; import { CodexThreadsView } from "./features/threads-view/view.obsidian"; import type { CodexPanelSettingTabHost } from "./settings/host"; import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator"; @@ -127,7 +129,7 @@ export class CodexPanelRuntime implements AppServerClientAccess { threadCatalog: this.threadCatalog, openNewPanel: () => this.panels.openNewPanel(), openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId), - getOpenPanelSnapshots: () => this.panels.getOpenPanelSnapshots(), + openPanelActivities: () => this.openPanelActivities(), }; } @@ -227,6 +229,15 @@ export class CodexPanelRuntime implements AppServerClientAccess { } } + private openPanelActivities(): readonly ThreadsViewPanelActivity[] { + return this.panels.getOpenPanelSnapshots().map((snapshot) => ({ + threadId: snapshot.threadId, + selected: snapshot.lastFocused, + pending: hasPendingRequests(snapshot.pendingRequests), + running: snapshot.turnLifecycle.kind !== "idle", + })); + } + private threadsViews(): CodexThreadsView[] { return this.options.app.workspace .getLeavesOfType(VIEW_TYPE_CODEX_THREADS) diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index 0b9b0476..ec7a37d2 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -2,9 +2,8 @@ import type { App, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../constants"; import { hasPendingRequests, pendingRequestCounts } from "../domain/pending-requests/aggregate"; -import type { ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; +import type { ChatWorkspacePanelSnapshot, ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; import { CodexChatView } from "../features/chat/host/view.obsidian"; -import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot"; type ThreadPanelTarget = | { @@ -42,7 +41,7 @@ interface WorkspacePanelReconcileOptions { const ignoreWorkspacePanelLoadError = (): void => undefined; -export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot { +export interface WorkspacePanelSnapshot extends ChatWorkspacePanelSnapshot { lastFocused: boolean; } @@ -127,7 +126,7 @@ export class WorkspacePanelCoordinator { return true; } - getOpenPanelSnapshots(): OpenCodexPanelSnapshot[] { + getOpenPanelSnapshots(): WorkspacePanelSnapshot[] { const leaves = this.panelLeaves(); this.ensureInitialFocusedPanel(leaves); return leaves.flatMap((leaf, index) => { @@ -370,7 +369,7 @@ export class WorkspacePanelCoordinator { if (viewId) this.lastFocusedPanelViewId = viewId; } - private openPanelSnapshotWithFocus(snapshot: ChatPanelSnapshot): OpenCodexPanelSnapshot { + private openPanelSnapshotWithFocus(snapshot: ChatWorkspacePanelSnapshot): WorkspacePanelSnapshot { return { ...snapshot, lastFocused: snapshot.viewId === this.lastFocusedPanelViewId }; } @@ -417,11 +416,11 @@ export class WorkspacePanelCoordinator { } } -function isIdleEmptyPanelSnapshot(snapshot: ChatPanelSnapshot): boolean { +function isIdleEmptyPanelSnapshot(snapshot: ChatWorkspacePanelSnapshot): boolean { return ( snapshot.threadId === null && snapshot.turnLifecycle.kind === "idle" && - !hasPendingRequests(pendingRequestCounts(snapshot)) && + !hasPendingRequests(snapshot.pendingRequests) && !snapshot.hasComposerDraft ); } @@ -441,16 +440,14 @@ function restoredThreadId(leaf: WorkspaceLeaf): string | null { return typeof threadId === "string" && threadId.length > 0 ? threadId : null; } -function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): OpenCodexPanelSnapshot | null { +function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): WorkspacePanelSnapshot | null { const threadId = restoredThreadId(leaf); if (!threadId) return null; return { viewId: `restored:${String(index)}:${threadId}`, threadId, turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, + pendingRequests: pendingRequestCounts({ pendingApprovals: 0, pendingUserInputs: 0, pendingMcpElicitations: 0 }), hasComposerDraft: false, connected: false, lastFocused: false, diff --git a/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts b/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts index ec453421..f64a48f2 100644 --- a/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts +++ b/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { transitionRestoredThreadLifecycle } from "../../../../../src/features/chat/application/threads/restored-thread-lifecycle"; +import { + parseRestoredThreadState, + transitionRestoredThreadLifecycle, +} from "../../../../../src/features/chat/application/threads/restored-thread-lifecycle"; describe("transitionRestoredThreadLifecycle", () => { it("clears restored-thread loading only for the active loading promise", () => { @@ -23,4 +26,14 @@ describe("transitionRestoredThreadLifecycle", () => { loading: null, }); }); + + 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(); + }); }); diff --git a/tests/features/chat/host/workspace-panel-snapshot.test.ts b/tests/features/chat/host/workspace-panel-snapshot.test.ts new file mode 100644 index 00000000..633b64cb --- /dev/null +++ b/tests/features/chat/host/workspace-panel-snapshot.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { openPanelTurnLifecycle } from "../../../../src/features/chat/host/workspace-panel-snapshot"; + +describe("chat workspace panel 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" }); + }); +}); diff --git a/tests/features/chat/panel/snapshot.test.ts b/tests/features/chat/panel/snapshot.test.ts deleted file mode 100644 index 5717ae76..00000000 --- a/tests/features/chat/panel/snapshot.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { openPanelTurnLifecycle, parseRestoredThreadState } from "../../../../src/features/chat/panel/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("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(); - }); -}); diff --git a/tests/features/threads-view/shell.test.tsx b/tests/features/threads-view/shell.test.tsx index 825f98d9..543e7166 100644 --- a/tests/features/threads-view/shell.test.tsx +++ b/tests/features/threads-view/shell.test.tsx @@ -3,8 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; import { renderThreadsViewShell } from "../../../src/features/threads-view/shell.dom"; -import { type ThreadsRowModel, threadRows } from "../../../src/features/threads-view/state"; -import type { OpenCodexPanelSnapshot } from "../../../src/workspace/panel-coordinator"; +import { type ThreadsRowModel, type ThreadsViewPanelActivity, threadRows } from "../../../src/features/threads-view/state"; import { changeInputValue, installObsidianDomShims } from "../../support/dom"; installObsidianDomShims(); @@ -14,29 +13,12 @@ function expectPresent(value: T | null | undefined): T { return value; } -function openPanelSnapshot( - overrides: Partial<{ - viewId: string; - threadId: string | null; - turnLifecycle: OpenCodexPanelSnapshot["turnLifecycle"]; - pendingApprovals: number; - pendingUserInputs: number; - pendingMcpElicitations: number; - hasComposerDraft: boolean; - connected: boolean; - lastFocused: boolean; - }> = {}, -): OpenCodexPanelSnapshot { +function panelActivity(overrides: Partial = {}): ThreadsViewPanelActivity { return { - viewId: "view", threadId: "thread", - lastFocused: false, - turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, - hasComposerDraft: false, - connected: true, + selected: false, + pending: false, + running: false, ...overrides, }; } @@ -88,38 +70,19 @@ describe("threads view renderer decisions", () => { threadRows( [threadFixture({ id: "thread" })], [ - openPanelSnapshot({ viewId: "open", threadId: "thread" }), - openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), - openPanelSnapshot({ viewId: "pending", threadId: "thread", pendingApprovals: 1 }), + panelActivity({ threadId: "thread" }), + panelActivity({ threadId: "thread", running: true }), + panelActivity({ threadId: "thread", pending: true }), ], new Map(), )[0]?.live, ).toMatchObject({ status: "pending" }); - expect( - threadRows( - [threadFixture({ id: "thread" })], - [openPanelSnapshot({ viewId: "draft", threadId: "thread", hasComposerDraft: true })], - new Map(), - )[0]?.live, - ).toMatchObject({ + expect(threadRows([threadFixture({ id: "thread" })], [panelActivity({ threadId: "thread" })], new Map())[0]?.live).toMatchObject({ status: "open", }); expect( - threadRows( - [threadFixture({ id: "thread" })], - [openPanelSnapshot({ viewId: "offline", threadId: "thread", connected: false })], - new Map(), - )[0]?.live, - ).toMatchObject({ - status: "open", - }); - expect( - threadRows( - [threadFixture({ id: "thread" })], - [openPanelSnapshot({ viewId: "none", threadId: null, turnLifecycle: { kind: "running", turnId: "turn" } })], - new Map(), - )[0]?.live, + threadRows([threadFixture({ id: "thread" })], [panelActivity({ threadId: null, running: true })], new Map())[0]?.live, ).toBeNull(); }); @@ -129,7 +92,7 @@ describe("threads view renderer decisions", () => { threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "focused", preview: "Focused thread", updatedAt: 2 }), ], - [openPanelSnapshot({ viewId: "view-focused", threadId: "focused", lastFocused: true })], + [panelActivity({ threadId: "focused", selected: true })], new Map(), ); @@ -142,7 +105,7 @@ describe("threads view renderer decisions", () => { const actions = threadsViewActions(); const rows = threadRows( [threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "open", preview: "Open thread", updatedAt: 2 })], - [openPanelSnapshot({ viewId: "view-open", threadId: "open", pendingApprovals: 1, lastFocused: true })], + [panelActivity({ threadId: "open", pending: true, selected: true })], new Map(), ); diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index 1bbb19e2..0e77a361 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; -import { threadRows, transitionThreadsRenameState } from "../../../src/features/threads-view/state"; -import type { OpenCodexPanelSnapshot } from "../../../src/workspace/panel-coordinator"; +import { type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState } from "../../../src/features/threads-view/state"; describe("threads view rename state", () => { it("maps threads view auto-name events through the shared rename lifecycle", () => { @@ -47,7 +46,7 @@ describe("threads view rename state", () => { }); it("treats pending MCP elicitations as pending live state", () => { - const rows = threadRows([thread()], [openPanelSnapshot({ pendingMcpElicitations: 1 })], new Map()); + const rows = threadRows([thread()], [panelActivity({ pending: true })], new Map()); expect(rows[0]?.live).toMatchObject({ status: "pending" }); }); @@ -65,17 +64,12 @@ function thread(overrides: Partial = {}): Thread { }; } -function openPanelSnapshot(overrides: Partial = {}): OpenCodexPanelSnapshot { +function panelActivity(overrides: Partial = {}): ThreadsViewPanelActivity { return { - viewId: "view", threadId: "thread", - turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, - hasComposerDraft: false, - connected: true, - lastFocused: false, + selected: false, + pending: false, + running: false, ...overrides, }; } diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 7afba607..814ee92d 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -539,7 +539,7 @@ function threadsHost(overrides: Record = {}) { }, openNewPanel: vi.fn().mockResolvedValue(undefined), openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), - getOpenPanelSnapshots: vi.fn(() => []), + openPanelActivities: vi.fn(() => []), threadCatalog: { apply: vi.fn(), loadActive: vi.fn(async () => []), diff --git a/tests/main.test.ts b/tests/main.test.ts index 76c6cb69..a8983ca3 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -107,9 +107,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { { threadId: "thread-1", turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, + pendingRequests: { actionable: 0 }, hasComposerDraft: false, connected: false, lastFocused: false, @@ -122,16 +120,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { const openLeaf = leaf(); openLeaf.view = chatView(CodexChatView, openLeaf); const openView = openLeaf.view as CodexChatView; - vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue({ - viewId: "open-view", - threadId: "thread-1", - turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, - hasComposerDraft: false, - connected: true, - }); + vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" })); vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined); const emptyLeaf = leaf(); emptyLeaf.view = chatView(CodexChatView, emptyLeaf); @@ -150,29 +139,11 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { const busyLeaf = leaf(); busyLeaf.view = chatView(CodexChatView, busyLeaf); const busyView = busyLeaf.view as CodexChatView; - vi.spyOn(busyView.surface, "openPanelSnapshot").mockReturnValue({ - viewId: "busy-view", - threadId: "other-thread", - turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, - hasComposerDraft: false, - connected: true, - }); + vi.spyOn(busyView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "busy-view", threadId: "other-thread" })); const emptyLeaf = leaf(); emptyLeaf.view = chatView(CodexChatView, emptyLeaf); const emptyView = emptyLeaf.view as CodexChatView; - vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue({ - viewId: "empty-view", - threadId: null, - turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, - hasComposerDraft: false, - connected: true, - }); + vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "empty-view", threadId: null })); const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([busyLeaf, emptyLeaf]); @@ -360,9 +331,9 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { activeLeafHandler(secondLeaf); expect(refreshThreadsViewLiveState).toHaveBeenCalledOnce(); - expect(pluginWithThreads.runtime.threadsHost().getOpenPanelSnapshots()).toMatchObject([ - { viewId: "first", lastFocused: false }, - { viewId: "second", lastFocused: true }, + expect(pluginWithThreads.runtime.threadsHost().openPanelActivities()).toMatchObject([ + { threadId: "thread-1", selected: false }, + { threadId: "thread-2", selected: true }, ]); }); @@ -898,18 +869,27 @@ function thread(id: string): Thread { }; } -function panelSnapshot( - overrides: Partial> = {}, -): ReturnType { +function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType { + const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, ...snapshotOverrides } = overrides; + const pendingRequests = snapshotOverrides.pendingRequests ?? { + approvals: pendingApprovals ?? 0, + userInputs: pendingUserInputs ?? 0, + mcpElicitations: pendingMcpElicitations ?? 0, + actionable: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0), + }; return { viewId: "view", threadId: "thread", turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - pendingMcpElicitations: 0, + pendingRequests, hasComposerDraft: false, connected: true, - ...overrides, + ...snapshotOverrides, }; } + +type PanelSnapshotFixtureOverrides = Partial> & { + pendingApprovals?: number; + pendingUserInputs?: number; + pendingMcpElicitations?: number; +}; diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 421d172d..a43b1ab2 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -27,6 +27,9 @@ const CHAT_APPLICATION_OUTER_LAYER_MESSAGE = const CHAT_APP_SERVER_OUTER_LAYER_MESSAGE = "Chat app-server adapters must not import chat host, panel, presentation, or UI layers."; const CHAT_WORKSPACE_BOUNDARY_MESSAGE = "Chat modules must not import workspace modules; pass workspace capabilities through chat host contracts."; +const FEATURE_WORKSPACE_BOUNDARY_MESSAGE = + "Feature modules must not import workspace modules; pass workspace capabilities through feature host contracts."; +const WORKSPACE_CHAT_INTERNAL_MESSAGE = "Workspace modules may coordinate chat only through chat host contracts and Obsidian views."; const CHAT_PANEL_RUNTIME_BOUNDARY_MESSAGE = "Chat panel modules must not import app-server adapters or chat host internals."; const CHAT_PRESENTATION_OUTER_LAYER_MESSAGE = "Chat presentation modules must stay pure view-model projection; keep application, app-server, host, panel, and UI dependencies outward."; @@ -402,6 +405,8 @@ export function timestamp(): number { "no-chat-application-outer-layer-imports.grit", "no-chat-app-server-outer-layer-imports.grit", "no-chat-workspace-boundary-imports.grit", + "no-feature-workspace-boundary-imports.grit", + "no-workspace-chat-internal-imports.grit", "no-chat-panel-runtime-boundary-imports.grit", "no-chat-presentation-outer-layer-imports.grit", "no-chat-ui-outer-layer-imports.grit", @@ -411,11 +416,11 @@ export function timestamp(): number { ` import type { Host } from "../host/contracts"; import type { ChatThreadHistoryPage } from "../app-server/threads/projection"; -import type { PanelSnapshot } from "../panel/snapshot"; +import type { ToolbarPanelActions } from "../panel/toolbar-actions"; import { statusText } from "../presentation/runtime/status"; import { Toolbar } from "../ui/toolbar"; -export type Escape = ChatThreadHistoryPage | Host | PanelSnapshot; +export type Escape = ChatThreadHistoryPage | Host | ToolbarPanelActions; export const values = [statusText, Toolbar] satisfies unknown[]; `.trimStart(), ); @@ -439,11 +444,11 @@ export type Escape = AppServerClient; path.join(cwd, "src/features/chat/app-server/outer.ts"), ` import type { Host } from "../host/contracts"; -import type { PanelSnapshot } from "../panel/snapshot"; +import type { ToolbarPanelActions } from "../panel/toolbar-actions"; import { statusText } from "../presentation/runtime/status"; import { Toolbar } from "../ui/toolbar"; -export type Escape = Host | PanelSnapshot; +export type Escape = Host | ToolbarPanelActions; export const values = [statusText, Toolbar] satisfies unknown[]; `.trimStart(), ); @@ -471,6 +476,40 @@ export type Escape = WorkspacePanelCoordinator; import type { ChatStateStore } from "../application/state/store"; export type Allowed = ChatStateStore; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/threads-view/workspace-escape.ts"), + ` +import type { WorkspacePanelCoordinator } from "../../workspace/panel-coordinator"; + +export type Escape = WorkspacePanelCoordinator; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/threads-view/workspace-allowed.ts"), + ` +import type { Thread } from "../../domain/threads/model"; + +export type Allowed = Thread; +`.trimStart(), + ); + await mkdir(path.join(cwd, "src/workspace"), { recursive: true }); + await writeFile( + path.join(cwd, "src/workspace/chat-internal-escape.ts"), + ` +import type { ToolbarPanelActions } from "../features/chat/panel/toolbar-actions"; + +export type Escape = ToolbarPanelActions; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/workspace/chat-host-allowed.ts"), + ` +import type { ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; +import type { CodexChatView } from "../features/chat/host/view.obsidian"; + +export type Allowed = ChatWorkspacePanelSurface | CodexChatView; `.trimStart(), ); await writeFile( @@ -500,10 +539,10 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import type { ChatStateStore } from "../application/state/store"; import type { ChatServerActionsHost } from "../app-server/actions/host"; import type { Host } from "../host/contracts"; -import type { PanelSnapshot } from "../panel/snapshot"; +import type { ToolbarPanelActions } from "../panel/toolbar-actions"; import { Toolbar } from "../ui/toolbar"; -export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | PanelSnapshot; +export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | ToolbarPanelActions; export const toolbar = Toolbar; `.trimStart(), ); @@ -523,9 +562,9 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import type { ChatStateStore } from "../application/state/store"; import type { ChatServerActionsHost } from "../app-server/actions/host"; import type { Host } from "../host/contracts"; -import type { PanelSnapshot } from "../panel/snapshot"; +import type { ToolbarPanelActions } from "../panel/toolbar-actions"; -export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | PanelSnapshot; +export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | ToolbarPanelActions; `.trimStart(), ); await writeFile( @@ -549,6 +588,10 @@ export const value = statusText; "src/features/chat/app-server/allowed.ts", "src/features/chat/host/workspace-escape.ts", "src/features/chat/host/workspace-allowed.ts", + "src/features/threads-view/workspace-escape.ts", + "src/features/threads-view/workspace-allowed.ts", + "src/workspace/chat-internal-escape.ts", + "src/workspace/chat-host-allowed.ts", "src/features/chat/panel/outer.tsx", "src/features/chat/panel/allowed.tsx", "src/features/chat/presentation/outer.ts", @@ -570,6 +613,10 @@ export const value = statusText; expect(pluginDiagnostics(report, "src/features/chat/app-server/allowed.ts")).toEqual([]); expect(pluginMessages(report, "src/features/chat/host/workspace-escape.ts")).toEqual([CHAT_WORKSPACE_BOUNDARY_MESSAGE]); expect(pluginDiagnostics(report, "src/features/chat/host/workspace-allowed.ts")).toEqual([]); + expect(pluginMessages(report, "src/features/threads-view/workspace-escape.ts")).toEqual([FEATURE_WORKSPACE_BOUNDARY_MESSAGE]); + expect(pluginDiagnostics(report, "src/features/threads-view/workspace-allowed.ts")).toEqual([]); + expect(pluginMessages(report, "src/workspace/chat-internal-escape.ts")).toEqual([WORKSPACE_CHAT_INTERNAL_MESSAGE]); + expect(pluginDiagnostics(report, "src/workspace/chat-host-allowed.ts")).toEqual([]); expect(pluginMessages(report, "src/features/chat/panel/outer.tsx")).toEqual( Array.from({ length: 3 }, () => CHAT_PANEL_RUNTIME_BOUNDARY_MESSAGE), );