Simplify thin chat composition wrappers

This commit is contained in:
murashit 2026-06-15 07:24:12 +09:00
parent 89099deed6
commit 57feea86ea
20 changed files with 121 additions and 285 deletions

View file

@ -15,17 +15,7 @@ export interface ChatReconnectActionsHost {
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> {
export async function reconnectPanel(host: ChatReconnectActionsHost): Promise<void> {
const threadId = activeThreadId(host.stateStore.getState());
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
host.invalidateConnectionWork();

View file

@ -37,13 +37,7 @@ export interface ComposerSubmitActions {
submit: () => Promise<void>;
}
export function createComposerSubmitActions(host: ComposerSubmitActionsHost): ComposerSubmitActions {
return {
submit: () => submitComposer(host),
};
}
async function submitComposer(host: ComposerSubmitActionsHost): Promise<void> {
export async function submitComposer(host: ComposerSubmitActionsHost): Promise<void> {
const draft = host.composer.trimmedDraft;
const state = submissionStateSnapshot(host.stateStore.getState());
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {

View file

@ -1,14 +1,14 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { CodexInput } from "../../../../domain/chat/input";
import type { ChatReconnectActions } from "../connection/reconnect-actions";
import type { MessageStreamNoticeSection } from "../../domain/message-stream/items";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import { canImplementPlan } from "../state/selectors";
import type { ChatStateStore } from "../state/store";
import type { ThreadManagementActions } from "../threads/thread-management-actions";
import type { GoalActions } from "../threads/goal-actions";
import { createComposerSubmitActions, type ComposerSubmitActions } from "./composer-submit-actions";
import { createPlanImplementation, type PlanImplementation } from "./plan-implementation";
import { createSlashCommandHandler } from "./slash-command-handler";
import { submitComposer, type ComposerSubmitActions, type ComposerSubmitActionsHost } from "./composer-submit-actions";
import { implementPlan, type PlanImplementation, type PlanImplementationHost } from "./plan-implementation";
import { executeSlashCommandWithState, type SlashCommandHandlerHost } from "./slash-command-handler";
import { TurnSubmissionController } from "./turn-submission-controller";
export interface ConversationTurnActionsContext {
@ -51,7 +51,7 @@ export interface ConversationTurnActionsRefs {
threadStarter: ConversationThreadStarter;
runtimeSettings: ChatRuntimeSettingsActions;
threadActions: ThreadManagementActions;
reconnectActions: ChatReconnectActions;
reconnectPanel: () => Promise<void>;
goals: GoalActions;
}
@ -83,7 +83,7 @@ export function createConversationTurnActions(
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
});
const slashCommands = createSlashCommandHandler({
const slashCommandHost: SlashCommandHandlerHost = {
stateStore,
currentClient: client.currentClient,
codexInput: composer.codexInput,
@ -95,7 +95,7 @@ export function createConversationTurnActions(
compactThread: (threadId) => refs.threadActions.compactThread(threadId),
archiveThread: (threadId, saveMarkdown) => refs.threadActions.archiveThread(threadId, saveMarkdown),
renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined),
reconnect: () => refs.reconnectActions.reconnectPanel(),
reconnect: refs.reconnectPanel,
toggleFastMode: () => refs.runtimeSettings.toggleFastMode(),
toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
@ -115,8 +115,8 @@ export function createConversationTurnActions(
mcpStatusLines: runtime.mcpStatusLines,
modelStatusLines: runtime.modelStatusLines,
effortStatusLines: runtime.effortStatusLines,
});
const planImplementation = createPlanImplementation({
};
const planImplementationHost: PlanImplementationHost = {
stateStore,
currentClient: client.currentClient,
ensureConnected: client.ensureConnected,
@ -124,8 +124,8 @@ export function createConversationTurnActions(
requestDefaultCollaborationModeForNextTurn: () => {
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
},
});
const composerSubmit = createComposerSubmitActions({
};
const composerSubmitHost: ComposerSubmitActionsHost = {
stateStore,
composer: {
get trimmedDraft() {
@ -133,7 +133,9 @@ export function createConversationTurnActions(
},
setDraft: composer.setDraft,
},
slashCommands,
slashCommands: {
execute: (command, args) => executeSlashCommandWithState(slashCommandHost, command, args),
},
turnSubmission,
connection: {
currentClient: client.currentClient,
@ -144,11 +146,16 @@ export function createConversationTurnActions(
addSystemMessage: status.addSystemMessage,
},
scroll,
});
};
return {
planImplementation,
composerSubmit,
planImplementation: {
canImplement: (item) => canImplementPlan(stateStore.getState(), item),
implement: (item) => implementPlan(planImplementationHost, item),
},
composerSubmit: {
submit: () => submitComposer(composerSubmitHost),
},
};
}

View file

@ -18,14 +18,7 @@ export interface PlanImplementation {
implement: (item: MessageStreamItem) => Promise<void>;
}
export function createPlanImplementation(host: PlanImplementationHost): PlanImplementation {
return {
canImplement: (item) => canImplementPlan(host.stateStore.getState(), item),
implement: (item) => implementPlan(host, item),
};
}
async function implementPlan(host: PlanImplementationHost, item: MessageStreamItem): Promise<void> {
export async function implementPlan(host: PlanImplementationHost, item: MessageStreamItem): Promise<void> {
if (!canImplementPlan(host.stateStore.getState(), item)) return;
await host.ensureConnected();
if (!host.currentClient() || !activeThreadId(host.stateStore.getState())) return;

View file

@ -27,17 +27,7 @@ export interface SlashCommandHandlerHost extends Omit<SlashCommandExecutionConte
setStatus: (status: string) => void;
}
export interface SlashCommandHandler {
execute: (command: SlashCommandName, args: string) => Promise<SlashCommandExecutionResult | undefined>;
}
export function createSlashCommandHandler(host: SlashCommandHandlerHost): SlashCommandHandler {
return {
execute: (command, args) => executeSlashCommand(host, command, args),
};
}
async function executeSlashCommand(
export async function executeSlashCommandWithState(
host: SlashCommandHandlerHost,
command: SlashCommandName,
args: string,

View file

@ -1,6 +1,6 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { CollaborationMode } from "../../domain/runtime/pending-settings";
import type { TurnCollaborationModeWarning } from "../../domain/runtime/warnings";
import type { TurnCollaborationModeWarning } from "./thread-settings-update";
const COLLABORATION_MODE_WARNING_MESSAGES: Record<TurnCollaborationModeWarning, string> = {
"missing-model": "No effective model is available. Sending without a mode override.",

View file

@ -8,7 +8,8 @@ import { currentModel, currentReasoningEffort, fastRuntimeServiceTierRequestValu
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { effectiveCollaborationMode, type PendingRuntimeSetting } from "../../domain/runtime/pending-settings";
import type { TurnCollaborationModeWarning } from "../../domain/runtime/warnings";
export type TurnCollaborationModeWarning = "missing-model";
type TurnCollaborationModeSettings =
| {

View file

@ -5,8 +5,9 @@ import { createSelectionActions } from "./selection-actions";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/store";
import type { PluginSettingsRef, ThreadSurfaceBroadcaster, WorkspacePanels } from "../ports/chat-host";
import { createThreadNamingParts } from "./naming-parts";
import { createThreadManagementActions } from "./thread-management-actions";
import { AutoTitleController } from "./auto-title-controller";
import { ThreadRenameEditorController } from "./rename-editor-controller";
import { createThreadManagementActions, type ThreadManagementActionsHost } from "./thread-management-actions";
import { createThreadLifecycleParts } from "./lifecycle-parts";
interface ThreadPartsContext {
@ -74,39 +75,55 @@ export function createThreadParts(context: ThreadPartsContext) {
const stateStore = state.stateStore;
const currentClient = client.getClient;
const naming = createThreadNamingParts({
settingsRef,
threadSurfaces,
const rename = new ThreadRenameEditorController({
stateStore,
client: {
currentClient,
ensureConnected: client.ensureConnected,
},
status: {
addSystemMessage: status.addSystemMessage,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
});
const autoTitle = new AutoTitleController({
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
currentClient,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
});
const { rename, autoTitle } = naming;
const managementActions = createThreadManagementActions({
obsidian,
settingsRef,
workspace,
threadSurfaces,
const threadManagementHost: ThreadManagementActionsHost = {
stateStore,
client: {
currentClient,
ensureConnected: client.ensureConnected,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
archiveAdapter: obsidian.archiveAdapter,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
showNotice: notify.showNotice,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: (threadId) => workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: (threadId) => thread.selectThread(threadId),
notifyThreadArchived: (threadId) => {
threadSurfaces.notifyThreadArchived(threadId);
},
status,
notify,
thread: {
selectThread: thread.selectThread,
refreshThreads: thread.refreshThreads,
notifyIdentityChanged: thread.notifyIdentityChanged,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
composer,
});
notifyActiveThreadIdentityChanged: () => {
thread.notifyIdentityChanged();
},
refreshThreads: () => thread.refreshThreads(),
refreshSharedThreadListFromOpenSurface: () => {
threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
};
const managementActions = createThreadManagementActions(threadManagementHost);
const goals = createGoalActions({
stateStore,
currentClient,

View file

@ -1,52 +0,0 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { ChatStateStore } from "../state/store";
import type { PluginSettingsRef, ThreadSurfaceBroadcaster } from "../ports/chat-host";
import { AutoTitleController } from "./auto-title-controller";
import { ThreadRenameEditorController } from "./rename-editor-controller";
export interface ThreadNamingPartsContext {
settingsRef: PluginSettingsRef;
threadSurfaces: ThreadSurfaceBroadcaster;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
status: {
addSystemMessage: (text: string) => void;
};
}
export interface ThreadNamingParts {
rename: ThreadRenameEditorController;
autoTitle: AutoTitleController;
}
export function createThreadNamingParts(context: ThreadNamingPartsContext): ThreadNamingParts {
const { settingsRef, threadSurfaces, stateStore, client, status } = context;
const rename = new ThreadRenameEditorController({
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
ensureConnected: client.ensureConnected,
currentClient: client.currentClient,
addSystemMessage: status.addSystemMessage,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
});
const autoTitle = new AutoTitleController({
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
currentClient: client.currentClient,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
});
return {
rename,
autoTitle,
};
}

View file

@ -5,7 +5,6 @@ import type { CodexPanelSettings } from "../../../../settings/model";
import type { ArchiveExportAdapter } from "../../../thread-operations/archive-markdown";
import { archiveThreadOnAppServer } from "../../../thread-operations/archive";
import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../../thread-operations/rename";
import type { PluginSettingsRef, ThreadSurfaceBroadcaster, WorkspacePanels } from "../ports/chat-host";
import {
archivedSourceOpenForkFailedMessage,
finishBeforeArchivingThreadsMessage,
@ -56,35 +55,6 @@ export interface ThreadManagementActions {
rollbackThread: (threadId: string) => Promise<void>;
}
export interface ThreadManagementActionsContext {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
settingsRef: PluginSettingsRef;
workspace: Pick<WorkspacePanels, "openThreadInNewView">;
threadSurfaces: Pick<ThreadSurfaceBroadcaster, "notifyThreadArchived" | "notifyThreadRenamed" | "refreshSharedThreadListFromOpenSurface">;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
notify: {
showNotice: (text: string) => void;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
notifyIdentityChanged: () => void;
};
composer: {
setText: (text: string) => void;
};
}
type RenameThreadHost = Pick<
ThreadManagementActionsHost,
"ensureConnected" | "currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed"
@ -95,32 +65,7 @@ type ConnectedRenameThreadHost = Pick<
"currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed"
>;
export function createThreadManagementActions(context: ThreadManagementActionsContext): ThreadManagementActions {
const { obsidian, settingsRef, workspace, threadSurfaces, stateStore, client, status, notify, thread, composer } = context;
const host: ThreadManagementActionsHost = {
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
archiveAdapter: obsidian.archiveAdapter,
ensureConnected: client.ensureConnected,
currentClient: client.currentClient,
addSystemMessage: status.addSystemMessage,
showNotice: notify.showNotice,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: workspace.openThreadInNewView,
openThreadInCurrentPanel: thread.selectThread,
notifyThreadArchived: threadSurfaces.notifyThreadArchived,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshThreads: thread.refreshThreads,
refreshSharedThreadListFromOpenSurface: () => {
threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
};
export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
return {
compactThread: (threadId) => compactThread(host, threadId),
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),

View file

@ -1 +0,0 @@
export type TurnCollaborationModeWarning = "missing-model";

View file

@ -1,7 +1,6 @@
import type { App, Component } from "obsidian";
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatStateStore } from "../application/state/store";
import type { ChatReconnectActions } from "../application/connection/reconnect-actions";
import { PendingRequestController } from "../application/pending-requests/controller";
import type { ChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import type { ThreadManagementActions } from "../application/threads/thread-management-actions";
@ -74,7 +73,7 @@ export function createConversationParts(
threadStarter: ConversationThreadStarter;
runtimeSettings: ChatRuntimeSettingsActions;
threadActions: ThreadManagementActions;
reconnectActions: ChatReconnectActions;
reconnectPanel: () => Promise<void>;
goals: GoalActions;
history: HistoryController;
},
@ -137,7 +136,7 @@ export function createConversationParts(
threadStarter: refs.threadStarter,
runtimeSettings: refs.runtimeSettings,
threadActions: refs.threadActions,
reconnectActions: refs.reconnectActions,
reconnectPanel: refs.reconnectPanel,
goals: refs.goals,
},
);

View file

@ -11,7 +11,7 @@ import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snaps
import type { ArchiveExportAdapter } from "../../thread-operations/archive-markdown";
import type { CodexChatHost } from "../application/ports/chat-host";
import { ChatConnectionController } from "../application/connection/connection-controller";
import { createChatReconnectActions } from "../application/connection/reconnect-actions";
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "../app-server/actions/metadata";
import { createChatServerThreadActions, type ChatServerThreadActions } from "../app-server/actions/threads";
@ -450,7 +450,7 @@ export class ChatPanelSession {
);
selectionRef.set(selection);
const reconnectActions = createChatReconnectActions({
const reconnectHost: ChatReconnectActionsHost = {
stateStore: this.stateStore,
invalidateConnectionWork: () => {
this.connectionWork.invalidate();
@ -468,7 +468,8 @@ export class ChatPanelSession {
ensureConnected: sessionPorts.ensureConnected,
resumeThread: (threadId) => resume.resumeThread(threadId),
addSystemMessage: sideEffects.status.addSystemMessage,
});
};
const reconnect = () => reconnectPanel(reconnectHost);
const serverParts = this.createServerParts({
connection,
sideEffects,
@ -488,7 +489,7 @@ export class ChatPanelSession {
},
{
connectionController,
reconnectActions,
reconnectPanel: reconnect,
inboundController,
threadActions,
toolbarPanels,
@ -578,7 +579,7 @@ export class ChatPanelSession {
threadStarter: serverThreads,
runtimeSettings,
threadActions,
reconnectActions,
reconnectPanel: reconnect,
goals,
history,
},

View file

@ -2,7 +2,6 @@ import type { ThreadManagementActions } from "../application/threads/thread-mana
import type { ChatAction, ChatState } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
import type { ChatConnectionController } from "../application/connection/connection-controller";
import type { ChatReconnectActions } from "../application/connection/reconnect-actions";
import type { ThreadRenameEditorController } from "../application/threads/rename-editor-controller";
import type { SelectionActions } from "../application/threads/selection-actions";
import type { ChatInboundController } from "../app-server/inbound/controller";
@ -33,7 +32,7 @@ export interface ChatPanelToolbarActionsHost {
export interface ChatPanelToolbarActionDependencies {
connectionController: ChatConnectionController;
reconnectActions: ChatReconnectActions;
reconnectPanel: () => Promise<void>;
inboundController: ChatInboundController;
threadActions: ThreadManagementActions;
toolbarPanels: ToolbarPanelActions;
@ -154,7 +153,7 @@ export function createChatPanelToolbarActions(host: ChatPanelToolbarActionsHost,
deps.toolbarPanels.toggleStatus();
},
connect: () => {
void deps.reconnectActions.reconnectPanel();
void deps.reconnectPanel();
},
refreshStatus: () => {
void deps.connectionController.refreshStatusPanel();

View file

@ -2,10 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { createChatState } from "../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import {
createChatReconnectActions,
type ChatReconnectActionsHost,
} from "../../../../src/features/chat/application/connection/reconnect-actions";
import { reconnectPanel, type ChatReconnectActionsHost } from "../../../../src/features/chat/application/connection/reconnect-actions";
function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
const stateStore = createChatStateStore(createChatState());
@ -36,12 +33,11 @@ function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
return { host, stateStore };
}
describe("createChatReconnectActions", () => {
describe("reconnectPanel", () => {
it("resets local connection state before reconnecting and resumes the active thread", async () => {
const { host, stateStore } = createHost();
const controller = createChatReconnectActions(host);
await controller.reconnectPanel();
await reconnectPanel(host);
expect(stateStore.getState().ui.toolbarPanel).toBeNull();
expect(host.invalidateConnectionWork).toHaveBeenCalledOnce();
@ -57,9 +53,8 @@ describe("createChatReconnectActions", () => {
const { host } = createHost({
resumeThread: vi.fn().mockRejectedValue(new Error("resume failed")),
});
const controller = createChatReconnectActions(host);
await controller.reconnectPanel();
await reconnectPanel(host);
expect(host.addSystemMessage).toHaveBeenCalledWith("resume failed");
});

View file

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { createComposerSubmitActions } from "../../../../../src/features/chat/application/conversation/composer-submit-actions";
import { submitComposer } from "../../../../../src/features/chat/application/conversation/composer-submit-actions";
import type { Thread } from "../../../../../src/domain/threads/model";
function thread(id: string): Thread {
@ -17,7 +17,7 @@ function thread(id: string): Thread {
};
}
function createController(draft: string) {
function createHost(draft: string) {
const stateStore = createChatStateStore(createChatState());
const interruptTurn = vi.fn().mockResolvedValue({});
const client = { interruptTurn } as unknown as AppServerClient;
@ -25,7 +25,7 @@ function createController(draft: string) {
const sendTurnText = vi.fn().mockResolvedValue(undefined);
const execute = vi.fn().mockResolvedValue(undefined);
const followBottom = vi.fn();
const controller = createComposerSubmitActions({
const host = {
stateStore,
composer: {
get trimmedDraft() {
@ -44,15 +44,15 @@ function createController(draft: string) {
addSystemMessage: vi.fn(),
},
scroll: { followBottom },
});
return { controller, execute, followBottom, interruptTurn, sendTurnText, setDraft, stateStore };
};
return { host, execute, followBottom, interruptTurn, sendTurnText, setDraft, stateStore };
}
describe("createComposerSubmitActions", () => {
describe("submitComposer", () => {
it("sends plain drafts as turn text", async () => {
const { controller, followBottom, sendTurnText } = createController("hello");
const { host, followBottom, sendTurnText } = createHost("hello");
await controller.submit();
await submitComposer(host);
expect(followBottom).toHaveBeenCalledOnce();
expect(sendTurnText).toHaveBeenCalledWith("hello");
@ -65,10 +65,10 @@ describe("createComposerSubmitActions", () => {
});
it("executes slash commands and forwards command send results", async () => {
const { controller, execute, followBottom, sendTurnText, setDraft } = createController("/clear hello");
const { host, execute, followBottom, sendTurnText, setDraft } = createHost("/clear hello");
execute.mockResolvedValue({ sendText: "hello" });
await controller.submit();
await submitComposer(host);
expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(execute).toHaveBeenCalledWith("clear", "hello");
@ -77,10 +77,10 @@ describe("createComposerSubmitActions", () => {
});
it("restores slash command composer drafts from command results", async () => {
const { controller, execute, followBottom, sendTurnText, setDraft } = createController("/goal edit");
const { host, execute, followBottom, sendTurnText, setDraft } = createHost("/goal edit");
execute.mockResolvedValue({ composerDraft: "/goal set Current objective" });
await controller.submit();
await submitComposer(host);
expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(setDraft).toHaveBeenCalledWith("/goal set Current objective", { focus: true, clearSuggestions: true });
@ -89,7 +89,7 @@ describe("createComposerSubmitActions", () => {
});
it("interrupts a running turn when submitting an empty draft", async () => {
const { controller, followBottom, interruptTurn, stateStore } = createController("");
const { host, followBottom, interruptTurn, stateStore } = createHost("");
stateStore.dispatch({
type: "active-thread/resumed",
thread: thread("thread"),
@ -103,7 +103,7 @@ describe("createComposerSubmitActions", () => {
});
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
await controller.submit();
await submitComposer(host);
expect(followBottom).not.toHaveBeenCalled();
expect(interruptTurn).toHaveBeenCalledWith("thread", "turn");

View file

@ -4,10 +4,7 @@ import type { AppServerClient } from "../../../../../src/app-server/connection/c
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/application/state/selectors";
import {
createPlanImplementation,
type PlanImplementationHost,
} from "../../../../../src/features/chat/application/conversation/plan-implementation";
import { implementPlan, type PlanImplementationHost } from "../../../../../src/features/chat/application/conversation/plan-implementation";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
const planItem = (id: string): MessageStreamItem => ({
@ -59,15 +56,15 @@ function createController({ client = {} as AppServerClient } = {}) {
requestDefaultCollaborationModeForNextTurn,
};
return {
controller: createPlanImplementation(host),
ensureConnected,
host,
requestDefaultCollaborationModeForNextTurn,
sendTurnText,
stateStore,
};
}
describe("createPlanImplementation", () => {
describe("implementPlan", () => {
it("finds the latest proposed plan only when the thread is idle and in plan mode", () => {
const stateStore = createChatStateStore(createChatState());
const first = planItem("first");
@ -91,12 +88,12 @@ describe("createPlanImplementation", () => {
});
it("switches out of plan mode and submits the implementation prompt", async () => {
const { controller, ensureConnected, requestDefaultCollaborationModeForNextTurn, sendTurnText, stateStore } = createController();
const { host, ensureConnected, requestDefaultCollaborationModeForNextTurn, sendTurnText, stateStore } = createController();
const plan = planItem("plan");
resumeThread(stateStore, [plan]);
stateStore.dispatch({ type: "ui/panel-set", panel: "status-panel" });
await controller.implement(plan);
await implementPlan(host, plan);
expect(ensureConnected).toHaveBeenCalledOnce();
expect(requestDefaultCollaborationModeForNextTurn).toHaveBeenCalledOnce();
@ -106,12 +103,12 @@ describe("createPlanImplementation", () => {
});
it("ignores stale plan items", async () => {
const { controller, ensureConnected, sendTurnText, stateStore } = createController();
const { host, ensureConnected, sendTurnText, stateStore } = createController();
const first = planItem("first");
const latest = planItem("latest");
resumeThread(stateStore, [first, latest]);
await controller.implement(first);
await implementPlan(host, first);
expect(ensureConnected).not.toHaveBeenCalled();
expect(sendTurnText).not.toHaveBeenCalled();

View file

@ -6,7 +6,7 @@ import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protoco
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import {
createSlashCommandHandler,
executeSlashCommandWithState,
type SlashCommandHandlerHost,
} from "../../../../../src/features/chat/application/conversation/slash-command-handler";
import type { Thread } from "../../../../../src/domain/threads/model";
@ -68,12 +68,11 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
return { compactThread, host, stateStore, threadTurnsList };
}
describe("createSlashCommandHandler", () => {
describe("executeSlashCommandWithState", () => {
it("executes slash commands against the current chat state", async () => {
const { host } = createHost();
const controller = createSlashCommandHandler(host);
const result = await controller.execute("clear", "");
const result = await executeSlashCommandWithState(host, "clear", "");
expect(host.startNewThread).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
@ -96,9 +95,8 @@ describe("createSlashCommandHandler", () => {
approvalsReviewer: null,
activePermissionProfile: null,
});
const controller = createSlashCommandHandler(host);
await controller.execute("compact", "");
await executeSlashCommandWithState(host, "compact", "");
expect(compactThread).toHaveBeenCalledWith("thread");
});
@ -116,18 +114,16 @@ describe("createSlashCommandHandler", () => {
approvalsReviewer: null,
activePermissionProfile: null,
});
const controller = createSlashCommandHandler(host);
await controller.execute("compact", "");
await executeSlashCommandWithState(host, "compact", "");
expect(compactThread).toHaveBeenCalledWith("thread");
});
it("starts an empty panel before setting a slash command goal", async () => {
const { host } = createHost();
const controller = createSlashCommandHandler(host);
await controller.execute("goal", "set Ship this");
await executeSlashCommandWithState(host, "goal", "set Ship this");
expect(host.startThreadForGoal).toHaveBeenCalledWith("Ship this");
expect(host.setGoalObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
@ -135,9 +131,8 @@ describe("createSlashCommandHandler", () => {
it("runs reconnect even when there is no current app-server client", async () => {
const { host } = createHost({ currentClient: () => null });
const controller = createSlashCommandHandler(host);
await controller.execute("reconnect", "");
await executeSlashCommandWithState(host, "reconnect", "");
expect(host.reconnect).toHaveBeenCalledOnce();
});
@ -148,9 +143,8 @@ describe("createSlashCommandHandler", () => {
type: "thread-list/applied",
threads: [thread("other", "Other")],
});
const controller = createSlashCommandHandler(host);
const result = await controller.execute("refer", "Other summarize");
const result = await executeSlashCommandWithState(host, "refer", "Other summarize");
expect(threadTurnsList).toHaveBeenCalledWith("other", null, 20);
expect(result).toBeUndefined();
@ -167,9 +161,8 @@ describe("createSlashCommandHandler", () => {
data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])],
nextCursor: null,
});
const controller = createSlashCommandHandler(host);
const result = await controller.execute("refer", "Other summarize");
const result = await executeSlashCommandWithState(host, "refer", "Other summarize");
expect(result?.sendText).toBe("summarize");
expect(host.setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns).");

View file

@ -113,7 +113,7 @@ function toolbarActionDeps(): Parameters<typeof createChatPanelToolbarActions>[1
connectionController: { refreshStatusPanel: vi.fn() } as unknown as Parameters<
typeof createChatPanelToolbarActions
>[1]["connectionController"],
reconnectActions: { reconnectPanel: vi.fn() } as Parameters<typeof createChatPanelToolbarActions>[1]["reconnectActions"],
reconnectPanel: vi.fn(),
inboundController: { addSystemMessage: vi.fn() } as unknown as Parameters<typeof createChatPanelToolbarActions>[1]["inboundController"],
threadActions: {
archiveThread: vi.fn().mockResolvedValue(undefined),

View file

@ -406,39 +406,7 @@ function clientMock() {
}
function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
return createThreadManagementActions({
obsidian: { archiveAdapter: host.archiveAdapter },
settingsRef: {
get settings() {
return host.settings();
},
get vaultPath() {
return host.vaultPath;
},
},
workspace: { openThreadInNewView: host.openThreadInNewView },
threadSurfaces: {
notifyThreadArchived: host.notifyThreadArchived,
notifyThreadRenamed: host.notifyThreadRenamed,
refreshSharedThreadListFromOpenSurface: host.refreshSharedThreadListFromOpenSurface,
},
stateStore: host.stateStore,
client: {
currentClient: host.currentClient,
ensureConnected: host.ensureConnected,
},
status: {
set: host.setStatus,
addSystemMessage: host.addSystemMessage,
},
notify: { showNotice: host.showNotice },
thread: {
selectThread: host.openThreadInCurrentPanel,
refreshThreads: host.refreshThreads,
notifyIdentityChanged: host.notifyActiveThreadIdentityChanged,
},
composer: { setText: host.setComposerText },
});
return createThreadManagementActions(host);
}
function hostMock({