Separate workspace panel live-state contracts

This commit is contained in:
murashit 2026-06-27 20:40:42 +09:00
parent 56b4c7d0b9
commit 32fb1d629c
20 changed files with 257 additions and 217 deletions

View file

@ -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"]

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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<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,
};
}

View file

@ -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<void>;
focusThread(threadId?: string | null): Promise<void>;
hydrateRestoredThread(): Promise<void>;

View file

@ -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(),
};

View file

@ -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" };
}

View file

@ -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<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,
};
}

View file

@ -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<unknown>;
openThreadInAvailableView(threadId: string): Promise<void>;
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(),

View file

@ -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<ThreadsLiveStatus, number> = {
export function threadRows(
threads: readonly Thread[],
snapshots: OpenCodexPanelSnapshot[],
panelActivities: readonly ThreadsViewPanelActivity[],
renameStates: ReadonlyMap<string, ThreadsRenameState>,
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<string, OpenCodexPanelSnapshot[]> {
const map = new Map<string, OpenCodexPanelSnapshot[]>();
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<string, ThreadsViewPanelActivity[]> {
const map = new Map<string, ThreadsViewPanelActivity[]>();
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";
}

View file

@ -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)

View file

@ -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,

View file

@ -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();
});
});

View file

@ -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" });
});
});

View file

@ -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();
});
});

View file

@ -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<T>(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> = {}): 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(),
);

View file

@ -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> = {}): Thread {
};
}
function openPanelSnapshot(overrides: Partial<OpenCodexPanelSnapshot> = {}): OpenCodexPanelSnapshot {
function panelActivity(overrides: Partial<ThreadsViewPanelActivity> = {}): 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,
};
}

View file

@ -539,7 +539,7 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
},
openNewPanel: vi.fn().mockResolvedValue(undefined),
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
getOpenPanelSnapshots: vi.fn(() => []),
openPanelActivities: vi.fn(() => []),
threadCatalog: {
apply: vi.fn(),
loadActive: vi.fn(async () => []),

View file

@ -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<CodexChatView["surface"]["openPanelSnapshot"]>> = {},
): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> {
function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> {
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<ReturnType<CodexChatView["surface"]["openPanelSnapshot"]>> & {
pendingApprovals?: number;
pendingUserInputs?: number;
pendingMcpElicitations?: number;
};

View file

@ -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),
);