Consolidate low-cohesion chat modules

This commit is contained in:
murashit 2026-06-23 07:47:35 +09:00
parent b94c2ac804
commit 46dd9135f0
24 changed files with 98 additions and 123 deletions

View file

@ -4,8 +4,8 @@ import {
RUNNING_EXECUTION_STATE,
type ExecutionStateByStatus,
} from "../../../domain/message-stream/execution-state";
import { pathsRelativeToRoot } from "../../../domain/message-stream/format/path-labels";
import { permissionRows } from "../../../domain/message-stream/format/permission-rows";
import { pathRelativeToRoot } from "../../../../../shared/path/file-paths";
const AUTO_REVIEW_STATES: ExecutionStateByStatus = {
inProgress: RUNNING_EXECUTION_STATE,
@ -150,7 +150,10 @@ function autoReviewActionRows(action: AutoReviewAction): MessageStreamAuditFact[
return [
{ key: "action", value: "apply patch" },
{ key: "cwd", value: action.cwd },
{ key: "files", value: action.files.length > 0 ? pathsRelativeToRoot([...action.files], action.cwd).join("\n") : "(none)" },
{
key: "files",
value: action.files.length > 0 ? action.files.map((file) => pathRelativeToRoot(file, action.cwd)).join("\n") : "(none)",
},
];
}
if (action.type === "networkAccess") {

View file

@ -1,27 +0,0 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
export interface ChatThreadHistoryPage {
items: MessageStreamItem[];
nextCursor: string | null;
hadTurns: boolean;
}
export async function readChatThreadHistoryPage(
client: AppServerClient,
threadId: string,
cursor: string | null,
limit = 20,
): Promise<ChatThreadHistoryPage> {
return chatThreadHistoryPageFromTurnsPage(await client.threadTurnsList(threadId, cursor, limit));
}
export function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ChatThreadHistoryPage {
return {
items: messageStreamItemsFromTurns(page.data),
nextCursor: page.nextCursor,
hadTurns: page.data.length > 0,
};
}

View file

@ -1,8 +1,15 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/threads";
import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation";
import type { ChatThreadHistoryPage } from "./history";
import { chatThreadHistoryPageFromTurnsPage } from "./history";
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
export interface ChatThreadHistoryPage {
items: MessageStreamItem[];
nextCursor: string | null;
hadTurns: boolean;
}
export interface ChatThreadResumeSnapshot {
activation: ThreadActivationSnapshot;
@ -10,6 +17,23 @@ export interface ChatThreadResumeSnapshot {
initialHistoryPage: ChatThreadHistoryPage | null;
}
export async function readChatThreadHistoryPage(
client: AppServerClient,
threadId: string,
cursor: string | null,
limit = 20,
): Promise<ChatThreadHistoryPage> {
return chatThreadHistoryPageFromTurnsPage(await client.threadTurnsList(threadId, cursor, limit));
}
function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ChatThreadHistoryPage {
return {
items: messageStreamItemsFromTurns(page.data),
nextCursor: page.nextCursor,
hadTurns: page.data.length > 0,
};
}
export async function resumeChatThread(client: AppServerClient, threadId: string, cwd: string): Promise<ChatThreadResumeSnapshot> {
const response = await client.resumeThread(threadId, cwd);
return {

View file

@ -6,13 +6,10 @@ import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import type { ChatStateStore } from "../state/store";
import type { ThreadManagementActions } from "../threads/thread-management-actions";
import type { GoalActions } from "../threads/goal-actions";
import { activeThreadId } from "../threads/state-selectors";
import { submitComposer, type ComposerSubmitActions, type ComposerSubmitActionsHost } from "./composer-submit-actions";
import { canImplementPlanItemId } from "./plan-implementation-target";
import { executeSlashCommandWithState, type SlashCommandExecutorHost } from "./slash-command-executor";
import { createTurnSubmissionActions } from "./turn-submission-actions";
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
import { implementPlan, type PlanImplementationHost } from "./plan-implementation";
export interface ConversationTurnActionsContext {
vaultPath: string;
@ -63,13 +60,6 @@ interface ConversationThreadStarter {
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
}
export interface PlanImplementationHost {
stateStore: ChatStateStore;
connectedClient(): Promise<AppServerClient | null>;
sendTurnText(text: string): Promise<void>;
requestDefaultCollaborationModeForNextTurn(): void;
}
interface PlanImplementation {
implement: (itemId: string) => Promise<void>;
}
@ -164,12 +154,3 @@ async function startThreadForGoal(starter: ConversationThreadStarter, objective:
const response = await starter.startThread(objective, { syncGoal: false });
return response?.threadId ?? null;
}
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
if (!canImplementPlanItemId(host.stateStore.getState(), itemId)) return;
if (!(await host.connectedClient()) || !activeThreadId(host.stateStore.getState())) return;
host.requestDefaultCollaborationModeForNextTurn();
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
await host.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}

View file

@ -1,9 +1,20 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import type { ChatStateStore } from "../state/store";
import type { ChatActiveThreadState, ChatMessageStreamState, ChatRuntimeState, ChatState, ChatTurnState } from "../state/root-reducer";
import { chatTurnBusy } from "../state/root-reducer";
import { messageStreamItems } from "../state/message-stream";
import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
export function canImplementPlanItemId(state: ChatState, itemId: string): boolean {
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
export interface PlanImplementationHost {
stateStore: ChatStateStore;
connectedClient(): Promise<AppServerClient | null>;
sendTurnText(text: string): Promise<void>;
requestDefaultCollaborationModeForNextTurn(): void;
}
function canImplementPlanItemId(state: ChatState, itemId: string): boolean {
return itemId === implementPlanTargetFromState(state)?.itemId;
}
@ -18,3 +29,12 @@ export function implementPlanTargetFromState(state: {
}
return latestImplementablePlanTargetFromItems(messageStreamItems(state.messageStream));
}
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
if (!canImplementPlanItemId(host.stateStore.getState(), itemId)) return;
if (!(await host.connectedClient()) || !host.stateStore.getState().activeThread.id) return;
host.requestDefaultCollaborationModeForNextTurn();
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
await host.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}

View file

@ -3,6 +3,8 @@ export interface PendingTurnStart {
readonly promptSubmitHookItemIds: readonly string[];
}
export const STATUS_TURN_RUNNING = "Turn running...";
export type ChatTurnLifecycleState =
| { readonly kind: "idle" }
| { readonly kind: "starting"; readonly pendingTurnStart: PendingTurnStart }

View file

@ -2,8 +2,8 @@ import type { AppServerClient } from "../../../../app-server/connection/client";
import type { CodexInput } from "../../../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import type { LocalIdSource } from "../../../../shared/id/local-id";
import { STATUS_TURN_RUNNING } from "../state/status-text";
import type { ChatStateStore } from "../state/store";
import { STATUS_TURN_RUNNING } from "./turn-state";
import {
acknowledgeOptimisticTurnStart,
cleanupFailedTurnStart,

View file

@ -61,6 +61,7 @@ import {
type RequestAction,
} from "../pending-requests/state";
import {
STATUS_TURN_RUNNING,
initialChatTurnState,
transitionChatTurnLifecycleState,
type ChatTurnState,
@ -76,7 +77,6 @@ import {
type ChatUiState,
type UiAction,
} from "./ui-state";
import { STATUS_TURN_RUNNING } from "./status-text";
import { definedPatch, patchObject } from "./patch";
export { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatTurnState, type PendingTurnStart } from "../conversation/turn-state";

View file

@ -1 +0,0 @@
export const STATUS_TURN_RUNNING = "Turn running...";

View file

@ -1,5 +1,5 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { readChatThreadHistoryPage, type ChatThreadHistoryPage } from "../../app-server/threads/history";
import { readChatThreadHistoryPage, type ChatThreadHistoryPage } from "../../app-server/threads/projection";
import type { ChatAction, ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { messageStreamItems } from "../state/message-stream";

View file

@ -1,6 +1,6 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics";
import { resumeChatThread, type ChatThreadResumeSnapshot } from "../../app-server/threads/resume";
import { resumeChatThread, type ChatThreadResumeSnapshot } from "../../app-server/threads/projection";
import { resumedThreadAction } from "../state/actions";
import type { ChatStateStore } from "../state/store";
import type { RestorationController } from "./restoration-controller";

View file

@ -1,9 +0,0 @@
import { pathRelativeToRoot as sharedPathRelativeToRoot } from "../../../../../shared/path/file-paths";
export function pathRelativeToRoot(path: string, root?: string | null): string {
return sharedPathRelativeToRoot(path, root);
}
export function pathsRelativeToRoot(paths: string[], root?: string | null): string[] {
return paths.map((path) => pathRelativeToRoot(path, root));
}

View file

@ -1,17 +0,0 @@
import type { ApprovalDetailRow, PendingApproval } from "../../../../domain/pending-requests/model";
export function approvalTitle(approval: PendingApproval): string {
return approval.title;
}
export function approvalSummary(approval: PendingApproval): string {
return approval.summary;
}
export function approvalResultSummary(approval: PendingApproval): string {
return approval.resultSummary;
}
export function approvalDetails(approval: PendingApproval): ApprovalDetailRow[] {
return [...approval.details];
}

View file

@ -7,7 +7,6 @@ import {
type PendingMcpElicitation,
type PendingUserInput,
} from "../../../../domain/pending-requests/model";
import { approvalDetails, approvalResultSummary, approvalTitle } from "./approval";
import type { MessageStreamItem, MessageStreamUserInputQuestionResult } from "../message-stream/items";
import { definedProp } from "../../../../utils";
@ -24,8 +23,8 @@ export function createApprovalResultItem(approval: PendingApproval, action: Appr
approval: {
status: approvalResultStatus(kind),
scope: kind === "accept-session" ? "session" : "turn",
request: approvalTitle(approval),
auditFacts: approvalDetails(approval),
request: approval.title,
auditFacts: [...approval.details],
},
};
}
@ -74,7 +73,7 @@ export function createMcpElicitationResultItem(
}
function approvalResultText(approval: PendingApproval, action: ApprovalAction): string {
return `${approvalResultPrefix(approvalActionKind(action))}: ${approvalResultSummary(approval)}`;
return `${approvalResultPrefix(approvalActionKind(action))}: ${approval.resultSummary}`;
}
function approvalResultPrefix(kind: ReturnType<typeof approvalActionKind>): string {

View file

@ -4,6 +4,7 @@ import { batch, computed, signal, type ReadonlySignal, type Signal } from "@prea
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
import { messageItemsHaveThreadTurns, runtimeSnapshotForChatSlices } from "../application/runtime/snapshot";
import { implementPlanTargetFromState } from "../application/conversation/plan-implementation";
import { activeTurnId, chatTurnBusy, type ChatState } from "../application/state/root-reducer";
import {
messageStreamActiveItems,
@ -13,12 +14,7 @@ import {
type MessageStreamRollbackCandidate,
} from "../application/state/message-stream";
import type { MessageStreamItem } from "../domain/message-stream/items";
import {
forkCandidatesFromItems,
latestImplementablePlanTargetFromItems,
type ForkCandidate,
type PlanImplementationTarget,
} from "../domain/message-stream/selectors";
import { forkCandidatesFromItems, type ForkCandidate, type PlanImplementationTarget } from "../domain/message-stream/selectors";
export interface ChatPanelShellState {
connection: Signal<ChatState["connection"]>;
@ -118,10 +114,14 @@ export function createChatPanelShellState(initialState: ChatState): ChatPanelShe
messageStreamActiveItems: computed(() => messageStreamActiveItems(messageStream.value)),
messageStreamRollbackCandidate: computed(() => (turnBusy.value ? null : messageStreamRollbackCandidateFromItems(messageItems.value))),
messageStreamForkCandidates: computed(() => (turnBusy.value ? [] : forkCandidatesFromItems(messageItems.value))),
messageStreamImplementPlanTarget: computed(() => {
if (!activeThreadIdSignal.value || turnBusy.value || runtime.value.selectedCollaborationMode !== "plan") return null;
return latestImplementablePlanTargetFromItems(messageItems.value);
}),
messageStreamImplementPlanTarget: computed(() =>
implementPlanTargetFromState({
activeThread: { id: activeThreadIdSignal.value },
turn: turn.value,
runtime: { selectedCollaborationMode: runtime.value.selectedCollaborationMode },
messageStream: messageStream.value,
}),
),
hasThreadTurns,
goalEditor: computed(() => ui.value.goalEditor),
goalObjectiveExpanded: computed(() => ui.value.disclosures.goalObjectiveExpanded),

View file

@ -1,6 +1,6 @@
import { shortThreadId, truncate } from "../../../../utils";
import { failedStatusLabel } from "../../domain/message-stream/execution-state";
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
import { pathRelativeToRoot } from "../../../../shared/path/file-paths";
import type {
AgentMessageStreamItem,
ApprovalResultMessageStreamItem,

View file

@ -1,5 +1,5 @@
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
import { pathRelativeToRoot } from "../../../../shared/path/file-paths";
import { messageStreamSemanticClassifications } from "../../domain/message-stream/semantics/classify";
import {
messageStreamIsAutoReviewDecision,

View file

@ -1,4 +1,3 @@
import { approvalDetails, approvalSummary, approvalTitle } from "../../domain/pending-requests/approval";
import { approvalActionOptions, type ApprovalActionOption } from "./approval-view";
import {
type PendingApproval,
@ -91,9 +90,9 @@ export interface PendingMcpElicitationViewModel {
export function pendingApprovalViewModel(approval: PendingApproval): PendingApprovalViewModel {
return {
requestId: approval.requestId,
title: approvalTitle(approval),
summary: approvalSummary(approval),
details: approvalDetails(approval),
title: approval.title,
summary: approval.summary,
details: [...approval.details],
actions: approvalActionOptions(approval),
};
}

View file

@ -3,8 +3,11 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { implementPlan, type PlanImplementationHost } from "../../../../../src/features/chat/application/conversation/composition";
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/conversation/plan-implementation-target";
import {
implementPlan,
implementPlanTargetFromState,
type PlanImplementationHost,
} from "../../../../../src/features/chat/application/conversation/plan-implementation";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
const planItem = (id: string): MessageStreamItem => ({

View file

@ -6,7 +6,7 @@ import { messageStreamLayoutBlocks } from "../../../../src/features/chat/present
import { upsertMessageStreamItemById } from "../../../../src/features/chat/domain/message-stream/updates";
import { taskProgressMessageStreamItem } from "../../../../src/features/chat/domain/message-stream/factories/task-progress";
import { normalizeProposedPlanMarkdown } from "../../../../src/features/chat/domain/message-stream/format/proposed-plan";
import { pathRelativeToRoot } from "../../../../src/features/chat/domain/message-stream/format/path-labels";
import { pathRelativeToRoot } from "../../../../src/shared/path/file-paths";
import { permissionRows } from "../../../../src/features/chat/domain/message-stream/format/permission-rows";
import {
createAutoReviewResultItem,

View file

@ -4,7 +4,6 @@ import {
appServerApprovalRequest as toPendingApproval,
appServerApprovalResponse as approvalResponse,
} from "../../../../../src/app-server/protocol/server-requests";
import { approvalDetails, approvalSummary, approvalTitle } from "../../../../../src/features/chat/domain/pending-requests/approval";
import { createApprovalResultItem } from "../../../../../src/features/chat/domain/pending-requests/result-items";
import { approvalActionOptions } from "../../../../../src/features/chat/presentation/pending-requests/approval-view";
import type { ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
@ -35,8 +34,8 @@ describe("approval model", () => {
};
const approval = expectPresent(toPendingApproval(request));
expect(approvalTitle(approval)).toBe("Command approval");
expect(approvalSummary(approval)).toBe("npm run build");
expect(approval.title).toBe("Command approval");
expect(approval.summary).toBe("npm run build");
expect(approvalActionOptions(approval).map((option) => option.label)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]);
expect(approvalResponse(approval, expectPresent(approvalActionOptions(approval)[1]).action)).toEqual({ decision: "acceptForSession" });
});
@ -80,8 +79,8 @@ describe("approval model", () => {
);
expect(approval).toMatchObject({ kind: "fileChange", title: "File change approval" });
expect(approvalSummary(approval)).toBe("Allow file changes?");
expect(approvalDetails(approval)).toEqual([]);
expect(approval.summary).toBe("Allow file changes?");
expect(approval.details).toEqual([]);
});
it("uses command approval decisions supplied by app-server", () => {
@ -233,9 +232,9 @@ describe("approval model", () => {
}),
);
expect(approvalSummary(command)).toBe("Needs unsandboxed access\nnpm run build");
expect(approvalSummary(fileChange)).toBe("Write outside workspace\ngrant root: /tmp/project");
expect(approvalSummary(permissions).startsWith("Need network\ncwd: /tmp/project")).toBe(true);
expect(command.summary).toBe("Needs unsandboxed access\nnpm run build");
expect(fileChange.summary).toBe("Write outside workspace\ngrant root: /tmp/project");
expect(permissions.summary.startsWith("Need network\ncwd: /tmp/project")).toBe(true);
});
it("keeps approval details semantic and omits raw payloads", () => {
@ -256,7 +255,7 @@ describe("approval model", () => {
}),
);
expect(approvalDetails(approval)).toEqual([
expect(approval.details).toEqual([
{ key: "reason", value: "Need network" },
{ key: "cwd", value: "/tmp/project" },
{ key: "network", value: "enabled" },
@ -299,7 +298,7 @@ describe("approval model", () => {
}),
);
expect(approvalDetails(approval)).toEqual([
expect(approval.details).toEqual([
{ key: "reason", value: "Needs network access" },
{ key: "command", value: "rg TODO src && sed -n '1,20p' src/main.ts" },
{ key: "cwd", value: "/tmp/project" },
@ -332,7 +331,7 @@ describe("approval model", () => {
} as unknown as ServerRequest),
);
expect(approvalDetails(approval)).toEqual([
expect(approval.details).toEqual([
{ key: "cwd", value: "/tmp/project" },
{ key: "actions", value: '{\n "path": "/tmp/project/src/main.ts"\n}\nlegacy action' },
{ key: "future network rules", value: "rule api.github.com\nallow (unknown host)\nlegacy rule" },
@ -359,7 +358,7 @@ describe("approval model", () => {
}),
);
expect(approvalDetails(approval)).toEqual([
expect(approval.details).toEqual([
{ key: "command", value: "npm test" },
{ key: "cwd", value: "/tmp/project" },
]);

View file

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { HistoryController, type HistoryControllerHost } from "../../../../src/features/chat/application/threads/history-controller";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { ChatThreadHistoryPage } from "../../../../src/features/chat/app-server/threads/history";
import type { ChatThreadHistoryPage } from "../../../../src/features/chat/app-server/threads/projection";
import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items";
import { deferred } from "../../../support/async";
import { chatStateMessageStreamItems } from "../support/message-stream";

View file

@ -9,8 +9,7 @@ import type { HistoryController } from "../../../../src/features/chat/applicatio
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/lifecycle";
import type { Thread as PanelThread } from "../../../../src/domain/threads/model";
import type { ThreadTokenUsage } from "../../../../src/domain/runtime/metrics";
import type { ChatThreadHistoryPage } from "../../../../src/features/chat/app-server/threads/history";
import type { ChatThreadResumeSnapshot } from "../../../../src/features/chat/app-server/threads/resume";
import type { ChatThreadHistoryPage, ChatThreadResumeSnapshot } from "../../../../src/features/chat/app-server/threads/projection";
import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items";
function activation(threadId: string, overrides: Partial<ChatThreadResumeSnapshot> = {}): ChatThreadResumeSnapshot {

View file

@ -5,7 +5,7 @@ import { act } from "preact/test-utils";
import { MarkdownRenderer } from "obsidian";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/conversation/plan-implementation-target";
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/conversation/plan-implementation";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events";
import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer";
import { deferred } from "../../../../support/async";