mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Split chat composition boundaries
This commit is contained in:
parent
8d2785781b
commit
cb866541e5
24 changed files with 1183 additions and 744 deletions
|
|
@ -28,18 +28,14 @@ export class StaleConnectionError extends Error {
|
|||
|
||||
export class ConnectionManager {
|
||||
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
|
||||
private handlers: ConnectionManagerHandlers | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly codexPath: () => string,
|
||||
private readonly cwd: string,
|
||||
private readonly handlers: ConnectionManagerHandlers,
|
||||
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers),
|
||||
) {}
|
||||
|
||||
setHandlers(handlers: ConnectionManagerHandlers): void {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
currentClient(): AppServerClient | null {
|
||||
return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null;
|
||||
}
|
||||
|
|
@ -59,20 +55,20 @@ export class ConnectionManager {
|
|||
const client = this.clientFactory(this.codexPath(), this.cwd, {
|
||||
onNotification: (notification) => {
|
||||
if (this.isStale(generation)) return;
|
||||
this.currentHandlers().onNotification(notification);
|
||||
this.handlers.onNotification(notification);
|
||||
},
|
||||
onServerRequest: (request) => {
|
||||
if (this.isStale(generation)) return;
|
||||
this.currentHandlers().onServerRequest(request);
|
||||
this.handlers.onServerRequest(request);
|
||||
},
|
||||
onLog: (message) => {
|
||||
if (this.isStale(generation)) return;
|
||||
this.currentHandlers().onLog(message);
|
||||
this.handlers.onLog(message);
|
||||
},
|
||||
onExit: () => {
|
||||
if (this.isStale(generation)) return;
|
||||
this.state = { kind: "disconnected", generation };
|
||||
this.currentHandlers().onExit();
|
||||
this.handlers.onExit();
|
||||
},
|
||||
});
|
||||
const promise = client
|
||||
|
|
@ -115,9 +111,4 @@ export class ConnectionManager {
|
|||
private isStale(generation: number): boolean {
|
||||
return generation !== this.state.generation;
|
||||
}
|
||||
|
||||
private currentHandlers(): ConnectionManagerHandlers {
|
||||
if (!this.handlers) throw new Error("ConnectionManager handlers have not been attached.");
|
||||
return this.handlers;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
src/features/chat/conversation/composer/composition.ts
Normal file
80
src/features/chat/conversation/composer/composition.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { App } from "obsidian";
|
||||
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll";
|
||||
import { currentModel, runtimeConfigOrDefault } from "../../runtime/effective";
|
||||
import { runtimeSnapshotForChatState } from "../../runtime/snapshot";
|
||||
import { activeTurnId, type ChatStateStore } from "../../state/reducer";
|
||||
import type { ComposerMetaViewModel } from "../../ui/composer";
|
||||
import type { ChatPanelComposerShellState } from "../../ui/shell-state";
|
||||
import type { CodexChatHost } from "../../chat-host";
|
||||
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
|
||||
import { ChatComposerController } from "./controller";
|
||||
|
||||
export interface ConversationComposerContext {
|
||||
app: App;
|
||||
plugin: CodexChatHost;
|
||||
stateStore: ChatStateStore;
|
||||
viewId: string;
|
||||
surface: {
|
||||
composerPlaceholder: (state: ChatPanelComposerShellState) => string;
|
||||
composerMetaViewModel: (state: ChatPanelComposerShellState) => ComposerMetaViewModel;
|
||||
};
|
||||
liveState: {
|
||||
refresh: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationComposerRefs {
|
||||
runtimeSettings: ChatRuntimeSettingsActions;
|
||||
}
|
||||
|
||||
export interface ConversationComposerParts {
|
||||
controller: ChatComposerController;
|
||||
scrollBridge: MessageStreamScrollBridge;
|
||||
codexInput: (text: string) => CodexInput;
|
||||
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void;
|
||||
}
|
||||
|
||||
export function createConversationComposer(
|
||||
context: ConversationComposerContext,
|
||||
refs: ConversationComposerRefs,
|
||||
): ConversationComposerParts {
|
||||
const { app, plugin, stateStore, viewId, surface, liveState } = context;
|
||||
const scrollBridge = new MessageStreamScrollBridge();
|
||||
const controller = new ChatComposerController({
|
||||
app,
|
||||
stateStore,
|
||||
viewId,
|
||||
sendShortcut: () => plugin.settings.sendShortcut,
|
||||
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
|
||||
canInterrupt: (state) => {
|
||||
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
|
||||
},
|
||||
composerPlaceholder: surface.composerPlaceholder,
|
||||
composerMeta: surface.composerMetaViewModel,
|
||||
currentModelForSuggestions: () => {
|
||||
const current = stateStore.getState();
|
||||
return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig));
|
||||
},
|
||||
threadScrollFromComposer: (action) => {
|
||||
scrollBridge.scrollFromComposer(action);
|
||||
},
|
||||
togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
|
||||
toggleFast: () => void refs.runtimeSettings.toggleFastMode(),
|
||||
onDraftChange: liveState.refresh,
|
||||
onHeightChange: () => {
|
||||
scrollBridge.repinMessageStreamToBottomIfPinned();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
controller,
|
||||
scrollBridge,
|
||||
codexInput: (text) => controller.codexInput(text),
|
||||
setDraft: (text, options) => {
|
||||
controller.setDraft(text, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ export interface ChatComposerControllerOptions {
|
|||
composerPlaceholder: (state: ChatPanelComposerShellState) => string;
|
||||
composerMeta: (state: ChatPanelComposerShellState) => ComposerMetaViewModel;
|
||||
currentModelForSuggestions: () => string | null;
|
||||
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
|
||||
togglePlan: () => void;
|
||||
toggleAutoReview: () => void;
|
||||
toggleFast: () => void;
|
||||
|
|
@ -37,23 +38,17 @@ export interface ChatComposerControllerOptions {
|
|||
onHeightChange: () => void;
|
||||
}
|
||||
|
||||
export interface ChatComposerActionHandlers {
|
||||
export interface ChatComposerRenderActions {
|
||||
submit: () => void;
|
||||
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
|
||||
}
|
||||
|
||||
export class ChatComposerController {
|
||||
private composer: HTMLTextAreaElement | null = null;
|
||||
private noteCandidatesCache: { sourcePath: string; notes: NoteCandidate[] } | null = null;
|
||||
private noteEventsRegistered = false;
|
||||
private actionHandlers: ChatComposerActionHandlers | null = null;
|
||||
|
||||
constructor(private readonly options: ChatComposerControllerOptions) {}
|
||||
|
||||
setActionHandlers(handlers: ChatComposerActionHandlers): void {
|
||||
this.actionHandlers = handlers;
|
||||
}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.options.stateStore.getState();
|
||||
}
|
||||
|
|
@ -78,7 +73,7 @@ export class ChatComposerController {
|
|||
registerEvent(this.options.app.vault.on("modify", invalidate));
|
||||
}
|
||||
|
||||
renderState(state: ChatPanelComposerShellState): ComposerShellProps {
|
||||
renderState(state: ChatPanelComposerShellState, actions: ChatComposerRenderActions): ComposerShellProps {
|
||||
return {
|
||||
viewId: this.options.viewId,
|
||||
draft: state.composer.draft,
|
||||
|
|
@ -87,7 +82,7 @@ export class ChatComposerController {
|
|||
normalPlaceholder: this.options.composerPlaceholder(state),
|
||||
suggestions: state.composer.suggestions,
|
||||
selectedSuggestionIndex: state.composer.suggestSelected,
|
||||
callbacks: this.composerCallbacks(),
|
||||
callbacks: this.composerCallbacks(actions),
|
||||
meta: this.options.composerMeta(state),
|
||||
onComposer: this.setComposerElement,
|
||||
};
|
||||
|
|
@ -178,7 +173,7 @@ export class ChatComposerController {
|
|||
if (!action) return false;
|
||||
|
||||
event.preventDefault();
|
||||
this.currentActionHandlers().threadScrollFromComposer(action);
|
||||
this.options.threadScrollFromComposer(action);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -300,12 +295,7 @@ export class ChatComposerController {
|
|||
return this.noteCandidatesCache.notes;
|
||||
}
|
||||
|
||||
private currentActionHandlers(): ChatComposerActionHandlers {
|
||||
if (!this.actionHandlers) throw new Error("ChatComposerController action handlers have not been attached.");
|
||||
return this.actionHandlers;
|
||||
}
|
||||
|
||||
private composerCallbacks(): ComposerCallbacks {
|
||||
private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks {
|
||||
return {
|
||||
onInput: (value) => {
|
||||
this.handleInput(value);
|
||||
|
|
@ -322,11 +312,11 @@ export class ChatComposerController {
|
|||
}
|
||||
if (isComposerSendKey(event, this.options.sendShortcut())) {
|
||||
event.preventDefault();
|
||||
this.currentActionHandlers().submit();
|
||||
actions.submit();
|
||||
}
|
||||
},
|
||||
onSendOrInterrupt: () => {
|
||||
this.currentActionHandlers().submit();
|
||||
actions.submit();
|
||||
},
|
||||
onHeightChange: () => {
|
||||
this.options.onHeightChange();
|
||||
|
|
|
|||
|
|
@ -1,30 +1,24 @@
|
|||
import type { App, Component } from "obsidian";
|
||||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { ChatServerThreadActions } from "../connection/server-actions/threads";
|
||||
import { ChatComposerController } from "./composer/controller";
|
||||
import { activeTurnId, type ChatStateStore } from "../state/reducer";
|
||||
import { type ChatStateStore } from "../state/reducer";
|
||||
import type { ChatReconnectActions } from "../connection/reconnect-actions";
|
||||
import { PendingRequestController } from "./pending-requests/controller";
|
||||
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
|
||||
import { createComposerSubmitActions } from "./turns/composer-submit-actions";
|
||||
import { createPlanImplementation } from "./turns/plan-implementation";
|
||||
import { createSlashCommandHandler } from "./turns/slash-command-handler";
|
||||
import { TurnSubmissionController } from "./turns/turn-submission-controller";
|
||||
import type { ChatThreadActions } from "../threads/action-context";
|
||||
import type { GoalActions } from "../threads/goal-actions";
|
||||
import type { HistoryController } from "../threads/history-controller";
|
||||
import type { ChatInboundController } from "../protocol/inbound/controller";
|
||||
import { currentModel, runtimeConfigOrDefault } from "../runtime/effective";
|
||||
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
|
||||
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
|
||||
import { MessageStreamScrollBridge } from "../panel/surface/message-stream-scroll";
|
||||
import type { DisplayDetailSection } from "../display/types";
|
||||
import type { ChatMessageScrollIntentState } from "../ui/message-stream/scroll-intent-state";
|
||||
import type { ComposerMetaViewModel } from "../ui/composer";
|
||||
import type { ChatPanelComposerShellState } from "../ui/shell-state";
|
||||
import type { CodexChatHost } from "../chat-host";
|
||||
import { createConversationComposer } from "./composer/composition";
|
||||
import { createConversationMessageStreamPresenter } from "./message-stream/composition";
|
||||
import { createConversationTurnActions } from "./turns/composition";
|
||||
|
||||
interface ConversationControllersContext {
|
||||
interface ConversationPartsContext {
|
||||
obsidian: {
|
||||
app: App;
|
||||
owner: Component;
|
||||
|
|
@ -74,8 +68,8 @@ interface ConversationControllersContext {
|
|||
};
|
||||
}
|
||||
|
||||
export function createConversationControllers(
|
||||
context: ConversationControllersContext,
|
||||
export function createConversationParts(
|
||||
context: ConversationPartsContext,
|
||||
refs: {
|
||||
controller: ChatInboundController;
|
||||
serverThreads: ChatServerThreadActions;
|
||||
|
|
@ -90,41 +84,27 @@ export function createConversationControllers(
|
|||
const { app, owner, viewId } = context.obsidian;
|
||||
const stateStore = state.stateStore;
|
||||
const currentClient = client.getClient;
|
||||
const { messageScrollIntent } = lifecycle;
|
||||
const messageStreamScrollBridge = new MessageStreamScrollBridge();
|
||||
|
||||
const composerController = new ChatComposerController({
|
||||
app,
|
||||
stateStore,
|
||||
viewId,
|
||||
sendShortcut: () => plugin.settings.sendShortcut,
|
||||
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
|
||||
canInterrupt: (state) => {
|
||||
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
|
||||
const composer = createConversationComposer(
|
||||
{
|
||||
app,
|
||||
plugin,
|
||||
stateStore,
|
||||
viewId,
|
||||
surface: {
|
||||
composerPlaceholder: surface.composerPlaceholder,
|
||||
composerMetaViewModel: surface.composerMetaViewModel,
|
||||
},
|
||||
liveState: {
|
||||
refresh: liveState.refresh,
|
||||
},
|
||||
},
|
||||
composerPlaceholder: surface.composerPlaceholder,
|
||||
composerMeta: surface.composerMetaViewModel,
|
||||
currentModelForSuggestions: () => {
|
||||
const current = stateStore.getState();
|
||||
return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig));
|
||||
{
|
||||
runtimeSettings: refs.runtimeSettings,
|
||||
},
|
||||
togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
|
||||
toggleFast: () => void refs.runtimeSettings.toggleFastMode(),
|
||||
onDraftChange: liveState.refresh,
|
||||
onHeightChange: () => {
|
||||
messageStreamScrollBridge.repinMessageStreamToBottomIfPinned();
|
||||
},
|
||||
});
|
||||
const codexInput = (text: string) => composerController.codexInput(text);
|
||||
const setComposerDraft = (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => {
|
||||
composerController.setDraft(text, options);
|
||||
};
|
||||
const startThread = (preview?: string) => refs.serverThreads.startThread(preview);
|
||||
const startThreadForGoal = async (objective: string) => {
|
||||
const response = await refs.serverThreads.startThread(objective, { syncGoal: false });
|
||||
return response?.threadId ?? null;
|
||||
};
|
||||
);
|
||||
const composerController = composer.controller;
|
||||
const messageStreamScrollBridge = composer.scrollBridge;
|
||||
|
||||
const pendingRequests = new PendingRequestController({
|
||||
stateStore,
|
||||
|
|
@ -133,125 +113,63 @@ export function createConversationControllers(
|
|||
refreshLiveState: liveState.refresh,
|
||||
});
|
||||
|
||||
const turnSubmission = new TurnSubmissionController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
currentClient,
|
||||
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
|
||||
startThread,
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
resetThreadTurnPresence: thread.resetTurnPresence,
|
||||
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
|
||||
codexInput,
|
||||
setDraft: setComposerDraft,
|
||||
setStatus: status.set,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
});
|
||||
const slashCommands = createSlashCommandHandler({
|
||||
stateStore,
|
||||
currentClient,
|
||||
codexInput,
|
||||
startNewThread: thread.startNewThread,
|
||||
startThreadForGoal,
|
||||
resumeThread: thread.selectThread,
|
||||
forkThread: (threadId) => refs.threadActions.forkThread(threadId),
|
||||
rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId),
|
||||
compactThread: (threadId) => refs.threadActions.compactThread(threadId),
|
||||
archiveThread: (threadId) => refs.threadActions.archiveThread(threadId),
|
||||
renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined),
|
||||
reconnect: () => refs.reconnectActions.reconnectPanel(),
|
||||
toggleFastMode: () => refs.runtimeSettings.toggleFastMode(),
|
||||
toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
|
||||
requestModel: (model) => refs.runtimeSettings.requestModel(model),
|
||||
resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(),
|
||||
requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort),
|
||||
resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(),
|
||||
activeGoal: () => refs.goals.activeGoal(),
|
||||
setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget),
|
||||
setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus),
|
||||
clearGoal: (threadId) => refs.goals.clear(threadId),
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
addStructuredSystemMessage: status.addStructuredSystemMessage,
|
||||
setStatus: status.set,
|
||||
statusSummaryLines: runtime.statusSummaryLines,
|
||||
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
|
||||
mcpStatusLines: runtime.mcpStatusLines,
|
||||
modelStatusLines: runtime.modelStatusLines,
|
||||
effortStatusLines: runtime.effortStatusLines,
|
||||
});
|
||||
const planImplementation = createPlanImplementation({
|
||||
stateStore,
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
sendTurnText: (text) => turnSubmission.sendTurnText(text),
|
||||
requestDefaultCollaborationModeForNextTurn: () => {
|
||||
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
|
||||
},
|
||||
});
|
||||
|
||||
const messageStreamPresenter = new MessageStreamPresenter({
|
||||
obsidian: {
|
||||
app,
|
||||
owner,
|
||||
},
|
||||
state: {
|
||||
store: stateStore,
|
||||
},
|
||||
workspace: {
|
||||
const turnActions = createConversationTurnActions(
|
||||
{
|
||||
vaultPath: plugin.vaultPath,
|
||||
},
|
||||
scroll: {
|
||||
consumeIntent: () => messageScrollIntent.consumeIntent(),
|
||||
registerVirtualizer: messageStreamScrollBridge.registerVirtualizer,
|
||||
dispose: () => {
|
||||
messageStreamScrollBridge.dispose();
|
||||
stateStore,
|
||||
client: {
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
},
|
||||
status,
|
||||
runtime,
|
||||
thread,
|
||||
composer: {
|
||||
codexInput: composer.codexInput,
|
||||
trimmedDraft: () => composerController.trimmedDraft,
|
||||
setDraft: composer.setDraft,
|
||||
},
|
||||
scroll: {
|
||||
followBottom: scroll.followBottom,
|
||||
},
|
||||
},
|
||||
history: {
|
||||
loadOlderTurns: () => void refs.history.loadOlder(),
|
||||
{
|
||||
serverThreads: refs.serverThreads,
|
||||
runtimeSettings: refs.runtimeSettings,
|
||||
threadActions: refs.threadActions,
|
||||
reconnectActions: refs.reconnectActions,
|
||||
goals: refs.goals,
|
||||
},
|
||||
actions: {
|
||||
rollbackThread: (threadId) => void refs.threadActions.rollbackThread(threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => void refs.threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
|
||||
implementPlan: (item) => void planImplementation.implement(item),
|
||||
openTurnDiff: (state) => void plugin.openTurnDiff(state),
|
||||
);
|
||||
|
||||
const messageStreamPresenter = createConversationMessageStreamPresenter(
|
||||
{
|
||||
obsidian: {
|
||||
app,
|
||||
owner,
|
||||
},
|
||||
plugin,
|
||||
stateStore,
|
||||
lifecycle,
|
||||
scroll: {
|
||||
bridge: messageStreamScrollBridge,
|
||||
},
|
||||
surface: {
|
||||
pendingRequestsSignature: surface.pendingRequestsSignature,
|
||||
},
|
||||
},
|
||||
requests: {
|
||||
pendingSignature: surface.pendingRequestsSignature,
|
||||
pendingSnapshot: () => pendingRequests.snapshot(),
|
||||
pendingActions: () => pendingRequests.actions(),
|
||||
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
|
||||
{
|
||||
history: refs.history,
|
||||
threadActions: refs.threadActions,
|
||||
pendingRequests,
|
||||
planImplementation: turnActions.planImplementation,
|
||||
},
|
||||
});
|
||||
const composerSubmit = createComposerSubmitActions({
|
||||
stateStore,
|
||||
composer: composerController,
|
||||
slashCommands,
|
||||
turnSubmission,
|
||||
connection: {
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
},
|
||||
status: {
|
||||
setStatus: status.set,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
},
|
||||
scroll: {
|
||||
followBottom: scroll.followBottom,
|
||||
},
|
||||
});
|
||||
composerController.setActionHandlers({
|
||||
submit: () => void composerSubmit.submit(),
|
||||
threadScrollFromComposer: (action) => {
|
||||
messageStreamScrollBridge.scrollFromComposer(action);
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
return {
|
||||
pendingRequests,
|
||||
messageStreamPresenter,
|
||||
composerController,
|
||||
composerSubmit,
|
||||
composerSubmit: turnActions.composerSubmit,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
77
src/features/chat/conversation/message-stream/composition.ts
Normal file
77
src/features/chat/conversation/message-stream/composition.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { App, Component } from "obsidian";
|
||||
|
||||
import type { CodexChatHost } from "../../chat-host";
|
||||
import type { DisplayItem } from "../../display/types";
|
||||
import { MessageStreamPresenter } from "../../panel/surface/message-stream-presenter";
|
||||
import type { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll";
|
||||
import type { ChatStateStore } from "../../state/reducer";
|
||||
import type { ChatThreadActions } from "../../threads/action-context";
|
||||
import type { HistoryController } from "../../threads/history-controller";
|
||||
import type { ChatMessageScrollIntentState } from "../../ui/message-stream/scroll-intent-state";
|
||||
import type { PendingRequestController } from "../pending-requests/controller";
|
||||
import type { PlanImplementation } from "../turns/plan-implementation";
|
||||
|
||||
export interface ConversationMessageStreamContext {
|
||||
obsidian: {
|
||||
app: App;
|
||||
owner: Component;
|
||||
};
|
||||
plugin: CodexChatHost;
|
||||
stateStore: ChatStateStore;
|
||||
lifecycle: {
|
||||
messageScrollIntent: ChatMessageScrollIntentState;
|
||||
};
|
||||
scroll: {
|
||||
bridge: MessageStreamScrollBridge;
|
||||
};
|
||||
surface: {
|
||||
pendingRequestsSignature: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationMessageStreamRefs {
|
||||
history: HistoryController;
|
||||
threadActions: ChatThreadActions;
|
||||
pendingRequests: PendingRequestController;
|
||||
planImplementation: PlanImplementation;
|
||||
}
|
||||
|
||||
export function createConversationMessageStreamPresenter(
|
||||
context: ConversationMessageStreamContext,
|
||||
refs: ConversationMessageStreamRefs,
|
||||
): MessageStreamPresenter {
|
||||
const { obsidian, plugin, stateStore, lifecycle, scroll, surface } = context;
|
||||
const { messageScrollIntent } = lifecycle;
|
||||
const scrollBridge = scroll.bridge;
|
||||
return new MessageStreamPresenter({
|
||||
obsidian,
|
||||
state: {
|
||||
store: stateStore,
|
||||
},
|
||||
workspace: {
|
||||
vaultPath: plugin.vaultPath,
|
||||
},
|
||||
scroll: {
|
||||
consumeIntent: () => messageScrollIntent.consumeIntent(),
|
||||
registerVirtualizer: scrollBridge.registerVirtualizer,
|
||||
dispose: () => {
|
||||
scrollBridge.dispose();
|
||||
},
|
||||
},
|
||||
history: {
|
||||
loadOlderTurns: () => void refs.history.loadOlder(),
|
||||
},
|
||||
actions: {
|
||||
rollbackThread: (threadId) => void refs.threadActions.rollbackThread(threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => void refs.threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
|
||||
implementPlan: (item: DisplayItem) => void refs.planImplementation.implement(item),
|
||||
openTurnDiff: (state) => void plugin.openTurnDiff(state),
|
||||
},
|
||||
requests: {
|
||||
pendingSignature: surface.pendingRequestsSignature,
|
||||
pendingSnapshot: () => refs.pendingRequests.snapshot(),
|
||||
pendingActions: () => refs.pendingRequests.actions(),
|
||||
consumePendingAutoFocus: () => refs.pendingRequests.consumeAutoFocus(),
|
||||
},
|
||||
});
|
||||
}
|
||||
155
src/features/chat/conversation/turns/composition.ts
Normal file
155
src/features/chat/conversation/turns/composition.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import type { ChatServerThreadActions } from "../../connection/server-actions/threads";
|
||||
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
|
||||
import type { DisplayDetailSection } from "../../display/types";
|
||||
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
|
||||
import type { ChatStateStore } from "../../state/reducer";
|
||||
import type { ChatThreadActions } from "../../threads/action-context";
|
||||
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 { TurnSubmissionController } from "./turn-submission-controller";
|
||||
|
||||
export interface ConversationTurnActionsContext {
|
||||
vaultPath: string;
|
||||
stateStore: ChatStateStore;
|
||||
client: {
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
status: {
|
||||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
|
||||
};
|
||||
runtime: {
|
||||
connectionDiagnosticDetails: () => DisplayDetailSection[];
|
||||
modelStatusLines: () => string[];
|
||||
effortStatusLines: () => string[];
|
||||
statusSummaryLines: () => string[];
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
};
|
||||
thread: {
|
||||
ensureRestoredThreadLoaded: () => Promise<boolean>;
|
||||
startNewThread: () => Promise<void>;
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
resetTurnPresence: (hadTurns: boolean) => void;
|
||||
};
|
||||
composer: {
|
||||
codexInput: (text: string) => CodexInput;
|
||||
trimmedDraft: () => string;
|
||||
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void;
|
||||
};
|
||||
scroll: {
|
||||
followBottom: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationTurnActionsRefs {
|
||||
serverThreads: ChatServerThreadActions;
|
||||
runtimeSettings: ChatRuntimeSettingsActions;
|
||||
threadActions: ChatThreadActions;
|
||||
reconnectActions: ChatReconnectActions;
|
||||
goals: GoalActions;
|
||||
}
|
||||
|
||||
export interface ConversationTurnActions {
|
||||
planImplementation: PlanImplementation;
|
||||
composerSubmit: ComposerSubmitActions;
|
||||
}
|
||||
|
||||
export function createConversationTurnActions(
|
||||
context: ConversationTurnActionsContext,
|
||||
refs: ConversationTurnActionsRefs,
|
||||
): ConversationTurnActions {
|
||||
const { vaultPath, stateStore, client, status, runtime, thread, composer, scroll } = context;
|
||||
const turnSubmission = new TurnSubmissionController({
|
||||
stateStore,
|
||||
vaultPath,
|
||||
currentClient: client.currentClient,
|
||||
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
|
||||
startThread: (preview) => refs.serverThreads.startThread(preview),
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
resetThreadTurnPresence: thread.resetTurnPresence,
|
||||
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
|
||||
codexInput: composer.codexInput,
|
||||
setDraft: composer.setDraft,
|
||||
setStatus: status.set,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
});
|
||||
const slashCommands = createSlashCommandHandler({
|
||||
stateStore,
|
||||
currentClient: client.currentClient,
|
||||
codexInput: composer.codexInput,
|
||||
startNewThread: thread.startNewThread,
|
||||
startThreadForGoal: (objective) => startThreadForGoal(refs.serverThreads, objective),
|
||||
resumeThread: thread.selectThread,
|
||||
forkThread: (threadId) => refs.threadActions.forkThread(threadId),
|
||||
rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId),
|
||||
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(),
|
||||
toggleFastMode: () => refs.runtimeSettings.toggleFastMode(),
|
||||
toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
|
||||
requestModel: (model) => refs.runtimeSettings.requestModel(model),
|
||||
resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(),
|
||||
requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort),
|
||||
resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(),
|
||||
activeGoal: () => refs.goals.activeGoal(),
|
||||
setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget),
|
||||
setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus),
|
||||
clearGoal: (threadId) => refs.goals.clear(threadId),
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
addStructuredSystemMessage: status.addStructuredSystemMessage,
|
||||
setStatus: status.set,
|
||||
statusSummaryLines: runtime.statusSummaryLines,
|
||||
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
|
||||
mcpStatusLines: runtime.mcpStatusLines,
|
||||
modelStatusLines: runtime.modelStatusLines,
|
||||
effortStatusLines: runtime.effortStatusLines,
|
||||
});
|
||||
const planImplementation = createPlanImplementation({
|
||||
stateStore,
|
||||
currentClient: client.currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
sendTurnText: (text) => turnSubmission.sendTurnText(text),
|
||||
requestDefaultCollaborationModeForNextTurn: () => {
|
||||
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
|
||||
},
|
||||
});
|
||||
const composerSubmit = createComposerSubmitActions({
|
||||
stateStore,
|
||||
composer: {
|
||||
get trimmedDraft() {
|
||||
return composer.trimmedDraft();
|
||||
},
|
||||
setDraft: composer.setDraft,
|
||||
},
|
||||
slashCommands,
|
||||
turnSubmission,
|
||||
connection: {
|
||||
currentClient: client.currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
},
|
||||
status: {
|
||||
setStatus: status.set,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
},
|
||||
scroll,
|
||||
});
|
||||
|
||||
return {
|
||||
planImplementation,
|
||||
composerSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
async function startThreadForGoal(actions: ChatServerThreadActions, objective: string): Promise<string | null> {
|
||||
const response = await actions.startThread(objective, { syncGoal: false });
|
||||
return response?.threadId ?? null;
|
||||
}
|
||||
|
|
@ -30,7 +30,11 @@ import { runtimeSnapshotForShellState } from "./runtime-snapshot";
|
|||
type ComposerMetaState = Pick<ChatState, "connection" | "runtime">;
|
||||
|
||||
export interface ChatPanelComposerController {
|
||||
renderState(state: ChatPanelComposerShellState): ComposerShellProps;
|
||||
renderState(state: ChatPanelComposerShellState, actions: ChatPanelComposerActions): ComposerShellProps;
|
||||
}
|
||||
|
||||
export interface ChatPanelComposerActions {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export interface RuntimeComposerChoicesInput {
|
||||
|
|
@ -45,9 +49,15 @@ export function composerPlaceholder(threadName: string | null): string {
|
|||
return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task...";
|
||||
}
|
||||
|
||||
export function ChatPanelComposer({ controller }: { controller: ChatPanelComposerController }): UiNode {
|
||||
export function ChatPanelComposer({
|
||||
controller,
|
||||
actions,
|
||||
}: {
|
||||
controller: ChatPanelComposerController;
|
||||
actions: ChatPanelComposerActions;
|
||||
}): UiNode {
|
||||
const state = composerStateFromShellState(useChatPanelShellState());
|
||||
return h(ComposerShell, controller.renderState(state));
|
||||
return h(ComposerShell, controller.renderState(state, actions));
|
||||
}
|
||||
|
||||
export function chatPanelComposerPlaceholder(surface: ChatPanelComposerSurface, state: ChatPanelComposerShellState): string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
|
||||
import { type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer";
|
||||
import type { ChatStateStore } from "../../state/reducer";
|
||||
import type { ChatConnectionController } from "../../connection/connection-controller";
|
||||
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
|
||||
import type { ChatInboundController } from "../../protocol/inbound/controller";
|
||||
|
|
@ -12,6 +12,8 @@ import type { SelectionActions } from "../../threads/selection-actions";
|
|||
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
|
||||
import type { GoalActions } from "../../threads/goal-actions";
|
||||
import type { RestoredThreadTitleSnapshot, ChatPanelSurface } from "./model";
|
||||
import { createChatPanelGoalSurface } from "./goal-surface";
|
||||
import { createChatPanelToolbarSurface } from "./toolbar-surface";
|
||||
|
||||
export interface ChatPanelSurfaceHost {
|
||||
settings: CodexPanelSettings;
|
||||
|
|
@ -36,108 +38,37 @@ export interface ChatPanelSurfaceDependencies {
|
|||
}
|
||||
|
||||
export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPanelSurfaceDependencies): ChatPanelSurface {
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
host.stateStore.dispatch(action);
|
||||
};
|
||||
const startGoalEditing = ({ closeToolbarPanel = false }: { closeToolbarPanel?: boolean } = {}): void => {
|
||||
if (closeToolbarPanel) dispatch({ type: "ui/panel-set", panel: null });
|
||||
const goal = host.stateStore.getState().activeThread.goal;
|
||||
dispatch({
|
||||
type: "ui/goal-editor-started",
|
||||
threadId: goal?.threadId ?? null,
|
||||
objective: goal?.objective ?? "",
|
||||
tokenBudget: goal?.tokenBudget ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
toolbar: {
|
||||
state: {
|
||||
connected: () => deps.connection.isConnected(),
|
||||
nowMs: () => Date.now(),
|
||||
toolbar: createChatPanelToolbarSurface(
|
||||
{
|
||||
settings: host.settings,
|
||||
vaultPath: host.vaultPath,
|
||||
stateStore: host.stateStore,
|
||||
startNewThread: host.startNewThread,
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => host.vaultPath,
|
||||
configuredCommand: () => host.settings.codexPath,
|
||||
archiveExportEnabled: () => host.settings.archiveExportEnabled,
|
||||
{
|
||||
connection: deps.connection,
|
||||
connectionController: deps.connectionController,
|
||||
reconnectActions: deps.reconnectActions,
|
||||
inboundController: deps.inboundController,
|
||||
threadActions: deps.threadActions,
|
||||
toolbarPanels: deps.toolbarPanels,
|
||||
rename: deps.rename,
|
||||
selection: deps.selection,
|
||||
},
|
||||
actions: {
|
||||
toolbar: {
|
||||
startNewThread: () => {
|
||||
void host.startNewThread();
|
||||
},
|
||||
toggleChatActions: () => {
|
||||
deps.toolbarPanels.toggleChatActions();
|
||||
},
|
||||
compactConversation: () => {
|
||||
void compactConversation(host.stateStore.getState(), deps);
|
||||
},
|
||||
setGoal: () => {
|
||||
startGoalEditing({ closeToolbarPanel: true });
|
||||
},
|
||||
toggleHistory: () => {
|
||||
deps.toolbarPanels.toggleHistory();
|
||||
},
|
||||
toggleStatusPanel: () => {
|
||||
deps.toolbarPanels.toggleStatus();
|
||||
},
|
||||
connect: () => {
|
||||
void deps.reconnectActions.reconnectPanel();
|
||||
},
|
||||
refreshStatus: () => {
|
||||
void deps.connectionController.refreshStatusPanel();
|
||||
},
|
||||
resumeThread: (threadId) => {
|
||||
void deps.selection.selectThreadFromToolbar(threadId);
|
||||
},
|
||||
startArchiveThread: (threadId) => {
|
||||
deps.toolbarPanels.startArchive(threadId);
|
||||
},
|
||||
archiveThread: (threadId, saveMarkdown) => {
|
||||
void deps.toolbarPanels.archiveThread(threadId, saveMarkdown);
|
||||
},
|
||||
startRenameThread: (threadId) => {
|
||||
deps.rename.start(threadId);
|
||||
},
|
||||
updateRenameDraft: (threadId, value) => {
|
||||
deps.rename.updateDraft(threadId, value);
|
||||
},
|
||||
saveRenameThread: (threadId, value) => {
|
||||
void deps.rename.save(threadId, value);
|
||||
},
|
||||
cancelRenameThread: (threadId) => {
|
||||
deps.rename.cancel(threadId);
|
||||
},
|
||||
autoNameThread: (threadId) => {
|
||||
void deps.rename.autoNameDraft(threadId);
|
||||
},
|
||||
},
|
||||
),
|
||||
goal: createChatPanelGoalSurface(
|
||||
{
|
||||
settings: host.settings,
|
||||
stateStore: host.stateStore,
|
||||
},
|
||||
},
|
||||
goal: {
|
||||
settings: {
|
||||
sendShortcut: () => host.settings.sendShortcut,
|
||||
{
|
||||
connectionController: deps.connectionController,
|
||||
inboundController: deps.inboundController,
|
||||
serverThreads: deps.serverThreads,
|
||||
goals: deps.goals,
|
||||
},
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget),
|
||||
setStatus: (threadId, status) => deps.goals.setStatus(threadId, status),
|
||||
clear: (threadId) => deps.goals.clear(threadId),
|
||||
startEditing: (threadId, objective, tokenBudget) => {
|
||||
dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
||||
},
|
||||
updateObjectiveDraft: (objective) => {
|
||||
dispatch({ type: "ui/goal-editor-draft-updated", objective });
|
||||
},
|
||||
setObjectiveExpanded: (threadId, expanded) => {
|
||||
dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded });
|
||||
},
|
||||
closeEditor: () => {
|
||||
dispatch({ type: "ui/goal-editor-closed" });
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
composer: {
|
||||
thread: {
|
||||
restoredPlaceholder: host.restoredThreadPlaceholder,
|
||||
|
|
@ -150,33 +81,3 @@ export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPan
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function compactConversation(state: ChatState, deps: ChatPanelSurfaceDependencies): Promise<void> {
|
||||
const threadId = state.activeThread.id;
|
||||
if (!threadId) {
|
||||
deps.inboundController.addSystemMessage("No active thread to compact.");
|
||||
return;
|
||||
}
|
||||
await deps.threadActions.compactThread(threadId);
|
||||
}
|
||||
|
||||
async function saveGoalObjective(
|
||||
state: ChatState,
|
||||
deps: ChatPanelSurfaceDependencies,
|
||||
objective: string,
|
||||
tokenBudget: number | null,
|
||||
): Promise<void> {
|
||||
let threadId = state.activeThread.id;
|
||||
if (!threadId) {
|
||||
try {
|
||||
await deps.connectionController.ensureConnected();
|
||||
const response = await deps.serverThreads.startThread(objective, { syncGoal: false });
|
||||
threadId = response?.threadId ?? null;
|
||||
} catch (error) {
|
||||
deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!threadId) return;
|
||||
void deps.goals.setObjective(threadId, objective, tokenBudget);
|
||||
}
|
||||
|
|
|
|||
71
src/features/chat/panel/surface/goal-surface.ts
Normal file
71
src/features/chat/panel/surface/goal-surface.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import type { ChatConnectionController } from "../../connection/connection-controller";
|
||||
import type { ChatInboundController } from "../../protocol/inbound/controller";
|
||||
import type { ChatServerThreadActions } from "../../connection/server-actions/threads";
|
||||
import type { GoalActions } from "../../threads/goal-actions";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer";
|
||||
import type { ChatPanelGoalSurface } from "./model";
|
||||
|
||||
export interface ChatPanelGoalSurfaceHost {
|
||||
settings: CodexPanelSettings;
|
||||
stateStore: ChatStateStore;
|
||||
}
|
||||
|
||||
export interface ChatPanelGoalSurfaceDependencies {
|
||||
connectionController: ChatConnectionController;
|
||||
inboundController: ChatInboundController;
|
||||
serverThreads: ChatServerThreadActions;
|
||||
goals: GoalActions;
|
||||
}
|
||||
|
||||
export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps: ChatPanelGoalSurfaceDependencies): ChatPanelGoalSurface {
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
host.stateStore.dispatch(action);
|
||||
};
|
||||
|
||||
return {
|
||||
settings: {
|
||||
sendShortcut: () => host.settings.sendShortcut,
|
||||
},
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget),
|
||||
setStatus: (threadId, status) => deps.goals.setStatus(threadId, status),
|
||||
clear: (threadId) => deps.goals.clear(threadId),
|
||||
startEditing: (threadId, objective, tokenBudget) => {
|
||||
dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
||||
},
|
||||
updateObjectiveDraft: (objective) => {
|
||||
dispatch({ type: "ui/goal-editor-draft-updated", objective });
|
||||
},
|
||||
setObjectiveExpanded: (threadId, expanded) => {
|
||||
dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded });
|
||||
},
|
||||
closeEditor: () => {
|
||||
dispatch({ type: "ui/goal-editor-closed" });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveGoalObjective(
|
||||
state: ChatState,
|
||||
deps: ChatPanelGoalSurfaceDependencies,
|
||||
objective: string,
|
||||
tokenBudget: number | null,
|
||||
): Promise<void> {
|
||||
let threadId = state.activeThread.id;
|
||||
if (!threadId) {
|
||||
try {
|
||||
await deps.connectionController.ensureConnected();
|
||||
const response = await deps.serverThreads.startThread(objective, { syncGoal: false });
|
||||
threadId = response?.threadId ?? null;
|
||||
} catch (error) {
|
||||
deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!threadId) return;
|
||||
void deps.goals.setObjective(threadId, objective, tokenBudget);
|
||||
}
|
||||
117
src/features/chat/panel/surface/toolbar-surface.ts
Normal file
117
src/features/chat/panel/surface/toolbar-surface.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
|
||||
import type { ChatConnectionController } from "../../connection/connection-controller";
|
||||
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
|
||||
import type { ChatInboundController } from "../../protocol/inbound/controller";
|
||||
import type { ChatThreadActions } from "../../threads/action-context";
|
||||
import type { ToolbarPanelActions } from "../toolbar-actions";
|
||||
import type { RenameController } from "../../threads/rename-controller";
|
||||
import type { SelectionActions } from "../../threads/selection-actions";
|
||||
import type { ChatStateStore } from "../../state/reducer";
|
||||
import type { ChatPanelToolbarSurface } from "./model";
|
||||
|
||||
export interface ChatPanelToolbarSurfaceHost {
|
||||
settings: CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
stateStore: ChatStateStore;
|
||||
startNewThread: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChatPanelToolbarSurfaceDependencies {
|
||||
connection: ConnectionManager;
|
||||
connectionController: ChatConnectionController;
|
||||
reconnectActions: ChatReconnectActions;
|
||||
inboundController: ChatInboundController;
|
||||
threadActions: ChatThreadActions;
|
||||
toolbarPanels: ToolbarPanelActions;
|
||||
rename: RenameController;
|
||||
selection: SelectionActions;
|
||||
}
|
||||
|
||||
export function createChatPanelToolbarSurface(
|
||||
host: ChatPanelToolbarSurfaceHost,
|
||||
deps: ChatPanelToolbarSurfaceDependencies,
|
||||
): ChatPanelToolbarSurface {
|
||||
return {
|
||||
state: {
|
||||
connected: () => deps.connection.isConnected(),
|
||||
nowMs: () => Date.now(),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => host.vaultPath,
|
||||
configuredCommand: () => host.settings.codexPath,
|
||||
archiveExportEnabled: () => host.settings.archiveExportEnabled,
|
||||
},
|
||||
actions: {
|
||||
toolbar: {
|
||||
startNewThread: () => {
|
||||
void host.startNewThread();
|
||||
},
|
||||
toggleChatActions: () => {
|
||||
deps.toolbarPanels.toggleChatActions();
|
||||
},
|
||||
compactConversation: () => {
|
||||
void compactConversation(host.stateStore.getState(), deps);
|
||||
},
|
||||
setGoal: () => {
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const goal = host.stateStore.getState().activeThread.goal;
|
||||
host.stateStore.dispatch({
|
||||
type: "ui/goal-editor-started",
|
||||
threadId: goal?.threadId ?? null,
|
||||
objective: goal?.objective ?? "",
|
||||
tokenBudget: goal?.tokenBudget ?? null,
|
||||
});
|
||||
},
|
||||
toggleHistory: () => {
|
||||
deps.toolbarPanels.toggleHistory();
|
||||
},
|
||||
toggleStatusPanel: () => {
|
||||
deps.toolbarPanels.toggleStatus();
|
||||
},
|
||||
connect: () => {
|
||||
void deps.reconnectActions.reconnectPanel();
|
||||
},
|
||||
refreshStatus: () => {
|
||||
void deps.connectionController.refreshStatusPanel();
|
||||
},
|
||||
resumeThread: (threadId) => {
|
||||
void deps.selection.selectThreadFromToolbar(threadId);
|
||||
},
|
||||
startArchiveThread: (threadId) => {
|
||||
deps.toolbarPanels.startArchive(threadId);
|
||||
},
|
||||
archiveThread: (threadId, saveMarkdown) => {
|
||||
void deps.toolbarPanels.archiveThread(threadId, saveMarkdown);
|
||||
},
|
||||
startRenameThread: (threadId) => {
|
||||
deps.rename.start(threadId);
|
||||
},
|
||||
updateRenameDraft: (threadId, value) => {
|
||||
deps.rename.updateDraft(threadId, value);
|
||||
},
|
||||
saveRenameThread: (threadId, value) => {
|
||||
void deps.rename.save(threadId, value);
|
||||
},
|
||||
cancelRenameThread: (threadId) => {
|
||||
deps.rename.cancel(threadId);
|
||||
},
|
||||
autoNameThread: (threadId) => {
|
||||
void deps.rename.autoNameDraft(threadId);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function compactConversation(
|
||||
state: ReturnType<ChatStateStore["getState"]>,
|
||||
deps: Pick<ChatPanelToolbarSurfaceDependencies, "inboundController" | "threadActions">,
|
||||
): Promise<void> {
|
||||
const threadId = state.activeThread.id;
|
||||
if (!threadId) {
|
||||
deps.inboundController.addSystemMessage("No active thread to compact.");
|
||||
return;
|
||||
}
|
||||
await deps.threadActions.compactThread(threadId);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions }
|
|||
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "./connection/server-actions/metadata";
|
||||
import { createChatServerThreadActions, type ChatServerThreadActions } from "./connection/server-actions/threads";
|
||||
import type { ChatComposerController } from "./conversation/composer/controller";
|
||||
import { createConversationControllers } from "./conversation/composition";
|
||||
import { createConversationParts } from "./conversation/composition";
|
||||
import type { PendingRequestController } from "./conversation/pending-requests/controller";
|
||||
import type { ComposerSubmitActions } from "./conversation/turns/composer-submit-actions";
|
||||
import { createStructuredSystemItem, createSystemItem } from "./display/items/system";
|
||||
|
|
@ -63,7 +63,7 @@ import type { RenameController } from "./threads/rename-controller";
|
|||
import type { RestorationController } from "./threads/restoration-controller";
|
||||
import type { ResumeController } from "./threads/resume-controller";
|
||||
import type { SelectionActions } from "./threads/selection-actions";
|
||||
import { createThreadSelectionActions, createThreadServices } from "./threads/services";
|
||||
import { createThreadParts, createThreadSelectionActions } from "./threads/composition";
|
||||
import type { MessageStreamPresenter } from "./panel/surface/message-stream-presenter";
|
||||
import { pendingRequestsSignature } from "./conversation/pending-requests/signatures";
|
||||
import { createChatPanelSurface } from "./panel/surface/create-surface";
|
||||
|
|
@ -292,16 +292,29 @@ export class ChatPanelSession {
|
|||
}
|
||||
|
||||
private createSessionParts(): ChatPanelSessionParts {
|
||||
const connection = new ConnectionManager(() => this.environment.plugin.settings.codexPath, this.environment.plugin.vaultPath);
|
||||
const connection = new ConnectionManager(() => this.environment.plugin.settings.codexPath, this.environment.plugin.vaultPath, {
|
||||
onNotification: (notification) => {
|
||||
this.parts.inbound.controller.handleNotification(notification);
|
||||
this.refreshLiveState();
|
||||
},
|
||||
onServerRequest: (request) => {
|
||||
this.parts.inbound.controller.handleServerRequest(request);
|
||||
this.refreshLiveState();
|
||||
},
|
||||
onLog: (message) => {
|
||||
this.parts.inbound.controller.handleAppServerLog(message);
|
||||
},
|
||||
onExit: () => {
|
||||
this.parts.connection.controller.handleExit();
|
||||
},
|
||||
});
|
||||
const sideEffects = this.createSideEffects();
|
||||
let connectionController: ChatConnectionController | null = null;
|
||||
let selection: SelectionActions | null = null;
|
||||
const currentClient = () => this.client;
|
||||
const ensureConnected = () => requireSessionPart(connectionController, "connection controller").ensureConnected();
|
||||
const refreshThreads = () => requireSessionPart(connectionController, "connection controller").refreshThreads();
|
||||
const refreshSkills = (forceReload?: boolean) =>
|
||||
requireSessionPart(connectionController, "connection controller").refreshSkills(forceReload);
|
||||
const selectThread = (threadId: string) => requireSessionPart(selection, "selection actions").selectThread(threadId);
|
||||
const ensureConnected = () => this.parts.connection.controller.ensureConnected();
|
||||
const refreshThreads = () => this.parts.connection.controller.refreshThreads();
|
||||
const refreshSkills = (forceReload?: boolean) => this.parts.connection.controller.refreshSkills(forceReload);
|
||||
const selectThread = (threadId: string) => this.parts.thread.selection.selectThread(threadId);
|
||||
const resumeRestoredThread = (threadId: string) => this.parts.thread.resume.resumeThread(threadId);
|
||||
|
||||
const runtimeSettings = createChatRuntimeSettingsActions({
|
||||
stateStore: this.stateStore,
|
||||
|
|
@ -310,7 +323,7 @@ export class ChatPanelSession {
|
|||
collaborationModeLabel: () => this.collaborationModeLabel(),
|
||||
addSystemMessage: sideEffects.status.addSystemMessage,
|
||||
});
|
||||
const threadControllers = createThreadServices(
|
||||
const threadParts = createThreadParts(
|
||||
{
|
||||
obsidian: {
|
||||
archiveAdapter: this.environment.obsidian.archiveAdapter,
|
||||
|
|
@ -332,6 +345,7 @@ export class ChatPanelSession {
|
|||
status: sideEffects.status,
|
||||
thread: {
|
||||
selectThread,
|
||||
resumeRestoredThread,
|
||||
refreshThreads,
|
||||
notifyIdentityChanged: () => {
|
||||
this.notifyActiveThreadIdentityChanged();
|
||||
|
|
@ -359,12 +373,12 @@ export class ChatPanelSession {
|
|||
connection,
|
||||
},
|
||||
);
|
||||
const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadControllers;
|
||||
const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadParts;
|
||||
const toolbarPanels = createToolbarPanelActions({
|
||||
stateStore: this.stateStore,
|
||||
threadActions,
|
||||
});
|
||||
selection = createThreadSelectionActions(
|
||||
const selection = createThreadSelectionActions(
|
||||
{
|
||||
plugin: this.environment.plugin,
|
||||
state: {
|
||||
|
|
@ -380,7 +394,7 @@ export class ChatPanelSession {
|
|||
toolbarPanels.closeForThreadSelection();
|
||||
},
|
||||
},
|
||||
).selection;
|
||||
);
|
||||
|
||||
const reconnectActions = createChatReconnectActions({
|
||||
stateStore: this.stateStore,
|
||||
|
|
@ -458,7 +472,7 @@ export class ChatPanelSession {
|
|||
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
|
||||
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
|
||||
});
|
||||
connectionController = new ChatConnectionController({
|
||||
const connectionController = new ChatConnectionController({
|
||||
stateStore: this.stateStore,
|
||||
connection,
|
||||
connectionWork: this.connectionWork,
|
||||
|
|
@ -504,23 +518,6 @@ export class ChatPanelSession {
|
|||
},
|
||||
});
|
||||
|
||||
connection.setHandlers({
|
||||
onNotification: (notification) => {
|
||||
inboundController.handleNotification(notification);
|
||||
this.refreshLiveState();
|
||||
},
|
||||
onServerRequest: (request) => {
|
||||
inboundController.handleServerRequest(request);
|
||||
this.refreshLiveState();
|
||||
},
|
||||
onLog: (message) => {
|
||||
inboundController.handleAppServerLog(message);
|
||||
},
|
||||
onExit: () => {
|
||||
requireSessionPart(connectionController, "connection controller").handleExit();
|
||||
},
|
||||
});
|
||||
|
||||
const surface = createChatPanelSurface(
|
||||
{
|
||||
settings: this.environment.plugin.settings,
|
||||
|
|
@ -543,7 +540,7 @@ export class ChatPanelSession {
|
|||
goals,
|
||||
},
|
||||
);
|
||||
const conversationControllers = createConversationControllers(
|
||||
const conversationParts = createConversationParts(
|
||||
{
|
||||
obsidian: {
|
||||
app: this.environment.obsidian.app,
|
||||
|
|
@ -614,8 +611,8 @@ export class ChatPanelSession {
|
|||
history,
|
||||
},
|
||||
);
|
||||
const { pendingRequests, composerSubmit, messageStreamPresenter } = conversationControllers;
|
||||
const composerController = conversationControllers.composerController;
|
||||
const { pendingRequests, composerSubmit, messageStreamPresenter } = conversationParts;
|
||||
const composerController = conversationParts.composerController;
|
||||
|
||||
return {
|
||||
connection: {
|
||||
|
|
@ -770,7 +767,12 @@ export class ChatPanelSession {
|
|||
toolbar: this.parts.surface.toolbar,
|
||||
goal: this.parts.surface.goal,
|
||||
messageStream: this.parts.render.messageStreamPresenter,
|
||||
composer: this.parts.composer.controller,
|
||||
composer: {
|
||||
controller: this.parts.composer.controller,
|
||||
actions: {
|
||||
submit: () => void this.parts.composer.submission.submit(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -895,8 +897,3 @@ export class ChatPanelSession {
|
|||
return createStructuredSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text, details);
|
||||
}
|
||||
}
|
||||
|
||||
function requireSessionPart<T>(part: T | null, name: string): T {
|
||||
if (!part) throw new Error(`Chat panel session did not initialize ${name}.`);
|
||||
return part;
|
||||
}
|
||||
|
|
|
|||
69
src/features/chat/threads/action-parts.ts
Normal file
69
src/features/chat/threads/action-parts.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown";
|
||||
import type { ChatStateStore } from "../state/reducer";
|
||||
import type { CodexChatHost } from "../chat-host";
|
||||
import { archiveThread } from "./archive-actions";
|
||||
import { compactThread } from "./compact-actions";
|
||||
import { forkThread, forkThreadFromTurn } from "./fork-actions";
|
||||
import { renameThread } from "./rename-actions";
|
||||
import { rollbackThread } from "./rollback-actions";
|
||||
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
|
||||
|
||||
export interface ThreadActionPartsContext {
|
||||
obsidian: {
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
};
|
||||
plugin: CodexChatHost;
|
||||
stateStore: ChatStateStore;
|
||||
client: {
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
status: {
|
||||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
thread: {
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
refreshThreads: () => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
};
|
||||
composer: {
|
||||
setText: (text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadActions(context: ThreadActionPartsContext): ChatThreadActions {
|
||||
const { obsidian, plugin, stateStore, client, status, thread, composer } = context;
|
||||
const host: ChatThreadActionsHost = {
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
archiveAdapter: obsidian.archiveAdapter,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient: client.currentClient,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
setStatus: status.set,
|
||||
setComposerText: composer.setText,
|
||||
openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId),
|
||||
openThreadInCurrentPanel: thread.selectThread,
|
||||
notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin),
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
plugin.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
refreshThreads: thread.refreshThreads,
|
||||
refreshSharedThreadListFromOpenSurface: () => {
|
||||
plugin.refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
compactThread: (threadId) => compactThread(host, threadId),
|
||||
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
|
||||
forkThread: (threadId) => forkThread(host, threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource),
|
||||
renameThread: (threadId, name) => renameThread(host, threadId, name),
|
||||
rollbackThread: (threadId) => rollbackThread(host, threadId),
|
||||
};
|
||||
}
|
||||
172
src/features/chat/threads/composition.ts
Normal file
172
src/features/chat/threads/composition.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown";
|
||||
import { createGoalActions } from "./goal-actions";
|
||||
import { createSelectionActions } from "./selection-actions";
|
||||
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
|
||||
import type { ChatStateStore } from "../state/reducer";
|
||||
import type { CodexChatHost } from "../chat-host";
|
||||
import { createThreadNamingParts } from "./naming-parts";
|
||||
import { createThreadActions } from "./action-parts";
|
||||
import { createThreadLifecycleParts } from "./lifecycle-parts";
|
||||
|
||||
interface ThreadPartsContext {
|
||||
obsidian: {
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
};
|
||||
plugin: CodexChatHost;
|
||||
state: {
|
||||
stateStore: ChatStateStore;
|
||||
};
|
||||
client: {
|
||||
getClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
lifecycle: {
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
resumeWork: ChatResumeWorkTracker;
|
||||
getOpened: () => boolean;
|
||||
getClosing: () => boolean;
|
||||
};
|
||||
thread: {
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
resumeRestoredThread: (threadId: string) => Promise<void>;
|
||||
refreshThreads: () => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
status: {
|
||||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
liveState: {
|
||||
refresh: () => void;
|
||||
};
|
||||
scroll: {
|
||||
preservePosition: () => void;
|
||||
forceBottom: () => void;
|
||||
};
|
||||
composer: {
|
||||
setText: (text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadParts(
|
||||
context: ThreadPartsContext,
|
||||
refs: {
|
||||
connection: ConnectionManager;
|
||||
},
|
||||
) {
|
||||
const { obsidian, plugin, state, thread, status, liveState, scroll, client, composer, lifecycle } = context;
|
||||
const stateStore = state.stateStore;
|
||||
const currentClient = client.getClient;
|
||||
|
||||
const naming = createThreadNamingParts(
|
||||
{
|
||||
plugin,
|
||||
stateStore,
|
||||
client: {
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
},
|
||||
status: {
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
},
|
||||
},
|
||||
{
|
||||
connection: refs.connection,
|
||||
},
|
||||
);
|
||||
const { rename, autoTitle, resetThreadTurnPresence } = naming;
|
||||
|
||||
const actions = createThreadActions({
|
||||
obsidian,
|
||||
plugin,
|
||||
stateStore,
|
||||
client: {
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
},
|
||||
status,
|
||||
thread: {
|
||||
selectThread: thread.selectThread,
|
||||
refreshThreads: thread.refreshThreads,
|
||||
notifyIdentityChanged: thread.notifyIdentityChanged,
|
||||
},
|
||||
composer,
|
||||
});
|
||||
const goals = createGoalActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
addGoalEvent: (item) => {
|
||||
stateStore.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: liveState.refresh,
|
||||
});
|
||||
const threadLifecycle = createThreadLifecycleParts({
|
||||
plugin,
|
||||
stateStore,
|
||||
client: {
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
},
|
||||
lifecycle,
|
||||
thread: {
|
||||
resumeRestoredThread: thread.resumeRestoredThread,
|
||||
notifyIdentityChanged: thread.notifyIdentityChanged,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
},
|
||||
status,
|
||||
liveState,
|
||||
scroll,
|
||||
goals,
|
||||
resetThreadTurnPresence,
|
||||
});
|
||||
const { history, restoration, resume, identity } = threadLifecycle;
|
||||
|
||||
return {
|
||||
history,
|
||||
actions,
|
||||
goals,
|
||||
restoration,
|
||||
resume,
|
||||
identity,
|
||||
rename,
|
||||
autoTitle,
|
||||
};
|
||||
}
|
||||
|
||||
interface ThreadSelectionActionsContext {
|
||||
plugin: CodexChatHost;
|
||||
state: {
|
||||
stateStore: ChatStateStore;
|
||||
};
|
||||
status: {
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
thread: {
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadSelectionActions(
|
||||
context: ThreadSelectionActionsContext,
|
||||
refs: {
|
||||
closeForThreadSelection: () => void;
|
||||
},
|
||||
) {
|
||||
const { plugin, thread, status } = context;
|
||||
const stateStore = context.state.stateStore;
|
||||
|
||||
return createSelectionActions({
|
||||
stateStore,
|
||||
closeForThreadSelection: () => {
|
||||
refs.closeForThreadSelection();
|
||||
},
|
||||
focusThreadInOpenView: (threadId) => plugin.focusThreadInOpenView(threadId),
|
||||
resumeThread: thread.resumeThread,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
});
|
||||
}
|
||||
118
src/features/chat/threads/lifecycle-parts.ts
Normal file
118
src/features/chat/threads/lifecycle-parts.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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/reducer";
|
||||
import type { CodexChatHost } from "../chat-host";
|
||||
import type { GoalActions } from "./goal-actions";
|
||||
import { HistoryController } from "./history-controller";
|
||||
import { createIdentitySync } from "./identity-sync";
|
||||
import { ResumeController } from "./resume-controller";
|
||||
import { RestorationController } from "./restoration-controller";
|
||||
|
||||
export interface ThreadLifecyclePartsContext {
|
||||
plugin: CodexChatHost;
|
||||
stateStore: ChatStateStore;
|
||||
client: {
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
lifecycle: {
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
resumeWork: ChatResumeWorkTracker;
|
||||
getOpened: () => boolean;
|
||||
getClosing: () => boolean;
|
||||
};
|
||||
thread: {
|
||||
resumeRestoredThread: (threadId: string) => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
status: {
|
||||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
liveState: {
|
||||
refresh: () => void;
|
||||
};
|
||||
scroll: {
|
||||
preservePosition: () => void;
|
||||
forceBottom: () => void;
|
||||
};
|
||||
goals: GoalActions;
|
||||
resetThreadTurnPresence: (hadTurns: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ThreadLifecycleParts {
|
||||
history: HistoryController;
|
||||
restoration: RestorationController;
|
||||
resume: ResumeController;
|
||||
identity: ReturnType<typeof createIdentitySync>;
|
||||
}
|
||||
|
||||
export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts {
|
||||
const { plugin, stateStore, client, lifecycle, thread, status, liveState, scroll, goals, resetThreadTurnPresence } = context;
|
||||
const { deferredTasks, resumeWork } = lifecycle;
|
||||
const history = new HistoryController({
|
||||
stateStore,
|
||||
currentClient: client.currentClient,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
keepCurrentScrollPosition: scroll.preservePosition,
|
||||
showLatestPageAtBottom: scroll.forceBottom,
|
||||
setThreadTurnPresence: resetThreadTurnPresence,
|
||||
});
|
||||
const invalidateResumeWork = () => {
|
||||
resumeWork.invalidate();
|
||||
history.invalidate();
|
||||
};
|
||||
const restoration = new RestorationController({
|
||||
deferredTasks,
|
||||
opened: lifecycle.getOpened,
|
||||
resumeThread: thread.resumeRestoredThread,
|
||||
invalidateResumeWork,
|
||||
stateStore,
|
||||
setStatus: status.set,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
});
|
||||
const resume = new ResumeController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
resumeWork,
|
||||
history,
|
||||
restoration,
|
||||
currentClient: client.currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
closing: lifecycle.getClosing,
|
||||
resetThreadTurnPresence,
|
||||
clearDeferredRestoredThreadHydration: () => {
|
||||
restoration.clearHydration();
|
||||
},
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
refreshLiveState: liveState.refresh,
|
||||
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),
|
||||
recoverTokenUsageFromRollout: (path) =>
|
||||
recoverRolloutTokenUsage(path, async (filePath, options) => {
|
||||
const response = await client.currentClient()?.readFile(filePath, options);
|
||||
return response?.dataBase64 ?? "";
|
||||
}),
|
||||
});
|
||||
const identity = createIdentitySync({
|
||||
stateStore,
|
||||
restoration,
|
||||
invalidateResumeWork,
|
||||
clearDeferredRestoredThreadHydration: () => {
|
||||
restoration.clearHydration();
|
||||
},
|
||||
resetThreadTurnPresence,
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
refreshLiveState: liveState.refresh,
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
restoration,
|
||||
resume,
|
||||
identity,
|
||||
};
|
||||
}
|
||||
56
src/features/chat/threads/naming-parts.ts
Normal file
56
src/features/chat/threads/naming-parts.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { ChatStateStore } from "../state/reducer";
|
||||
import type { CodexChatHost } from "../chat-host";
|
||||
import { AutoTitleController } from "./auto-title-controller";
|
||||
import { RenameController } from "./rename-controller";
|
||||
|
||||
export interface ThreadNamingPartsContext {
|
||||
plugin: CodexChatHost;
|
||||
stateStore: ChatStateStore;
|
||||
client: {
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
status: {
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThreadNamingPartsRefs {
|
||||
connection: ConnectionManager;
|
||||
}
|
||||
|
||||
export interface ThreadNamingParts {
|
||||
rename: RenameController;
|
||||
autoTitle: AutoTitleController;
|
||||
resetThreadTurnPresence: (hadTurns: boolean) => void;
|
||||
}
|
||||
|
||||
export function createThreadNamingParts(context: ThreadNamingPartsContext, refs: ThreadNamingPartsRefs): ThreadNamingParts {
|
||||
const { plugin, stateStore, client, status } = context;
|
||||
const rename = new RenameController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient: () => refs.connection.currentClient(),
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
const autoTitle = new AutoTitleController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
currentClient: client.currentClient,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
|
||||
return {
|
||||
rename,
|
||||
autoTitle,
|
||||
resetThreadTurnPresence: (hadTurns) => {
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import { recoverRolloutTokenUsage } from "../../../app-server/services/rollout-token-usage";
|
||||
import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown";
|
||||
import { archiveThread } from "./archive-actions";
|
||||
import { AutoTitleController } from "./auto-title-controller";
|
||||
import { compactThread } from "./compact-actions";
|
||||
import { forkThread, forkThreadFromTurn } from "./fork-actions";
|
||||
import { createGoalActions } from "./goal-actions";
|
||||
import { HistoryController } from "./history-controller";
|
||||
import { createIdentitySync } from "./identity-sync";
|
||||
import { RenameController } from "./rename-controller";
|
||||
import { renameThread } from "./rename-actions";
|
||||
import { ResumeController } from "./resume-controller";
|
||||
import { rollbackThread } from "./rollback-actions";
|
||||
import { createSelectionActions } from "./selection-actions";
|
||||
import { RestorationController } from "./restoration-controller";
|
||||
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
|
||||
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
|
||||
import type { ChatStateStore } from "../state/reducer";
|
||||
import type { CodexChatHost } from "../chat-host";
|
||||
|
||||
interface ThreadServicesContext {
|
||||
obsidian: {
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
};
|
||||
plugin: CodexChatHost;
|
||||
state: {
|
||||
stateStore: ChatStateStore;
|
||||
};
|
||||
client: {
|
||||
getClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
lifecycle: {
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
resumeWork: ChatResumeWorkTracker;
|
||||
getOpened: () => boolean;
|
||||
getClosing: () => boolean;
|
||||
};
|
||||
thread: {
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
refreshThreads: () => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
status: {
|
||||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
liveState: {
|
||||
refresh: () => void;
|
||||
};
|
||||
scroll: {
|
||||
preservePosition: () => void;
|
||||
forceBottom: () => void;
|
||||
};
|
||||
composer: {
|
||||
setText: (text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadServices(
|
||||
context: ThreadServicesContext,
|
||||
refs: {
|
||||
connection: ConnectionManager;
|
||||
},
|
||||
) {
|
||||
const { obsidian, plugin, state, thread, status, liveState, scroll, client, composer, lifecycle } = context;
|
||||
const stateStore = state.stateStore;
|
||||
const currentClient = client.getClient;
|
||||
const { deferredTasks, resumeWork } = lifecycle;
|
||||
|
||||
const rename = new RenameController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient: () => refs.connection.currentClient(),
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
const autoTitle = new AutoTitleController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
currentClient,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
const resetThreadTurnPresence = (hadTurns: boolean) => {
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
};
|
||||
const history = new HistoryController({
|
||||
stateStore,
|
||||
currentClient,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
keepCurrentScrollPosition: scroll.preservePosition,
|
||||
showLatestPageAtBottom: scroll.forceBottom,
|
||||
setThreadTurnPresence: resetThreadTurnPresence,
|
||||
});
|
||||
const invalidateResumeWork = () => {
|
||||
resumeWork.invalidate();
|
||||
history.invalidate();
|
||||
};
|
||||
const threadActionHost: ChatThreadActionsHost = {
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
archiveAdapter: obsidian.archiveAdapter,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
setStatus: status.set,
|
||||
setComposerText: composer.setText,
|
||||
openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId),
|
||||
openThreadInCurrentPanel: thread.selectThread,
|
||||
notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin),
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
plugin.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
refreshThreads: thread.refreshThreads,
|
||||
refreshSharedThreadListFromOpenSurface: () => {
|
||||
plugin.refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
};
|
||||
const actions: ChatThreadActions = {
|
||||
compactThread: (threadId) => compactThread(threadActionHost, threadId),
|
||||
archiveThread: (threadId, saveMarkdown) => archiveThread(threadActionHost, threadId, saveMarkdown),
|
||||
forkThread: (threadId) => forkThread(threadActionHost, threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(threadActionHost, threadId, turnId, archiveSource),
|
||||
renameThread: (threadId, name) => renameThread(threadActionHost, threadId, name),
|
||||
rollbackThread: (threadId) => rollbackThread(threadActionHost, threadId),
|
||||
};
|
||||
const goals = createGoalActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
addGoalEvent: (item) => {
|
||||
stateStore.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: liveState.refresh,
|
||||
});
|
||||
let resume: ResumeController | null = null;
|
||||
const restoration = new RestorationController({
|
||||
deferredTasks,
|
||||
opened: lifecycle.getOpened,
|
||||
resumeThread: (threadId) => requireThreadController(resume, "resume controller").resumeThread(threadId),
|
||||
invalidateResumeWork,
|
||||
stateStore,
|
||||
setStatus: status.set,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
});
|
||||
resume = new ResumeController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
resumeWork,
|
||||
history,
|
||||
restoration,
|
||||
currentClient,
|
||||
ensureConnected: client.ensureConnected,
|
||||
closing: lifecycle.getClosing,
|
||||
resetThreadTurnPresence,
|
||||
clearDeferredRestoredThreadHydration: () => {
|
||||
restoration.clearHydration();
|
||||
},
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
refreshLiveState: liveState.refresh,
|
||||
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),
|
||||
recoverTokenUsageFromRollout: (path) =>
|
||||
recoverRolloutTokenUsage(path, async (filePath, options) => {
|
||||
const response = await currentClient()?.readFile(filePath, options);
|
||||
return response?.dataBase64 ?? "";
|
||||
}),
|
||||
});
|
||||
const identity = createIdentitySync({
|
||||
stateStore,
|
||||
restoration,
|
||||
invalidateResumeWork,
|
||||
clearDeferredRestoredThreadHydration: () => {
|
||||
restoration.clearHydration();
|
||||
},
|
||||
resetThreadTurnPresence,
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
refreshLiveState: liveState.refresh,
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
actions,
|
||||
goals,
|
||||
restoration,
|
||||
resume: requireThreadController(resume, "resume controller"),
|
||||
identity,
|
||||
rename,
|
||||
autoTitle,
|
||||
invalidateResumeWork,
|
||||
};
|
||||
}
|
||||
|
||||
function requireThreadController<T>(controller: T | null, name: string): T {
|
||||
if (!controller) throw new Error(`Chat thread controller graph did not initialize ${name}.`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
interface ThreadSelectionActionsContext {
|
||||
plugin: CodexChatHost;
|
||||
state: {
|
||||
stateStore: ChatStateStore;
|
||||
};
|
||||
status: {
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
thread: {
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadSelectionActions(
|
||||
context: ThreadSelectionActionsContext,
|
||||
refs: {
|
||||
closeForThreadSelection: () => void;
|
||||
},
|
||||
) {
|
||||
const { plugin, thread, status } = context;
|
||||
const stateStore = context.state.stateStore;
|
||||
|
||||
const selection = createSelectionActions({
|
||||
stateStore,
|
||||
closeForThreadSelection: () => {
|
||||
refs.closeForThreadSelection();
|
||||
},
|
||||
focusThreadInOpenView: (threadId) => plugin.focusThreadInOpenView(threadId),
|
||||
resumeThread: thread.resumeThread,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
});
|
||||
|
||||
return { selection };
|
||||
}
|
||||
|
|
@ -5,14 +5,17 @@ import type { ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/sur
|
|||
import { ChatPanelToolbar } from "../panel/surface/toolbar";
|
||||
import { ChatPanelGoal } from "../panel/surface/goal";
|
||||
import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "../panel/surface/message-stream";
|
||||
import { ChatPanelComposer, type ChatPanelComposerController } from "../panel/surface/composer";
|
||||
import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerController } from "../panel/surface/composer";
|
||||
import { ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState, type ChatPanelShellState } from "./shell-state";
|
||||
|
||||
export interface ChatPanelShellParts {
|
||||
toolbar: ChatPanelToolbarSurface;
|
||||
goal: ChatPanelGoalSurface;
|
||||
messageStream: ChatPanelMessageStreamPresenter;
|
||||
composer: ChatPanelComposerController;
|
||||
composer: {
|
||||
controller: ChatPanelComposerController;
|
||||
actions: ChatPanelComposerActions;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelShellProps {
|
||||
|
|
@ -111,7 +114,7 @@ function ChatPanelShell({ showToolbar, parts, shellState }: ChatPanelShellProps
|
|||
</div>
|
||||
<ChatPanelMessageStream presenter={parts.messageStream} />
|
||||
<div className="codex-panel__region codex-panel__region--composer" data-codex-panel-shell-region="composer">
|
||||
<ChatPanelComposer controller={parts.composer} />
|
||||
<ChatPanelComposer controller={parts.composer.controller} actions={parts.composer.actions} />
|
||||
</div>
|
||||
</div>
|
||||
</ChatPanelShellStateContext.Provider>
|
||||
|
|
|
|||
|
|
@ -69,8 +69,7 @@ export class CodexThreadsView extends ItemView {
|
|||
) {
|
||||
super(leaf);
|
||||
this.deferredTasks = createThreadsViewDeferredTasks(() => this.containerEl.win);
|
||||
this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath);
|
||||
this.connection.setHandlers({
|
||||
this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, {
|
||||
onNotification: () => {
|
||||
this.scheduleRefresh();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppServerClient } from "../../src/app-server/connection/client";
|
||||
import { ConnectionManager, StaleConnectionError } from "../../src/app-server/connection/connection-manager";
|
||||
import {
|
||||
ConnectionManager,
|
||||
type ConnectionManagerHandlers,
|
||||
StaleConnectionError,
|
||||
} from "../../src/app-server/connection/connection-manager";
|
||||
import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport";
|
||||
import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages";
|
||||
|
||||
|
|
@ -50,18 +54,13 @@ describe("ConnectionManager", () => {
|
|||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
silentConnectionHandlers(),
|
||||
(codexPath, cwd, handlers) =>
|
||||
new AppServerClient(codexPath, cwd, handlers, 5, (transportHandlers) => {
|
||||
transport = new SilentTransport(transportHandlers);
|
||||
return transport;
|
||||
}),
|
||||
);
|
||||
manager.setHandlers({
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit: () => undefined,
|
||||
});
|
||||
|
||||
await expect(manager.connect()).rejects.toThrow("Codex app-server request timed out: initialize");
|
||||
|
||||
|
|
@ -74,18 +73,13 @@ describe("ConnectionManager", () => {
|
|||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
silentConnectionHandlers(),
|
||||
(codexPath, cwd, handlers) =>
|
||||
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
|
||||
transport = new SilentTransport(transportHandlers);
|
||||
return transport;
|
||||
}),
|
||||
);
|
||||
manager.setHandlers({
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit: () => undefined,
|
||||
});
|
||||
|
||||
const first = manager.connect();
|
||||
const second = manager.connect();
|
||||
|
|
@ -103,18 +97,13 @@ describe("ConnectionManager", () => {
|
|||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
{ ...silentConnectionHandlers(), onExit },
|
||||
(codexPath, cwd, handlers) =>
|
||||
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
|
||||
transport = new SilentTransport(transportHandlers);
|
||||
return transport;
|
||||
}),
|
||||
);
|
||||
manager.setHandlers({
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
});
|
||||
|
||||
const connecting = manager.connect();
|
||||
transport.emitExit();
|
||||
|
|
@ -124,78 +113,19 @@ describe("ConnectionManager", () => {
|
|||
expect(manager.currentClient()).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the latest attached handlers for app-server events", async () => {
|
||||
let transport!: SilentTransport;
|
||||
const firstExit = vi.fn();
|
||||
const secondExit = vi.fn();
|
||||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
(codexPath, cwd, handlers) =>
|
||||
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
|
||||
transport = new SilentTransport(transportHandlers);
|
||||
return transport;
|
||||
}),
|
||||
);
|
||||
manager.setHandlers({
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit: firstExit,
|
||||
});
|
||||
manager.setHandlers({
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit: secondExit,
|
||||
});
|
||||
|
||||
const connecting = manager.connect();
|
||||
transport.emitExit();
|
||||
|
||||
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
|
||||
expect(firstExit).not.toHaveBeenCalled();
|
||||
expect(secondExit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails clearly when app-server events arrive before handlers are attached", async () => {
|
||||
let transport!: SilentTransport;
|
||||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
(codexPath, cwd, handlers) =>
|
||||
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
|
||||
transport = new SilentTransport(transportHandlers);
|
||||
return transport;
|
||||
}),
|
||||
);
|
||||
|
||||
const connecting = manager.connect();
|
||||
|
||||
expect(() => {
|
||||
transport.emitExit();
|
||||
}).toThrow("ConnectionManager handlers have not been attached.");
|
||||
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
|
||||
});
|
||||
|
||||
it("marks initialization completed after disconnect as stale", async () => {
|
||||
let transport!: SilentTransport;
|
||||
const onExit = vi.fn();
|
||||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
{ ...silentConnectionHandlers(), onExit },
|
||||
(codexPath, cwd, handlers) =>
|
||||
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
|
||||
transport = new SilentTransport(transportHandlers);
|
||||
return transport;
|
||||
}),
|
||||
);
|
||||
manager.setHandlers({
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
});
|
||||
|
||||
const connecting = manager.connect();
|
||||
manager.disconnect();
|
||||
|
|
@ -206,3 +136,12 @@ describe("ConnectionManager", () => {
|
|||
expect(manager.currentClient()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function silentConnectionHandlers(): ConnectionManagerHandlers {
|
||||
return {
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit: () => undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { App } from "obsidian";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { h } from "preact";
|
||||
|
||||
import { ChatComposerController } from "../../../../../src/features/chat/conversation/composer/controller";
|
||||
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../../src/features/chat/conversation/composer/controller";
|
||||
import { ComposerShell } from "../../../../../src/features/chat/ui/composer";
|
||||
import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/state/reducer";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
|
|
@ -13,8 +13,13 @@ import { installObsidianDomShims } from "../../../../support/dom";
|
|||
|
||||
installObsidianDomShims();
|
||||
|
||||
function renderComposerController(parent: HTMLElement, controller: ChatComposerController, stateStore: ChatStateStore): void {
|
||||
renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState())));
|
||||
function renderComposerController(
|
||||
parent: HTMLElement,
|
||||
controller: ChatComposerController,
|
||||
stateStore: ChatStateStore,
|
||||
actions: ChatComposerRenderActions = { submit: vi.fn() },
|
||||
): void {
|
||||
renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState(), actions)));
|
||||
}
|
||||
|
||||
describe("ChatComposerController", () => {
|
||||
|
|
@ -32,6 +37,7 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
|
|
@ -87,6 +93,7 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
|
|
@ -149,6 +156,7 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
|
|
@ -204,6 +212,7 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
|
|
@ -241,7 +250,7 @@ describe("ChatComposerController", () => {
|
|||
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false);
|
||||
});
|
||||
|
||||
it("delegates submit events through attached action handlers", () => {
|
||||
it("delegates submit events through render actions", () => {
|
||||
const stateStore = createChatStateStore();
|
||||
stateStore.dispatch({ type: "composer/draft-set", draft: "hello" });
|
||||
const parent = document.createElement("div");
|
||||
|
|
@ -252,6 +261,7 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
|
|
@ -279,12 +289,8 @@ describe("ChatComposerController", () => {
|
|||
onDraftChange: vi.fn(),
|
||||
onHeightChange: vi.fn(),
|
||||
});
|
||||
controller.setActionHandlers({
|
||||
submit,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
});
|
||||
|
||||
renderComposerController(parent, controller, stateStore);
|
||||
renderComposerController(parent, controller, stateStore, { submit });
|
||||
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
|
|
@ -300,6 +306,7 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
threadScrollFromComposer: vi.fn(),
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ function shellProps(store: ReturnType<typeof createChatStateStore>) {
|
|||
interface TestShellParts {
|
||||
parts: ChatPanelShellParts;
|
||||
composerRenderState: ReturnType<
|
||||
typeof vi.fn<(state: ChatPanelComposerShellState) => ReturnType<ChatPanelShellParts["composer"]["renderState"]>>
|
||||
typeof vi.fn<(state: ChatPanelComposerShellState) => ReturnType<ChatPanelShellParts["composer"]["controller"]["renderState"]>>
|
||||
>;
|
||||
messageStreamRenderState: ReturnType<
|
||||
typeof vi.fn<(state: ChatPanelMessageStreamShellState) => ReturnType<ChatPanelShellParts["messageStream"]["renderState"]>>
|
||||
|
|
@ -401,7 +401,12 @@ function trackedShellParts(): TestShellParts {
|
|||
renderState: messageStreamRenderState,
|
||||
},
|
||||
composer: {
|
||||
renderState: composerRenderState,
|
||||
controller: {
|
||||
renderState: composerRenderState,
|
||||
},
|
||||
actions: {
|
||||
submit: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
composerRenderState,
|
||||
|
|
|
|||
|
|
@ -98,45 +98,50 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarActio
|
|||
}),
|
||||
},
|
||||
composer: {
|
||||
renderState: () => ({
|
||||
viewId: "view",
|
||||
draft: "",
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
normalPlaceholder: "Ask Codex to work on this task...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
callbacks: {
|
||||
onInput: vi.fn(),
|
||||
onUpdateSuggestions: vi.fn(),
|
||||
onKeydown: vi.fn(),
|
||||
onSendOrInterrupt: vi.fn(),
|
||||
onHeightChange: vi.fn(),
|
||||
onSuggestionHover: vi.fn(),
|
||||
onSuggestionInsert: vi.fn(),
|
||||
},
|
||||
meta: {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
],
|
||||
percent: "--%",
|
||||
controller: {
|
||||
renderState: () => ({
|
||||
viewId: "view",
|
||||
draft: "",
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
normalPlaceholder: "Ask Codex to work on this task...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
callbacks: {
|
||||
onInput: vi.fn(),
|
||||
onUpdateSuggestions: vi.fn(),
|
||||
onKeydown: vi.fn(),
|
||||
onSendOrInterrupt: vi.fn(),
|
||||
onHeightChange: vi.fn(),
|
||||
onSuggestionHover: vi.fn(),
|
||||
onSuggestionInsert: vi.fn(),
|
||||
},
|
||||
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
||||
model: "default",
|
||||
effort: null,
|
||||
planActive: false,
|
||||
autoReviewActive: false,
|
||||
fastActive: false,
|
||||
modelChoices: [],
|
||||
effortChoices: [],
|
||||
},
|
||||
onComposer: () => undefined,
|
||||
}),
|
||||
meta: {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
],
|
||||
percent: "--%",
|
||||
},
|
||||
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
||||
model: "default",
|
||||
effort: null,
|
||||
planActive: false,
|
||||
autoReviewActive: false,
|
||||
fastActive: false,
|
||||
modelChoices: [],
|
||||
effortChoices: [],
|
||||
},
|
||||
onComposer: () => undefined,
|
||||
}),
|
||||
},
|
||||
actions: {
|
||||
submit: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,15 +39,16 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => {
|
|||
class StaleConnectionError extends Error {}
|
||||
|
||||
class ConnectionManager {
|
||||
private handlers: { onNotification: (notification: ServerNotification) => void; onExit: () => void } | null = null;
|
||||
|
||||
setHandlers(handlers: {
|
||||
onNotification: (notification: ServerNotification) => void;
|
||||
onServerRequest: (request: unknown) => void;
|
||||
onLog: (message: string) => void;
|
||||
onExit: () => void;
|
||||
}): void {
|
||||
this.handlers = handlers;
|
||||
constructor(
|
||||
_codexPath: () => string,
|
||||
_cwd: string,
|
||||
private readonly handlers: {
|
||||
onNotification: (notification: ServerNotification) => void;
|
||||
onServerRequest: (request: unknown) => void;
|
||||
onLog: (message: string) => void;
|
||||
onExit: () => void;
|
||||
},
|
||||
) {
|
||||
connectionMock.state.onNotification = handlers.onNotification;
|
||||
connectionMock.state.onExit = handlers.onExit;
|
||||
}
|
||||
|
|
@ -81,7 +82,7 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => {
|
|||
|
||||
exit(): void {
|
||||
connectionMock.state.connected = false;
|
||||
this.handlers?.onExit();
|
||||
this.handlers.onExit();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => {
|
|||
class StaleConnectionError extends Error {}
|
||||
|
||||
class ConnectionManager {
|
||||
setHandlers(handlers: { onExit: () => void }): void {
|
||||
constructor(_codexPath: () => string, _cwd: string, handlers: { onExit: () => void }) {
|
||||
connectionMock.state.onExit = handlers.onExit;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue