Functionize stateless chat controllers

This commit is contained in:
murashit 2026-06-08 09:08:11 +09:00
parent 985036e63e
commit 02df0cc4b7
20 changed files with 332 additions and 290 deletions

View file

@ -12,15 +12,15 @@ import type { ThreadRenameController } from "../threads/thread-rename-controller
import type { ToolbarPanelController } from "./toolbar-controller";
import type { AppServerWarmupActions } from "../session/app-server-warmup-controller";
import type { ChatConnectionController } from "../session/connection-controller";
import type { ChatReconnectController } from "../session/reconnect-controller";
import type { ChatReconnectActions } from "../session/reconnect-actions";
import type { PendingRequestController } from "../requests/pending-request-controller";
import { ServerRequestResponder } from "../requests/server-request-responder";
import { createServerRequestActions } from "../requests/server-request-actions";
import type { ComposerSubmissionController } from "../turns/composer-submission-controller";
import type { RestoredThreadController } from "../threads/restored-thread-controller";
import type { ThreadIdentityController } from "../threads/thread-identity-controller";
import type { ThreadIdentityActions } from "../threads/thread-identity-actions";
import type { ThreadResumeController } from "../threads/thread-resume-controller";
import type { ThreadSelectionActions } from "../threads/thread-selection-controller";
import type { ChatViewOpenCloseController } from "./view-open-close-controller";
import type { ChatViewOpenCloseActions } from "./open-close-actions";
import type { ChatViewRenderController } from "./view-render-controller";
import type { ChatViewStateActions } from "./view-state-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
@ -34,7 +34,7 @@ export interface ChatViewControllers {
connection: {
manager: ConnectionManager;
controller: ChatConnectionController;
reconnect: ChatReconnectController;
reconnect: ChatReconnectActions;
warmup: AppServerWarmupActions;
};
inbound: {
@ -50,7 +50,7 @@ export interface ChatViewControllers {
resume: ThreadResumeController;
actions: ChatThreadActionController;
restored: RestoredThreadController;
identity: ThreadIdentityController;
identity: ThreadIdentityActions;
rename: ThreadRenameController;
selection: ThreadSelectionActions;
};
@ -71,7 +71,7 @@ export interface ChatViewControllers {
render: {
controller: ChatViewRenderController;
messages: ChatMessageRenderer;
openClose: ChatViewOpenCloseController;
openClose: ChatViewOpenCloseActions;
viewState: ChatViewStateActions;
};
}
@ -99,7 +99,7 @@ export function createChatViewControllers(ports: ChatPanelContext): ChatViewCont
connection,
goals,
});
const serverRequestResponder = new ServerRequestResponder({
const serverRequestResponder = createServerRequestActions({
currentClient: ports.client.getClient,
});
const controller = createChatInboundController(ports, {

View file

@ -0,0 +1,79 @@
import type { EventRef, WorkspaceLeaf } from "obsidian";
import { unmountChatPanelShell } from "../ui/shell";
export interface ChatViewOpenCloseActionsHost {
setOpened: (opened: boolean) => void;
setClosing: (closing: boolean) => void;
registerEvent: (eventRef: EventRef) => void;
registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void;
isOwnLeaf: (leaf: WorkspaceLeaf | null) => boolean;
scrollMessagesToBottomOnFocus: () => void;
applyCachedSharedAppServerState: () => void;
render: () => void;
scheduleDeferredAppServerWarmup: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredTasks: () => void;
panelRoot: () => HTMLElement | null;
disposeMessages: () => void;
disposeComposer: () => void;
disconnect: () => void;
clearClient: () => void;
refreshLiveState: () => void;
deferRefreshLiveState: () => void;
}
export interface ChatViewOpenCloseActions {
open: () => void;
close: () => void;
}
export function createChatViewOpenCloseActions(host: ChatViewOpenCloseActionsHost): ChatViewOpenCloseActions {
return {
open: () => {
openChatView(host);
},
close: () => {
closeChatView(host);
},
};
}
function openChatView(host: ChatViewOpenCloseActionsHost): void {
host.setOpened(true);
host.setClosing(false);
host.registerComposerNoteIndexInvalidation((eventRef) => {
host.registerEvent(eventRef);
});
host.registerPointerDown((event) => {
host.closeToolbarPanelOnOutsidePointer(event);
});
host.registerActiveLeafChange((leaf) => {
if (host.isOwnLeaf(leaf)) host.scrollMessagesToBottomOnFocus();
});
host.applyCachedSharedAppServerState();
host.render();
host.scheduleDeferredAppServerWarmup();
host.scheduleDeferredRestoredThreadHydration();
}
function closeChatView(host: ChatViewOpenCloseActionsHost): void {
host.setOpened(false);
host.setClosing(true);
host.invalidateConnectionWork();
host.invalidateResumeWork();
host.clearDeferredTasks();
const panelRoot = host.panelRoot();
host.disposeMessages();
host.disposeComposer();
unmountChatPanelShell(panelRoot);
host.disconnect();
host.clearClient();
host.refreshLiveState();
host.deferRefreshLiveState();
}

View file

@ -3,7 +3,7 @@ import type { ChatAppServerMetadataController } from "../app-server/metadata-con
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
import type { ChatComposerController } from "../composer/controller";
import { createAppServerWarmupActions } from "../session/app-server-warmup-controller";
import { ChatViewOpenCloseController } from "./view-open-close-controller";
import { createChatViewOpenCloseActions } from "./open-close-actions";
import { ChatViewRenderController } from "./view-render-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
import { applyCachedSharedAppServerState } from "./cached-app-server-state";
@ -60,7 +60,7 @@ export function createConnectionLifecycleControllerGroup(
connected: () => refs.connection.isConnected(),
ensureConnected: client.ensureConnected,
}),
openCloseController: new ChatViewOpenCloseController({
openCloseController: createChatViewOpenCloseActions({
setOpened: lifecycle.setOpened,
setClosing: lifecycle.setClosing,
registerEvent: obsidian.registerEvent,

View file

@ -1,67 +0,0 @@
import type { EventRef, WorkspaceLeaf } from "obsidian";
import { unmountChatPanelShell } from "../ui/shell";
export interface ChatViewOpenCloseControllerHost {
setOpened: (opened: boolean) => void;
setClosing: (closing: boolean) => void;
registerEvent: (eventRef: EventRef) => void;
registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void;
isOwnLeaf: (leaf: WorkspaceLeaf | null) => boolean;
scrollMessagesToBottomOnFocus: () => void;
applyCachedSharedAppServerState: () => void;
render: () => void;
scheduleDeferredAppServerWarmup: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredTasks: () => void;
panelRoot: () => HTMLElement | null;
disposeMessages: () => void;
disposeComposer: () => void;
disconnect: () => void;
clearClient: () => void;
refreshLiveState: () => void;
deferRefreshLiveState: () => void;
}
export class ChatViewOpenCloseController {
constructor(private readonly host: ChatViewOpenCloseControllerHost) {}
open(): void {
this.host.setOpened(true);
this.host.setClosing(false);
this.host.registerComposerNoteIndexInvalidation((eventRef) => {
this.host.registerEvent(eventRef);
});
this.host.registerPointerDown((event) => {
this.host.closeToolbarPanelOnOutsidePointer(event);
});
this.host.registerActiveLeafChange((leaf) => {
if (this.host.isOwnLeaf(leaf)) this.host.scrollMessagesToBottomOnFocus();
});
this.host.applyCachedSharedAppServerState();
this.host.render();
this.host.scheduleDeferredAppServerWarmup();
this.host.scheduleDeferredRestoredThreadHydration();
}
close(): void {
this.host.setOpened(false);
this.host.setClosing(true);
this.host.invalidateConnectionWork();
this.host.invalidateResumeWork();
this.host.clearDeferredTasks();
const panelRoot = this.host.panelRoot();
this.host.disposeMessages();
this.host.disposeComposer();
unmountChatPanelShell(panelRoot);
this.host.disconnect();
this.host.clearClient();
this.host.refreshLiveState();
this.host.deferRefreshLiveState();
}
}

View file

@ -0,0 +1,40 @@
import type { AppServerClient } from "../../../app-server/client";
type RespondRequestId = Parameters<AppServerClient["respondToServerRequest"]>[0];
type RejectRequestId = Parameters<AppServerClient["rejectServerRequest"]>[0];
export interface ServerRequestActionsHost {
currentClient: () => AppServerClient | null;
}
export interface ServerRequestActions {
respond: (requestId: RespondRequestId, result: unknown) => boolean;
reject: (requestId: RejectRequestId, code: number, message: string) => boolean;
}
export function createServerRequestActions(host: ServerRequestActionsHost): ServerRequestActions {
return {
respond: (requestId, result) => respondToServerRequest(host, requestId, result),
reject: (requestId, code, message) => rejectServerRequest(host, requestId, code, message),
};
}
function respondToServerRequest(host: ServerRequestActionsHost, requestId: RespondRequestId, result: unknown): boolean {
try {
const client = host.currentClient();
client?.respondToServerRequest(requestId, result);
return Boolean(client);
} catch {
return false;
}
}
function rejectServerRequest(host: ServerRequestActionsHost, requestId: RejectRequestId, code: number, message: string): boolean {
try {
const client = host.currentClient();
client?.rejectServerRequest(requestId, code, message);
return Boolean(client);
} catch {
return false;
}
}

View file

@ -1,32 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
type RespondRequestId = Parameters<AppServerClient["respondToServerRequest"]>[0];
type RejectRequestId = Parameters<AppServerClient["rejectServerRequest"]>[0];
export interface ServerRequestResponderHost {
currentClient: () => AppServerClient | null;
}
export class ServerRequestResponder {
constructor(private readonly host: ServerRequestResponderHost) {}
respond(requestId: RespondRequestId, result: unknown): boolean {
try {
const client = this.host.currentClient();
client?.respondToServerRequest(requestId, result);
return Boolean(client);
} catch {
return false;
}
}
reject(requestId: RejectRequestId, code: number, message: string): boolean {
try {
const client = this.host.currentClient();
client?.rejectServerRequest(requestId, code, message);
return Boolean(client);
} catch {
return false;
}
}
}

View file

@ -5,7 +5,7 @@ import { ChatAppServerDiagnosticsController } from "../app-server/diagnostics-co
import { ChatAppServerMetadataController } from "../app-server/metadata-controller";
import { ChatAppServerThreadController } from "../app-server/thread-controller";
import { ChatConnectionController } from "./connection-controller";
import type { ServerRequestResponder } from "../requests/server-request-responder";
import type { ServerRequestActions } from "../requests/server-request-actions";
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import { ChatInboundController } from "../inbound/controller";
@ -59,7 +59,7 @@ export function createChatInboundController(
appServerMetadata: ChatAppServerMetadataController;
appServerDiagnostics: ChatAppServerDiagnosticsController;
threadRename: ThreadRenameController;
serverRequestResponder: ServerRequestResponder;
serverRequestResponder: ServerRequestActions;
},
): ChatInboundController {
const { plugin, thread, render } = context;

View file

@ -0,0 +1,48 @@
import { clearLocalTurnAction, closePanelsAction } from "../chat-state-actions";
import { activeThreadId } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
export interface ChatReconnectActionsHost {
stateStore: ChatStateStore;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredDiagnostics: () => void;
reconnect: () => void;
clearClient: () => void;
setStatus: (status: string) => void;
render: () => void;
ensureConnected: () => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
addSystemMessage: (text: string) => void;
}
export interface ChatReconnectActions {
reconnectPanel: () => Promise<void>;
}
export function createChatReconnectActions(host: ChatReconnectActionsHost): ChatReconnectActions {
return {
reconnectPanel: () => reconnectPanel(host),
};
}
async function reconnectPanel(host: ChatReconnectActionsHost): Promise<void> {
const threadId = activeThreadId(host.stateStore.getState());
host.stateStore.dispatch(closePanelsAction());
host.invalidateConnectionWork();
host.invalidateResumeWork();
host.clearDeferredDiagnostics();
host.reconnect();
host.clearClient();
host.stateStore.dispatch(clearLocalTurnAction());
host.setStatus("Reconnecting...");
host.render();
await host.ensureConnected();
if (!threadId) return;
try {
await host.resumeThread(threadId);
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}

View file

@ -1,42 +0,0 @@
import { clearLocalTurnAction, closePanelsAction } from "../chat-state-actions";
import { activeThreadId } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
export interface ChatReconnectControllerHost {
stateStore: ChatStateStore;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredDiagnostics: () => void;
reconnect: () => void;
clearClient: () => void;
setStatus: (status: string) => void;
render: () => void;
ensureConnected: () => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
addSystemMessage: (text: string) => void;
}
export class ChatReconnectController {
constructor(private readonly host: ChatReconnectControllerHost) {}
async reconnectPanel(): Promise<void> {
const threadId = activeThreadId(this.host.stateStore.getState());
this.host.stateStore.dispatch(closePanelsAction());
this.host.invalidateConnectionWork();
this.host.invalidateResumeWork();
this.host.clearDeferredDiagnostics();
this.host.reconnect();
this.host.clearClient();
this.host.stateStore.dispatch(clearLocalTurnAction());
this.host.setStatus("Reconnecting...");
this.host.render();
await this.host.ensureConnected();
if (!threadId) return;
try {
await this.host.resumeThread(threadId);
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
}

View file

@ -4,12 +4,12 @@ import { ChatRuntimeSettingsController } from "../runtime/runtime-settings-contr
import { ChatThreadActionController } from "./thread-actions-controller";
import { ChatThreadGoalController } from "./thread-goal-controller";
import { ThreadHistoryController } from "./thread-history-controller";
import { ThreadIdentityController } from "./thread-identity-controller";
import { createThreadIdentityActions } from "./thread-identity-actions";
import { ThreadRenameController } from "./thread-rename-controller";
import { ThreadResumeController } from "./thread-resume-controller";
import { createThreadSelectionActions } from "./thread-selection-controller";
import { RestoredThreadController } from "./restored-thread-controller";
import { ChatReconnectController } from "../session/reconnect-controller";
import { createChatReconnectActions } from "../session/reconnect-actions";
import { createChatViewStateActions } from "../panel/view-state-controller";
import { ToolbarPanelController } from "../panel/toolbar-controller";
import type { ChatPanelContext } from "../panel/context";
@ -71,7 +71,7 @@ export function createThreadControllerGroup(
resumeThread: thread.resumeThread,
addSystemMessage: status.addSystemMessage,
});
const reconnectActions = new ChatReconnectController({
const reconnectActions = createChatReconnectActions({
stateStore,
invalidateConnectionWork: lifecycle.invalidateConnectionWork,
invalidateResumeWork: lifecycle.invalidateResumeWork,
@ -145,7 +145,7 @@ export function createThreadControllerGroup(
return response?.dataBase64 ?? "";
}),
});
const threadIdentity = new ThreadIdentityController({
const threadIdentity = createThreadIdentityActions({
stateStore,
restoredThread,
invalidateResumeWork: lifecycle.invalidateResumeWork,

View file

@ -0,0 +1,75 @@
import type { RestoredThreadController } from "./restored-thread-controller";
import { applyThreadListAction, clearActiveThreadAction } from "../chat-state-actions";
import { activeThreadId, listedThreads } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
export interface ThreadIdentityActionsHost {
stateStore: ChatStateStore;
restoredThread: RestoredThreadController;
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshTabHeader: () => void;
refreshLiveState: () => void;
render: () => void;
}
export interface ThreadIdentityActions {
clearActiveThreadContext: () => void;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string | null) => void;
}
export function createThreadIdentityActions(host: ThreadIdentityActionsHost): ThreadIdentityActions {
return {
clearActiveThreadContext: () => {
clearActiveThreadContext(host);
},
notifyThreadArchived: (threadId) => {
notifyThreadArchived(host, threadId);
},
notifyThreadRenamed: (threadId, name) => {
notifyThreadRenamed(host, threadId, name);
},
};
}
function clearActiveThreadContext(host: ThreadIdentityActionsHost): void {
host.invalidateResumeWork();
host.restoredThread.clear();
host.clearDeferredRestoredThreadHydration();
host.stateStore.dispatch(clearActiveThreadAction());
host.resetThreadTurnPresence(false);
host.notifyActiveThreadIdentityChanged();
host.refreshLiveState();
}
function notifyThreadArchived(host: ThreadIdentityActionsHost, threadId: string): void {
if (activeThreadId(host.stateStore.getState()) !== threadId) return;
clearActiveThreadContext(host);
host.render();
}
function notifyThreadRenamed(host: ThreadIdentityActionsHost, threadId: string, name: string | null): void {
let changed = false;
const renamedThreads = listedThreads(host.stateStore.getState()).map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
host.stateStore.dispatch(applyThreadListAction(renamedThreads));
const restoredThread = host.restoredThread.placeholder();
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
host.restoredThread.rename(threadId, name);
changed = true;
}
const activeThreadChanged = activeThreadId(host.stateStore.getState()) === threadId || host.restoredThread.isPending(threadId);
if (!changed && !activeThreadChanged) return;
if (activeThreadChanged) {
host.notifyActiveThreadIdentityChanged();
} else {
host.refreshTabHeader();
}
host.render();
}

View file

@ -1,60 +0,0 @@
import type { RestoredThreadController } from "./restored-thread-controller";
import { applyThreadListAction, clearActiveThreadAction } from "../chat-state-actions";
import { activeThreadId, listedThreads } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
export interface ThreadIdentityControllerHost {
stateStore: ChatStateStore;
restoredThread: RestoredThreadController;
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshTabHeader: () => void;
refreshLiveState: () => void;
render: () => void;
}
export class ThreadIdentityController {
constructor(private readonly host: ThreadIdentityControllerHost) {}
clearActiveThreadContext(): void {
this.host.invalidateResumeWork();
this.host.restoredThread.clear();
this.host.clearDeferredRestoredThreadHydration();
this.host.stateStore.dispatch(clearActiveThreadAction());
this.host.resetThreadTurnPresence(false);
this.host.notifyActiveThreadIdentityChanged();
this.host.refreshLiveState();
}
notifyThreadArchived(threadId: string): void {
if (activeThreadId(this.host.stateStore.getState()) !== threadId) return;
this.clearActiveThreadContext();
this.host.render();
}
notifyThreadRenamed(threadId: string, name: string | null): void {
let changed = false;
const renamedThreads = listedThreads(this.host.stateStore.getState()).map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
this.host.stateStore.dispatch(applyThreadListAction(renamedThreads));
const restoredThread = this.host.restoredThread.placeholder();
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
this.host.restoredThread.rename(threadId, name);
changed = true;
}
const activeThreadChanged =
activeThreadId(this.host.stateStore.getState()) === threadId || this.host.restoredThread.isPending(threadId);
if (!changed && !activeThreadChanged) return;
if (activeThreadChanged) {
this.host.notifyActiveThreadIdentityChanged();
} else {
this.host.refreshTabHeader();
}
this.host.render();
}
}

View file

@ -1,11 +1,11 @@
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
import { ChatComposerController } from "../composer/controller";
import { activeTurnId } from "../chat-state";
import type { ChatReconnectController } from "../session/reconnect-controller";
import type { ChatReconnectActions } from "../session/reconnect-actions";
import { PendingRequestController } from "../requests/pending-request-controller";
import type { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
import { ComposerSubmissionController } from "./composer-submission-controller";
import { PlanImplementationController } from "./plan-implementation-controller";
import { createPlanImplementationActions } from "./plan-implementation-actions";
import { SlashCommandController } from "./slash-command-controller";
import { TurnSubmissionController } from "./turn-submission-controller";
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
@ -25,7 +25,7 @@ export function createTurnControllerGroup(
runtimeSettings: ChatRuntimeSettingsController;
threadActions: ChatThreadActionController;
threadRename: ThreadRenameController;
reconnectActions: ChatReconnectController;
reconnectActions: ChatReconnectActions;
goals: ChatThreadGoalController;
history: ThreadHistoryController;
},
@ -139,7 +139,7 @@ export function createTurnControllerGroup(
effortStatusLines: runtime.effortStatusLines,
},
});
const planImplementation = new PlanImplementationController({
const planImplementation = createPlanImplementationActions({
stateStore,
connection: {
currentClient,

View file

@ -0,0 +1,44 @@
import type { AppServerClient } from "../../../app-server/client";
import { closePanelsAction, setRequestedCollaborationModeDefaultAction } from "../chat-state-actions";
import { activeThreadId, canImplementPlan } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
import type { DisplayItem } from "../display/types";
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
interface PlanImplementationConnectionPort {
currentClient(): AppServerClient | null;
ensureConnected(): Promise<void>;
}
interface PlanImplementationSubmissionPort {
sendTurnText(text: string): Promise<void>;
}
export interface PlanImplementationActionsHost {
stateStore: ChatStateStore;
connection: PlanImplementationConnectionPort;
submission: PlanImplementationSubmissionPort;
}
export interface PlanImplementationActions {
canImplement: (item: DisplayItem) => boolean;
implement: (item: DisplayItem) => Promise<void>;
}
export function createPlanImplementationActions(host: PlanImplementationActionsHost): PlanImplementationActions {
return {
canImplement: (item) => canImplementPlan(host.stateStore.getState(), item),
implement: (item) => implementPlan(host, item),
};
}
async function implementPlan(host: PlanImplementationActionsHost, item: DisplayItem): Promise<void> {
if (!canImplementPlan(host.stateStore.getState(), item)) return;
await host.connection.ensureConnected();
if (!host.connection.currentClient() || !activeThreadId(host.stateStore.getState())) return;
host.stateStore.dispatch(setRequestedCollaborationModeDefaultAction());
host.stateStore.dispatch(closePanelsAction());
await host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}

View file

@ -1,40 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
import { closePanelsAction, setRequestedCollaborationModeDefaultAction } from "../chat-state-actions";
import { activeThreadId, canImplementPlan } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
import type { DisplayItem } from "../display/types";
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
interface PlanImplementationConnectionPort {
currentClient(): AppServerClient | null;
ensureConnected(): Promise<void>;
}
interface PlanImplementationSubmissionPort {
sendTurnText(text: string): Promise<void>;
}
export interface PlanImplementationControllerHost {
stateStore: ChatStateStore;
connection: PlanImplementationConnectionPort;
submission: PlanImplementationSubmissionPort;
}
export class PlanImplementationController {
constructor(private readonly host: PlanImplementationControllerHost) {}
canImplement(item: DisplayItem): boolean {
return canImplementPlan(this.host.stateStore.getState(), item);
}
async implement(item: DisplayItem): Promise<void> {
if (!this.canImplement(item)) return;
await this.host.connection.ensureConnected();
if (!this.host.connection.currentClient() || !activeThreadId(this.host.stateStore.getState())) return;
this.host.stateStore.dispatch(setRequestedCollaborationModeDefaultAction());
this.host.stateStore.dispatch(closePanelsAction());
await this.host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}
}

View file

@ -3,19 +3,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EventRef } from "obsidian";
import {
ChatViewOpenCloseController,
type ChatViewOpenCloseControllerHost,
} from "../../../../src/features/chat/panel/view-open-close-controller";
import { createChatViewOpenCloseActions, type ChatViewOpenCloseActionsHost } from "../../../../src/features/chat/panel/open-close-actions";
import { unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
vi.mock("../../../../src/features/chat/ui/shell", () => ({
unmountChatPanelShell: vi.fn(),
}));
function createHost(overrides: Partial<ChatViewOpenCloseControllerHost> = {}) {
function createHost(overrides: Partial<ChatViewOpenCloseActionsHost> = {}) {
const root = document.createElement("div");
const host: ChatViewOpenCloseControllerHost = {
const host: ChatViewOpenCloseActionsHost = {
setOpened: vi.fn(),
setClosing: vi.fn(),
registerEvent: vi.fn(),
@ -43,10 +40,10 @@ function createHost(overrides: Partial<ChatViewOpenCloseControllerHost> = {}) {
deferRefreshLiveState: vi.fn(),
...overrides,
};
return { controller: new ChatViewOpenCloseController(host), host, root };
return { controller: createChatViewOpenCloseActions(host), host, root };
}
describe("ChatViewOpenCloseController", () => {
describe("createChatViewOpenCloseActions", () => {
beforeEach(() => {
vi.mocked(unmountChatPanelShell).mockClear();
});

View file

@ -1,12 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/client";
import { ServerRequestResponder } from "../../../../src/features/chat/requests/server-request-responder";
import { createServerRequestActions } from "../../../../src/features/chat/requests/server-request-actions";
describe("ServerRequestResponder", () => {
describe("createServerRequestActions", () => {
it("responds through the current app-server client", () => {
const respondToServerRequest = vi.fn();
const responder = new ServerRequestResponder({
const responder = createServerRequestActions({
currentClient: () => ({ respondToServerRequest }) as unknown as AppServerClient,
});
@ -17,7 +17,7 @@ describe("ServerRequestResponder", () => {
it("rejects through the current app-server client", () => {
const rejectServerRequest = vi.fn();
const responder = new ServerRequestResponder({
const responder = createServerRequestActions({
currentClient: () => ({ rejectServerRequest }) as unknown as AppServerClient,
});
@ -27,9 +27,9 @@ describe("ServerRequestResponder", () => {
});
it("reports failure when there is no client or the client throws", () => {
expect(new ServerRequestResponder({ currentClient: () => null }).respond(7, null)).toBe(false);
expect(createServerRequestActions({ currentClient: () => null }).respond(7, null)).toBe(false);
expect(
new ServerRequestResponder({
createServerRequestActions({
currentClient: () =>
({
rejectServerRequest: () => {

View file

@ -1,9 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
import { ChatReconnectController, type ChatReconnectControllerHost } from "../../../../src/features/chat/session/reconnect-controller";
import { createChatReconnectActions, type ChatReconnectActionsHost } from "../../../../src/features/chat/session/reconnect-actions";
function createHost(overrides: Partial<ChatReconnectControllerHost> = {}) {
function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
const stateStore = createChatStateStore(createChatState());
stateStore.dispatch({ type: "ui/panel-set", panel: "history" });
stateStore.dispatch({
@ -17,7 +17,7 @@ function createHost(overrides: Partial<ChatReconnectControllerHost> = {}) {
approvalsReviewer: null,
activePermissionProfile: null,
});
const host: ChatReconnectControllerHost = {
const host: ChatReconnectActionsHost = {
stateStore,
invalidateConnectionWork: vi.fn(),
invalidateResumeWork: vi.fn(),
@ -34,10 +34,10 @@ function createHost(overrides: Partial<ChatReconnectControllerHost> = {}) {
return { host, stateStore };
}
describe("ChatReconnectController", () => {
describe("createChatReconnectActions", () => {
it("resets local connection state before reconnecting and resumes the active thread", async () => {
const { host, stateStore } = createHost();
const controller = new ChatReconnectController(host);
const controller = createChatReconnectActions(host);
await controller.reconnectPanel();
@ -57,7 +57,7 @@ describe("ChatReconnectController", () => {
const { host } = createHost({
resumeThread: vi.fn().mockRejectedValue(new Error("resume failed")),
});
const controller = new ChatReconnectController(host);
const controller = createChatReconnectActions(host);
await controller.reconnectPanel();

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
import { ThreadIdentityController } from "../../../../src/features/chat/threads/thread-identity-controller";
import { createThreadIdentityActions } from "../../../../src/features/chat/threads/thread-identity-actions";
import type { RestoredThreadController } from "../../../../src/features/chat/threads/restored-thread-controller";
import type { RestoredThreadPlaceholderState } from "../../../../src/features/chat/panel/lifecycle";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
@ -52,10 +52,10 @@ function createController() {
refreshLiveState: vi.fn(),
render: vi.fn(),
};
return { controller: new ThreadIdentityController(host), host, restoredPlaceholder, restoredRename, stateStore };
return { controller: createThreadIdentityActions(host), host, restoredPlaceholder, restoredRename, stateStore };
}
describe("ThreadIdentityController", () => {
describe("createThreadIdentityActions", () => {
it("clears the active thread when it is archived", () => {
const { controller, host, stateStore } = createController();
stateStore.dispatch({

View file

@ -4,9 +4,9 @@ import type { AppServerClient } from "../../../../src/app-server/client";
import { createChatState, createChatStateStore, type ChatStateStore } from "../../../../src/features/chat/chat-state";
import { implementPlanCandidateFromState } from "../../../../src/features/chat/plan-implementation";
import {
PlanImplementationController,
type PlanImplementationControllerHost,
} from "../../../../src/features/chat/turns/plan-implementation-controller";
createPlanImplementationActions,
type PlanImplementationActionsHost,
} from "../../../../src/features/chat/turns/plan-implementation-actions";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
const planItem = (id: string): DisplayItem => ({
@ -38,7 +38,7 @@ function createController({ client = {} as AppServerClient } = {}) {
const stateStore = createChatStateStore(createChatState());
const ensureConnected = vi.fn().mockResolvedValue(undefined);
const sendTurnText = vi.fn().mockResolvedValue(undefined);
const host: PlanImplementationControllerHost = {
const host: PlanImplementationActionsHost = {
stateStore,
connection: {
currentClient: () => client,
@ -48,10 +48,10 @@ function createController({ client = {} as AppServerClient } = {}) {
sendTurnText,
},
};
return { controller: new PlanImplementationController(host), ensureConnected, sendTurnText, stateStore };
return { controller: createPlanImplementationActions(host), ensureConnected, sendTurnText, stateStore };
}
describe("PlanImplementationController", () => {
describe("createPlanImplementationActions", () => {
it("finds the latest proposed plan only when the thread is implementable", () => {
const stateStore = createChatStateStore(createChatState());
const first = planItem("first");