Inline thin chat host and thread surface adapters

This commit is contained in:
murashit 2026-06-15 19:02:03 +09:00
parent de913e9d9d
commit 3737dcf0bd
15 changed files with 142 additions and 261 deletions

View file

@ -1,37 +0,0 @@
import type { CodexPanelSettings } from "../../../../settings/model";
import type { SharedThreadCatalog } from "../../../../workspace/shared-thread-catalog";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
export interface CodexChatHost {
readonly settingsRef: PluginSettingsRef;
readonly workspace: WorkspacePanels;
readonly threadCatalog: ThreadCatalogFacade;
}
export interface PluginSettingsRef {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
}
export interface WorkspacePanels {
openThreadInNewView(threadId: string): Promise<unknown>;
focusThreadInOpenView(threadId: string): Promise<boolean>;
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
}
export type ThreadCatalogFacade = Pick<
SharedThreadCatalog,
| "archiveThreadInCatalog"
| "renameThreadInCatalog"
| "refreshThreadsViewLiveState"
| "refreshFromOpenSurface"
| "setActiveThreads"
| "setAppServerMetadata"
| "fetchActiveThreads"
| "activeThreadsSnapshot"
| "appServerMetadataSnapshot"
| "modelsSnapshot"
| "observeActiveThreads"
| "observeAppServerMetadata"
| "observeModels"
>;

View file

@ -5,16 +5,20 @@ import type { GoalActions } from "./goal-actions";
import { createSelectionActions } from "./selection-actions";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/store";
import type { PluginSettingsRef, ThreadCatalogFacade, WorkspacePanels } from "../ports/chat-host";
import type { AutoTitleController } from "./auto-title-controller";
import { ThreadRenameEditorController } from "./rename-editor-controller";
import { createThreadManagementActions, type ThreadManagementActions, type ThreadManagementActionsHost } from "./thread-management-actions";
import { createThreadLifecycleParts } from "./lifecycle-parts";
interface ThreadPartsContext {
settingsRef: PluginSettingsRef;
workspace: Pick<WorkspacePanels, "focusThreadInOpenView" | "openThreadInNewView">;
threadCatalog: Pick<ThreadCatalogFacade, "refreshFromOpenSurface">;
settingsRef: { readonly vaultPath: string };
workspace: {
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
openThreadInNewView: (threadId: string) => Promise<unknown>;
};
threadCatalog: {
refreshFromOpenSurface(): void;
};
state: {
stateStore: ChatStateStore;
};
@ -134,7 +138,9 @@ export function createThreadParts(context: ThreadPartsContext) {
}
interface ThreadSelectionActionsContext {
workspace: Pick<WorkspacePanels, "focusThreadInOpenView">;
workspace: {
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
};
state: {
stateStore: ChatStateStore;
};

View file

@ -2,7 +2,6 @@ import type { AppServerClient } from "../../../../app-server/connection/client";
import { recoverRolloutTokenUsage } from "../../../../app-server/services/rollout-token-usage";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/store";
import type { PluginSettingsRef } from "../ports/chat-host";
import type { GoalActions } from "./goal-actions";
import { HistoryController } from "./history-controller";
import { createIdentitySync } from "./identity-sync";
@ -10,7 +9,7 @@ import { ResumeController } from "./resume-controller";
import { RestorationController } from "./restoration-controller";
export interface ThreadLifecyclePartsContext {
settingsRef: PluginSettingsRef;
settingsRef: { readonly vaultPath: string };
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;

View file

@ -6,14 +6,14 @@ import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import { activeTurnId } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
import type { ChatPanelComposerShellState } from "../panel/shell-state";
import type { PluginSettingsRef } from "../application/ports/chat-host";
import type { CodexPanelSettings } from "../../../settings/model";
import type { ChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import { ChatComposerController } from "../panel/composer-controller";
import type { ChatPanelComposerProjection } from "../panel/surface/model";
export interface ConversationComposerContext {
app: App;
settingsRef: PluginSettingsRef;
settingsRef: { readonly settings: CodexPanelSettings };
stateStore: ChatStateStore;
viewId: string;
surface: {

View file

@ -1,8 +1,9 @@
import { Notice } from "obsidian";
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import type { ThreadCatalogFacade } from "../application/ports/chat-host";
import type { ChatViewDeferredTasks } from "../application/lifecycle";
import { ChatConnectionController, handleChatConnectionExit } from "../application/connection/connection-controller";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
@ -42,10 +43,13 @@ export interface ChatConnectionBundleContext {
vaultPath: string;
connectionWork: ConnectionWorkTracker;
deferredTasks: ChatViewDeferredTasks;
threadCatalog: Pick<
ThreadCatalogFacade,
"setActiveThreads" | "setAppServerMetadata" | "fetchActiveThreads" | "archiveThreadInCatalog" | "renameThreadInCatalog"
>;
threadCatalog: {
setActiveThreads(threads: readonly Thread[]): void;
setAppServerMetadata(metadata: SharedServerMetadata): void;
fetchActiveThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
archiveThreadInCatalog(threadId: string): void;
renameThreadInCatalog(threadId: string, name: string | null): void;
};
goalSync: ThreadGoalSyncActions;
autoTitle: AutoTitleController;
status: ChatConnectionBundleStatus;

View file

@ -8,7 +8,7 @@ import type { GoalActions } from "../application/threads/goal-actions";
import type { HistoryController } from "../application/threads/history-controller";
import type { ChatInboundController } from "../app-server/inbound/controller";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import type { PluginSettingsRef, WorkspacePanels } from "../application/ports/chat-host";
import type { ChatTurnDiffViewState } from "../domain/turn-diff";
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import type { ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
import type { ConversationComposerParts } from "./composer";
@ -20,8 +20,10 @@ interface ConversationPartsContext {
owner: Component;
viewId: string;
};
settingsRef: PluginSettingsRef;
workspace: WorkspacePanels;
settingsRef: { readonly vaultPath: string };
workspace: {
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
};
state: {
stateStore: ChatStateStore;
};

View file

@ -2,7 +2,9 @@ import { Notice, type App, type Component, type EventRef } from "obsidian";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown";
import type { CodexPanelSettings } from "../../../settings/model";
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import type { SharedThreadCatalog } from "../../../workspace/shared-thread-catalog";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import type { ChatConnectionPhase, ChatAction, ChatState } from "../application/state/root-reducer";
@ -34,9 +36,43 @@ import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/me
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
import { ThreadOperations } from "../../threads/thread-operations";
import { ThreadTitleService } from "../../threads/thread-title-service";
import type { CodexChatHost } from "../application/ports/chat-host";
import { messageStreamItems } from "../application/state/message-stream";
import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context";
import type { ChatTurnDiffViewState } from "../domain/turn-diff";
export interface CodexChatHost {
readonly settingsRef: PluginSettingsRef;
readonly workspace: WorkspacePanels;
readonly threadCatalog: ChatThreadCatalog;
}
export interface PluginSettingsRef {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
}
interface WorkspacePanels {
openThreadInNewView(threadId: string): Promise<unknown>;
focusThreadInOpenView(threadId: string): Promise<boolean>;
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
}
type ChatThreadCatalog = Pick<
SharedThreadCatalog,
| "archiveThreadInCatalog"
| "renameThreadInCatalog"
| "refreshThreadsViewLiveState"
| "refreshFromOpenSurface"
| "setActiveThreads"
| "setAppServerMetadata"
| "fetchActiveThreads"
| "activeThreadsSnapshot"
| "appServerMetadataSnapshot"
| "modelsSnapshot"
| "observeActiveThreads"
| "observeAppServerMetadata"
| "observeModels"
>;
export interface ChatPanelEnvironment {
obsidian: {

View file

@ -1,7 +1,7 @@
import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
import type { CodexChatHost } from "../application/ports/chat-host";
import type { CodexChatHost } from "./runtime";
import { ChatPanelSession } from "./session";
import type { ChatSurfaceHandle } from "./surface-handle";

View file

@ -3,15 +3,14 @@ import type { App } from "obsidian";
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { AppServerQueryCache } from "./app-server/query/cache";
import type { AppServerQueryContext } from "./app-server/query/keys";
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/application/ports/chat-host";
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/host/runtime";
import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view";
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal";
import type { CodexThreadsHost, CodexThreadsView } from "./features/threads-view/view";
import { CodexThreadsView, type CodexThreadsHost } from "./features/threads-view/view";
import type { CodexPanelSettingTabHost } from "./settings/tab";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
import { createThreadSurfaceActions, type ThreadSurfaceActions } from "./workspace/thread-surface-actions";
import { SharedThreadCatalog } from "./workspace/shared-thread-catalog";
export interface CodexPanelRuntimeOptions {
@ -23,7 +22,6 @@ export interface CodexPanelRuntimeOptions {
export class CodexPanelRuntime {
private readonly appServerQueries = new AppServerQueryCache();
readonly panels: WorkspacePanelCoordinator;
private readonly threadSurfaces: ThreadSurfaceActions;
readonly threadCatalog: SharedThreadCatalog;
constructor(private readonly options: CodexPanelRuntimeOptions) {
@ -33,13 +31,22 @@ export class CodexPanelRuntime {
this.threadCatalog.refreshThreadsViewLiveState();
},
});
this.threadSurfaces = createThreadSurfaceActions({
app: options.app,
panels: this.panels,
});
this.threadCatalog = new SharedThreadCatalog({
cache: this.appServerQueries,
surfaces: this.threadSurfaces,
surfaces: {
invalidateThreadsFromOpenSurface: () => {
this.invalidateThreadsFromOpenSurface();
},
applyThreadArchived: (threadId, archiveOptions) => {
this.applyThreadArchived(threadId, archiveOptions);
},
applyThreadRenamed: (threadId, name) => {
this.applyThreadRenamed(threadId, name);
},
refreshThreadsViewLiveState: () => {
this.refreshThreadsViewLiveState();
},
},
context: () => this.appServerQueryContext(),
});
}
@ -117,7 +124,7 @@ export class CodexPanelRuntime {
vaultPath: this.options.settingsRef.vaultPath,
saveSettings: () => this.options.saveSettings(),
refreshOpenViews: () => {
this.threadSurfaces.refreshOpenViews();
this.refreshOpenViews();
},
threadCatalog: this.threadCatalog,
};
@ -145,6 +152,51 @@ export class CodexPanelRuntime {
await this.options.app.workspace.revealLeaf(leaf);
}
private refreshOpenViews(): void {
for (const view of this.panels.panelViews()) {
view.surface.refreshSettings();
}
}
private invalidateThreadsFromOpenSurface(): void {
const chatView = this.panels.panelViews().find((view) => view.surface.openPanelSnapshot().connected);
if (chatView) {
void chatView.surface.refreshSharedThreadList();
return;
}
const threadsView = this.threadsViews().at(0);
if (threadsView) void threadsView.refresh();
}
private applyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
const leavesToClose = archiveOptions.closeOpenPanels ? this.panels.panelLeavesForThread(threadId) : [];
for (const view of this.panels.panelViews()) {
view.surface.applyThreadArchived(threadId);
}
for (const leaf of leavesToClose) {
leaf.detach();
}
}
private applyThreadRenamed(threadId: string, name: string | null): void {
for (const view of this.panels.panelViews()) {
view.surface.applyThreadRenamed(threadId, name);
}
}
private refreshThreadsViewLiveState(): void {
for (const view of this.threadsViews()) {
view.refreshLiveState();
}
}
private threadsViews(): CodexThreadsView[] {
return this.options.app.workspace
.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
}
private appServerQueryContext(): AppServerQueryContext {
return {
codexPath: this.options.settingsRef.settings.codexPath,

View file

@ -3,7 +3,13 @@ import type { SharedServerMetadata } from "../domain/server/metadata";
import type { Thread } from "../domain/threads/model";
import type { AppServerQueryCache } from "../app-server/query/cache";
import { appServerQueryContextMatches, cloneAppServerQueryContext, type AppServerQueryContext } from "../app-server/query/keys";
import type { ThreadSurfaceActions } from "./thread-surface-actions";
interface ThreadSurfaceActions {
invalidateThreadsFromOpenSurface(): void;
applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
applyThreadRenamed(threadId: string, name: string | null): void;
refreshThreadsViewLiveState(): void;
}
export interface SharedThreadCatalogOptions {
cache: AppServerQueryCache;

View file

@ -1,68 +0,0 @@
import type { App } from "obsidian";
import { VIEW_TYPE_CODEX_THREADS } from "../constants";
import { CodexThreadsView } from "../features/threads-view/view";
import type { WorkspacePanelCoordinator } from "./panel-coordinator";
export interface ThreadSurfaceActionsOptions {
app: App;
panels: WorkspacePanelCoordinator;
}
export interface ThreadSurfaceActions {
refreshOpenViews(): void;
invalidateThreadsFromOpenSurface(): void;
applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
applyThreadRenamed(threadId: string, name: string | null): void;
refreshThreadsViewLiveState(): void;
}
export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions): ThreadSurfaceActions {
const threadsViews = (): CodexThreadsView[] =>
options.app.workspace
.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
const invalidateThreadsFromOpenSurface = (): void => {
const chatView = options.panels.panelViews().find((view) => view.surface.openPanelSnapshot().connected);
if (chatView) {
void chatView.surface.refreshSharedThreadList();
return;
}
const threadsView = threadsViews().at(0);
if (threadsView) void threadsView.refresh();
};
return {
refreshOpenViews(): void {
for (const view of options.panels.panelViews()) {
view.surface.refreshSettings();
}
},
invalidateThreadsFromOpenSurface,
applyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : [];
for (const view of options.panels.panelViews()) {
view.surface.applyThreadArchived(threadId);
}
for (const leaf of leavesToClose) {
leaf.detach();
}
},
applyThreadRenamed(threadId: string, name: string | null): void {
for (const view of options.panels.panelViews()) {
view.surface.applyThreadRenamed(threadId, name);
}
},
refreshThreadsViewLiveState(): void {
for (const view of threadsViews()) {
view.refreshLiveState();
}
},
};
}

View file

@ -3,7 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import type { CodexChatHost } from "../../../src/features/chat/application/ports/chat-host";
import type { CodexChatHost } from "../../../src/features/chat/host/runtime";
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
import type { Thread } from "../../../src/domain/threads/model";
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";

View file

@ -7,7 +7,7 @@ import { VIEW_TYPE_CODEX_PANEL } from "../src/constants";
import { DEFAULT_SETTINGS } from "../src/settings/model";
import type CodexPanelPlugin from "../src/main";
import type { CodexChatView } from "../src/features/chat/host/view";
import type { CodexChatHost } from "../src/features/chat/application/ports/chat-host";
import type { CodexChatHost } from "../src/features/chat/host/runtime";
import type { Thread } from "../src/domain/threads/model";
import type { WorkspacePanelCoordinator } from "../src/workspace/panel-coordinator";
import type { SharedThreadCatalog } from "../src/workspace/shared-thread-catalog";

View file

@ -6,12 +6,14 @@ import { createServerDiagnostics } from "../../src/domain/server/diagnostics";
import type { SharedServerMetadata } from "../../src/domain/server/metadata";
import type { Thread } from "../../src/domain/threads/model";
import { SharedThreadCatalog } from "../../src/workspace/shared-thread-catalog";
import type { ThreadSurfaceActions } from "../../src/workspace/thread-surface-actions";
type MockSurfaceActions = ThreadSurfaceActions & {
interface MockSurfaceActions {
refreshOpenViews: Mock<() => void>;
invalidateThreadsFromOpenSurface: Mock<() => void>;
applyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>;
applyThreadRenamed: Mock<(threadId: string, name: string | null) => void>;
};
refreshThreadsViewLiveState: Mock<() => void>;
}
describe("SharedThreadCatalog", () => {
it("applies thread snapshots to the shared cache and active observers", () => {

View file

@ -1,121 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { VIEW_TYPE_CODEX_THREADS } from "../../src/constants";
import { CodexThreadsView } from "../../src/features/threads-view/view";
import { createThreadSurfaceActions } from "../../src/workspace/thread-surface-actions";
import type { OpenCodexPanelSnapshot } from "../../src/workspace/open-panel-snapshot";
describe("createThreadSurfaceActions", () => {
it("falls back to the threads view when no connected chat panel can refresh shared threads", () => {
const disconnectedPanelRefresh = vi.fn().mockResolvedValue(undefined);
const threadsRefresh = vi.fn().mockResolvedValue(undefined);
const threadSurfaces = createThreadSurfaceActions({
app: {
workspace: {
getLeavesOfType: vi.fn((type: string) =>
type === VIEW_TYPE_CODEX_THREADS ? [{ view: threadsView({ refresh: threadsRefresh }) }] : [],
),
},
} as never,
panels: {
panelViews: () => [
{
surface: {
openPanelSnapshot: () => panelSnapshot({ connected: false }),
refreshSharedThreadList: disconnectedPanelRefresh,
},
},
],
} as never,
});
threadSurfaces.invalidateThreadsFromOpenSurface();
expect(disconnectedPanelRefresh).not.toHaveBeenCalled();
expect(threadsRefresh).toHaveBeenCalledOnce();
});
it("uses a connected chat panel before falling back to the threads view", () => {
const disconnectedPanelRefresh = vi.fn().mockResolvedValue(undefined);
const connectedPanelRefresh = vi.fn().mockResolvedValue(undefined);
const threadsRefresh = vi.fn().mockResolvedValue(undefined);
const threadSurfaces = createThreadSurfaceActions({
app: {
workspace: {
getLeavesOfType: vi.fn((type: string) =>
type === VIEW_TYPE_CODEX_THREADS ? [{ view: threadsView({ refresh: threadsRefresh }) }] : [],
),
},
} as never,
panels: {
panelViews: () => [
{
surface: {
openPanelSnapshot: () => panelSnapshot({ viewId: "disconnected", connected: false }),
refreshSharedThreadList: disconnectedPanelRefresh,
},
},
{
surface: {
openPanelSnapshot: () => panelSnapshot({ viewId: "connected", connected: true }),
refreshSharedThreadList: connectedPanelRefresh,
},
},
],
} as never,
});
threadSurfaces.invalidateThreadsFromOpenSurface();
expect(disconnectedPanelRefresh).not.toHaveBeenCalled();
expect(connectedPanelRefresh).toHaveBeenCalledOnce();
expect(threadsRefresh).not.toHaveBeenCalled();
});
it("applies known thread mutations without refreshing thread lists", () => {
const panel = {
surface: {
openPanelSnapshot: () => panelSnapshot({ connected: true }),
refreshSharedThreadList: vi.fn().mockResolvedValue(undefined),
applyThreadArchived: vi.fn(),
applyThreadRenamed: vi.fn(),
},
};
const threadSurfaces = createThreadSurfaceActions({
app: {
workspace: {
getLeavesOfType: vi.fn(() => []),
},
} as never,
panels: {
panelViews: () => [panel],
panelLeavesForThread: vi.fn(() => []),
} as never,
});
threadSurfaces.applyThreadRenamed("thread", "Renamed");
threadSurfaces.applyThreadArchived("thread");
expect(panel.surface.applyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed");
expect(panel.surface.applyThreadArchived).toHaveBeenCalledWith("thread");
expect(panel.surface.refreshSharedThreadList).not.toHaveBeenCalled();
});
});
function threadsView(overrides: Partial<CodexThreadsView> = {}): CodexThreadsView {
return Object.assign(Object.create(CodexThreadsView.prototype), overrides) as CodexThreadsView;
}
function panelSnapshot(overrides: Partial<OpenCodexPanelSnapshot> = {}): OpenCodexPanelSnapshot {
return {
viewId: "panel",
threadId: "thread",
lastFocused: false,
turnLifecycle: { kind: "idle" },
pendingApprovals: 0,
pendingUserInputs: 0,
hasComposerDraft: false,
connected: true,
...overrides,
};
}