Move chat panel shell and surface contracts out of UI

This commit is contained in:
murashit 2026-06-14 18:03:22 +09:00
parent cfa693c8ba
commit 647ba5d05f
34 changed files with 240 additions and 157 deletions

View file

@ -25,7 +25,7 @@ const removedChatStateEscapeHatchRestrictions = [
const chatSignalAdapterRestrictions = [
{
selector: "ImportDeclaration[source.value='@preact/signals']",
message: "Keep chat signals in ui/shell-state.tsx as a reducer-to-Preact notification adapter.",
message: "Keep chat signals in panel/shell-state.tsx as a reducer-to-Preact notification adapter.",
},
];
const uiRootImportRestrictions = [
@ -154,12 +154,12 @@ const chatPreactDomBridgeFiles = [
"src/features/chat/ui/message-stream/tool-result.tsx",
"src/features/chat/ui/message-stream/viewport.tsx",
"src/features/chat/ui/composer-dom.ts",
"src/features/chat/ui/shell.tsx",
"src/features/chat/panel/shell.tsx",
"src/features/chat/ui/turn-diff/render.tsx",
];
const chatImperativeDomBridgeFiles = [...chatExternalDomBridgeFiles, ...chatPreactDomBridgeFiles];
const uiRootBridgeFiles = [
"src/features/chat/ui/shell.tsx",
"src/features/chat/panel/shell.tsx",
"src/features/chat/ui/turn-diff/render.tsx",
"src/features/chat/ui/turn-diff/view.ts",
"src/features/selection-rewrite/popover.tsx",
@ -639,7 +639,7 @@ export default defineConfig([
},
{
files: ["src/features/chat/**/*.{ts,tsx}"],
ignores: ["src/features/chat/ui/shell-state.tsx", ...chatImperativeDomBridgeFiles],
ignores: ["src/features/chat/panel/shell-state.tsx", ...chatImperativeDomBridgeFiles],
rules: {
...restrictedSyntaxRule(chatSourceSyntaxRestrictions),
"codex-panel/no-imperative-dom": "error",
@ -647,7 +647,7 @@ export default defineConfig([
},
},
{
files: ["src/features/chat/ui/shell-state.tsx"],
files: ["src/features/chat/panel/shell-state.tsx"],
rules: {
...restrictedSyntaxRule(baseSourceSyntaxRestrictions),
"codex-panel/no-imperative-dom": "error",

View file

@ -76,7 +76,6 @@ export {
chatTurnBusy,
pendingTurnStart,
transitionChatTurnLifecycleState,
type ChatTurnLifecycleState,
type ChatTurnState,
type PendingTurnStart,
} from "../conversation/turn-state";

View file

@ -5,11 +5,11 @@ import { MessageStreamScrollBridge } from "../panel/surface/message-stream-scrol
import { currentModel, runtimeConfigOrDefault } from "../domain/runtime/effective";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import { activeTurnId, type ChatStateStore } from "../application/state/reducer";
import type { ComposerMetaViewModel } from "../ui/composer";
import type { ChatPanelComposerShellState } from "../ui/shell-state";
import type { ChatPanelComposerShellState } from "../panel/shell-state";
import type { CodexChatHost } from "../application/chat-host";
import type { ChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import { ChatComposerController } from "../ui/composer-controller";
import { ChatComposerController } from "../panel/composer-controller";
import type { ChatPanelComposerProjection } from "../panel/surface/model";
export interface ConversationComposerContext {
app: App;
@ -17,10 +17,7 @@ export interface ConversationComposerContext {
stateStore: ChatStateStore;
viewId: string;
surface: {
composerProjection: (state: ChatPanelComposerShellState) => {
placeholder: string;
meta: ComposerMetaViewModel;
};
composerProjection: (state: ChatPanelComposerShellState) => ChatPanelComposerProjection;
};
liveState: {
refresh: () => void;

View file

@ -10,11 +10,11 @@ import type { GoalActions } from "../application/threads/goal-actions";
import type { HistoryController } from "../application/threads/history-controller";
import type { ChatInboundController } from "../app-server/inbound/controller";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import type { ChatMessageScrollIntentState } from "../ui/message-stream/scroll-intent-state";
import type { ComposerMetaViewModel } from "../ui/composer";
import type { ChatPanelComposerShellState } from "../ui/shell-state";
import type { ChatPanelComposerShellState } from "../panel/shell-state";
import type { CodexChatHost } from "../application/chat-host";
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import type { ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
import type { ChatPanelComposerProjection } from "../panel/surface/model";
import { createConversationComposer } from "./composer";
import { createConversationTurnActions } from "../application/conversation/composition";
@ -33,10 +33,7 @@ interface ConversationPartsContext {
};
surface: {
pendingRequestsSignature: () => string;
composerProjection: (state: ChatPanelComposerShellState) => {
placeholder: string;
meta: ComposerMetaViewModel;
};
composerProjection: (state: ChatPanelComposerShellState) => ChatPanelComposerProjection;
};
runtime: {
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];

View file

@ -15,7 +15,7 @@ import { createChatReconnectActions } from "../application/connection/reconnect-
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "../app-server/actions/metadata";
import { createChatServerThreadActions, type ChatServerThreadActions } from "../app-server/actions/threads";
import type { ChatComposerController } from "../ui/composer-controller";
import type { ChatComposerController } from "../panel/composer-controller";
import { createConversationParts } from "./conversation";
import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions";
import { codexPanelDisplayTitle } from "../application/threads/title-display";
@ -36,16 +36,16 @@ import {
import { applyChatViewState } from "../panel/view-state";
import { closeChatView, openChatView, type ChatViewLifecycleHost } from "../panel/view-lifecycle";
import { createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import { connectionDiagnosticsModel } from "../panel/surface/toolbar";
import { connectionDiagnosticsModel } from "../panel/surface/toolbar-projection";
import { openPanelTurnLifecycle } from "../panel/snapshot";
import { ChatInboundController } from "../app-server/inbound/controller";
import { rejectServerRequest, respondToServerRequest } from "../app-server/requests/responder";
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "../application/runtime/snapshot";
import { chatPanelComposerProjection } from "../panel/surface/composer";
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "../ui/message-stream/scroll-intent-state";
import { renderChatPanelShell } from "../ui/shell";
import { chatPanelComposerProjection } from "../panel/surface/composer-projection";
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
import { renderChatPanelShell } from "../panel/shell";
import {
chatTurnBusy,
createChatStateStore,

View file

@ -4,14 +4,11 @@ import type { CodexInput } from "../../../domain/chat/input";
import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard";
import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../application/state/reducer";
import type { ComposerMetaViewModel, ComposerShellProps } from "./composer";
import { syncComposerHeight, type ComposerCallbacks } from "./composer";
import type { ComposerShellProps } from "../ui/composer";
import { syncComposerHeight, type ComposerCallbacks } from "../ui/composer";
import type { ChatPanelComposerShellState } from "./shell-state";
import { composerBoundaryScrollDirection, type ComposerBoundaryScrollAction } from "../application/composer/boundary-scroll";
import {
noteCandidates as appNoteCandidates,
resolveWikiLinkMention as resolveAppWikiLinkMention,
} from "../application/composer/obsidian-context";
import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "./composer-obsidian-context";
import {
activeComposerSuggestions,
applyComposerSuggestionInsertion,
@ -22,6 +19,7 @@ import {
type NoteCandidate,
} from "../application/composer/suggestions";
import { userInputWithWikiLinkMentionsAndSkills } from "../application/composer/wikilink-context";
import type { ChatPanelComposerProjection } from "./surface/model";
export interface ChatComposerControllerOptions {
app: App;
@ -30,10 +28,7 @@ export interface ChatComposerControllerOptions {
sendShortcut: () => SendShortcut;
scrollThreadFromComposerEdges: () => boolean;
canInterrupt: (state: ChatPanelComposerShellState) => boolean;
composerProjection: (state: ChatPanelComposerShellState) => {
placeholder: string;
meta: ComposerMetaViewModel;
};
composerProjection: (state: ChatPanelComposerShellState) => ChatPanelComposerProjection;
currentModelForSuggestions: () => string | null;
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
togglePlan: () => void;

View file

@ -1,7 +1,7 @@
import type { App } from "obsidian";
import { stripHeadingForLink, TFile } from "obsidian";
import type { NoteCandidate } from "./suggestions";
import type { NoteCandidate } from "../application/composer/suggestions";
export interface WikiLinkMention {
name: string;

View file

@ -1,11 +1,11 @@
import type { ComponentChild as UiNode } from "preact";
import { renderUiRoot, unmountUiRoot } from "../../../shared/ui/ui-root";
import type { ChatStateStore } from "../application/state/reducer";
import type { ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/surface/model";
import { ChatPanelToolbar } from "../panel/surface/toolbar";
import { ChatPanelGoal } from "../panel/surface/goal";
import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerController } from "../panel/surface/composer";
import type { ChatPanelGoalSurface, ChatPanelToolbarSurface } from "./surface/model";
import { ChatPanelToolbar } from "./surface/toolbar-projection";
import { ChatPanelGoal } from "./surface/goal-projection";
import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "./surface/message-stream-presenter";
import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerController } from "./surface/composer-projection";
import { ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState, type ChatPanelShellState } from "./shell-state";
export interface ChatPanelShellParts {

View file

@ -15,28 +15,22 @@ import { sortedModelMetadata } from "../../../../domain/catalog/metadata";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { RuntimeSnapshot } from "../../application/runtime/snapshot";
import type { ChatState } from "../../application/state/reducer";
import type {
ComposerContextMeterCellViewModel,
ComposerContextMeterViewModel,
ComposerMetaViewModel,
RuntimeChoice,
} from "../../ui/composer";
import { ComposerShell, type ComposerShellProps } from "../../ui/composer";
import { composerStateFromShellState, useChatPanelShellState, type ChatPanelComposerShellState } from "../../ui/shell-state";
import { composerStateFromShellState, useChatPanelShellState, type ChatPanelComposerShellState } from "../shell-state";
import { explicitThreadName } from "../../../../domain/threads/model";
import type { ChatPanelComposerSurface, RestoredThreadTitleSnapshot } from "./model";
import type {
ChatPanelComposerContextMeter,
ChatPanelComposerContextMeterCell,
ChatPanelComposerMeta,
ChatPanelComposerProjection,
ChatPanelComposerRuntimeChoice,
ChatPanelComposerSurface,
RestoredThreadTitleSnapshot,
} from "./model";
import { runtimeSnapshotForShellState } from "./runtime-snapshot";
type ComposerMetaState = Pick<ChatState, "connection" | "runtime">;
export interface ChatPanelComposerProjection {
placeholder: string;
meta: ComposerMetaViewModel & {
modelChoices: RuntimeChoice[];
effortChoices: RuntimeChoice[];
};
}
export interface ChatPanelComposerController {
renderState(state: ChatPanelComposerShellState, actions: ChatPanelComposerActions): ComposerShellProps;
}
@ -88,7 +82,10 @@ export function chatPanelComposerProjection(
};
}
export function composerMetaViewModel(state: ComposerMetaState, snapshot: RuntimeSnapshot): ComposerMetaViewModel {
export function composerMetaViewModel(
state: ComposerMetaState,
snapshot: RuntimeSnapshot,
): Omit<ChatPanelComposerMeta, "modelChoices" | "effortChoices"> {
if (state.connection.phase.kind === "failed" || state.connection.phase.kind === "disconnected") {
return {
fatal: "Codex app-server disconnected",
@ -131,13 +128,13 @@ export function composerMetaViewModel(state: ComposerMetaState, snapshot: Runtim
}
export function runtimeComposerChoices(input: RuntimeComposerChoicesInput): {
modelChoices: RuntimeChoice[];
effortChoices: RuntimeChoice[];
modelChoices: ChatPanelComposerRuntimeChoice[];
effortChoices: ChatPanelComposerRuntimeChoice[];
} {
const config = runtimeConfigOrDefault(input.state.connection.runtimeConfig);
const activeModel = currentModel(input.snapshot, config);
const models = sortedModelMetadata(input.state.connection.availableModels);
const modelChoices: RuntimeChoice[] = models.slice(0, 12).map((model) => ({
const modelChoices: ChatPanelComposerRuntimeChoice[] = models.slice(0, 12).map((model) => ({
label: model.model,
selected: activeModel === model.model,
onClick: () => {
@ -153,7 +150,7 @@ export function runtimeComposerChoices(input: RuntimeComposerChoicesInput): {
}
const activeEffort = currentReasoningEffort(input.snapshot, config);
const effortChoices: RuntimeChoice[] = [
const effortChoices: ChatPanelComposerRuntimeChoice[] = [
{
label: "Codex default",
selected: activeEffort === null,
@ -174,7 +171,7 @@ export function runtimeComposerChoices(input: RuntimeComposerChoicesInput): {
}
function composerStatusSummary(input: {
context: ComposerContextMeterViewModel;
context: ChatPanelComposerContextMeter;
model: string;
effort: string | null;
planActive: boolean;
@ -214,7 +211,7 @@ const CONTEXT_PARTIAL_DOTS = ["", "⣀", "⣤", "⣶", "⣿"] as const;
const CONTEXT_FULL_DOT = "⣿";
const CONTEXT_EMPTY_DOT = "⣀";
function contextComposerMeter(percent: number | null): ComposerContextMeterViewModel {
function contextComposerMeter(percent: number | null): ChatPanelComposerContextMeter {
const percentLabel = percent === null ? "--%" : `${String(Math.round(Math.max(0, Math.min(100, percent)))).padStart(2, " ")}%`;
return {
cells: contextBrailleCells(percent),
@ -222,10 +219,10 @@ function contextComposerMeter(percent: number | null): ComposerContextMeterViewM
};
}
function contextBrailleCells(percent: number | null): ComposerContextMeterCellViewModel[] {
function contextBrailleCells(percent: number | null): ChatPanelComposerContextMeterCell[] {
if (percent === null) return Array.from({ length: CONTEXT_DOT_WIDTH }, () => ({ text: CONTEXT_EMPTY_DOT, placeholder: true }));
const clamped = Math.max(0, Math.min(100, percent));
const cells: ComposerContextMeterCellViewModel[] = [];
const cells: ChatPanelComposerContextMeterCell[] = [];
for (let index = 0; index < CONTEXT_DOT_WIDTH; index += 1) {
const remaining = clamped - index * CONTEXT_CELL_PERCENT;
if (remaining <= 0) {

View file

@ -3,7 +3,7 @@ import { h } from "preact";
import type { GoalPanelActions, GoalPanelDisplayState, GoalPanelEditorState, GoalPanelOptions } from "../../ui/goal";
import { GoalPanel } from "../../ui/goal";
import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../../ui/shell-state";
import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../shell-state";
import type { ChatPanelGoalSurface } from "./model";
export interface ChatPanelGoalProjection {

View file

@ -8,7 +8,7 @@ import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-render
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../../ui/shell-state";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state";
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
import {

View file

@ -22,7 +22,7 @@ import {
type MessageStreamRollbackCandidate,
} from "../../application/state/message-stream";
import type { MessageStreamContext } from "../../ui/message-stream/context";
import type { ChatPanelMessageStreamShellState } from "../../ui/shell-state";
import type { ChatPanelMessageStreamShellState } from "../shell-state";
import { pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/snapshot";
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";

View file

@ -1,4 +1,4 @@
import type { MessageStreamScrollIntent } from "./virtualizer";
import type { MessageStreamScrollIntent } from "../../ui/message-stream/virtualizer";
export interface ChatMessageScrollIntentState {
consumeIntent(): MessageStreamScrollIntent;

View file

@ -1,6 +1,5 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { SendShortcut } from "../../../../shared/ui/keyboard";
import type { ToolbarActions } from "../../ui/toolbar";
export interface RestoredThreadTitleSnapshot {
threadId: string;
@ -8,7 +7,60 @@ export interface RestoredThreadTitleSnapshot {
explicitName: string | null;
}
type ChatPanelToolbarActions = ToolbarActions;
export interface ChatPanelComposerContextMeterCell {
text: string;
placeholder: boolean;
}
export interface ChatPanelComposerContextMeter {
cells: ChatPanelComposerContextMeterCell[];
percent: string;
}
export interface ChatPanelComposerRuntimeChoice {
label: string;
selected?: boolean;
disabled?: boolean;
meta?: string;
onClick: () => void;
}
export interface ChatPanelComposerMeta {
fatal: string | null;
context: ChatPanelComposerContextMeter;
statusSummary: string;
model: string;
effort: string | null;
planActive: boolean;
autoReviewActive: boolean;
fastActive: boolean;
modelChoices?: ChatPanelComposerRuntimeChoice[];
effortChoices?: ChatPanelComposerRuntimeChoice[];
}
export interface ChatPanelComposerProjection {
placeholder: string;
meta: ChatPanelComposerMeta;
}
interface ChatPanelToolbarActions {
startNewThread: () => void;
toggleChatActions: () => void;
compactConversation: () => void;
setGoal: () => void;
toggleHistory: () => void;
toggleStatusPanel: () => void;
connect: () => void;
refreshStatus: () => void;
resumeThread: (threadId: string) => void;
startArchiveThread: (threadId: string) => void;
archiveThread: (threadId: string, saveMarkdown: boolean) => void;
startRenameThread: (threadId: string) => void;
updateRenameDraft: (threadId: string, value: string) => void;
saveRenameThread: (threadId: string, value: string) => void;
cancelRenameThread: (threadId: string) => void;
autoNameThread: (threadId: string) => void;
}
interface ChatPanelGoalActions {
saveObjective: (objective: string, tokenBudget: number | null) => Promise<void>;

View file

@ -7,7 +7,7 @@ import { runtimeConfigSections, rateLimitSummary } from "../../presentation/runt
import { connectionDiagnosticSections } from "../../application/connection/diagnostics-display";
import type { RuntimeSnapshot } from "../../application/runtime/snapshot";
import { chatTurnBusy, type ChatState } from "../../application/state/reducer";
import { toolbarStateFromShellState, useChatPanelShellState, type ChatPanelToolbarShellState } from "../../ui/shell-state";
import { toolbarStateFromShellState, useChatPanelShellState, type ChatPanelToolbarShellState } from "../shell-state";
import { Toolbar, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
import type { ChatPanelToolbarSurface } from "./model";
import { runtimeSnapshotForToolbarShellState } from "./runtime-snapshot";

View file

@ -1,6 +1,6 @@
import type { EventRef } from "obsidian";
import { unmountChatPanelShell } from "../ui/shell";
import { unmountChatPanelShell } from "./shell";
export interface ChatViewLifecycleHost {
lifecycle: {

View file

@ -1,12 +1,21 @@
import type { ButtonHTMLAttributes, ComponentChild as UiNode, Ref } from "preact";
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import type { ComposerSuggestion } from "../application/composer/suggestions";
import { IconButton } from "../../../shared/ui/components";
import { disposeDomListeners, listenDomEvent } from "../../../shared/ui/dom-events";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
import { renderComposerMetaIcon, scrollComposerSuggestionIntoView, updateComposerMetaStatusOverflow } from "./composer-dom";
export interface ComposerSuggestion {
display: string;
detail: string;
replacement: string;
start: number;
appendSpaceOnInsert?: boolean;
tabCursorOffset?: number;
suffixOnInsert?: string;
}
export interface ComposerMetaViewModel {
fatal: string | null;
context: ComposerContextMeterViewModel;
@ -20,7 +29,7 @@ export interface ComposerMetaViewModel {
effortChoices?: RuntimeChoice[];
}
export interface RuntimeChoice {
interface RuntimeChoice {
label: string;
selected?: boolean;
disabled?: boolean;
@ -28,12 +37,12 @@ export interface RuntimeChoice {
onClick: () => void;
}
export interface ComposerContextMeterCellViewModel {
interface ComposerContextMeterCellViewModel {
text: string;
placeholder: boolean;
}
export interface ComposerContextMeterViewModel {
interface ComposerContextMeterViewModel {
cells: ComposerContextMeterCellViewModel[];
percent: string;
}

View file

@ -1,7 +1,6 @@
import type { ComponentChild as UiNode } from "preact";
import type { ChatDisclosureBucket, ChatDisclosureUiState, ChatTurnLifecycleState } from "../../application/state/reducer";
import type { PendingRequestBlockActions } from "../../application/pending-requests/block";
import type { ApprovalAction, PendingRequestId } from "../../domain/pending-requests/model";
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/snapshot";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import type { TextMessageStreamItem } from "../../presentation/message-stream/text-view";
@ -14,9 +13,41 @@ export interface MessageStreamBlock {
export type { TextMessageStreamItem };
type MessageStreamDisclosureBucket =
| "toolResults"
| "activityGroups"
| "agentDetails"
| "textDetails"
| "userMessageExpanded"
| "goalObjectiveExpanded"
| "approvalDetails";
export interface MessageStreamDisclosureState {
toolResults: ReadonlySet<string>;
activityGroups: ReadonlySet<string>;
agentDetails: ReadonlySet<string>;
textDetails: ReadonlySet<string>;
userMessageExpanded: ReadonlySet<string>;
goalObjectiveExpanded: ReadonlySet<string>;
approvalDetails: ReadonlySet<string>;
}
export type MessageStreamTurnLifecycleState =
| { kind: "idle" }
| { kind: "starting"; pendingTurnStart: unknown }
| { kind: "running"; turnId: string };
export interface PendingRequestBlockActions {
resolveApproval: (requestId: PendingRequestId, action: ApprovalAction) => void;
resolveUserInput: (requestId: PendingRequestId) => void;
cancelUserInput: (requestId: PendingRequestId) => void;
setApprovalDetailsExpanded?: (requestId: PendingRequestId, expanded: boolean) => void;
setUserInputDraft: (key: string, value: string) => void;
}
export interface TextItemDetailStateContext {
disclosures: ChatDisclosureUiState;
onDisclosureToggle?: (bucket: ChatDisclosureBucket, id: string, open: boolean) => void;
disclosures: MessageStreamDisclosureState;
onDisclosureToggle?: (bucket: MessageStreamDisclosureBucket, id: string, open: boolean) => void;
}
export interface TextItemContentContext extends TextItemDetailStateContext {
@ -24,7 +55,7 @@ export interface TextItemContentContext extends TextItemDetailStateContext {
}
export interface TextItemActionContext extends TextItemDetailStateContext {
turnLifecycle: ChatTurnLifecycleState;
turnLifecycle: MessageStreamTurnLifecycleState;
forkActionsItemId: string | null;
onForkActionsToggle?: (itemId: string | null) => void;
copyText?: (text: string) => void;
@ -44,7 +75,7 @@ export interface TextItemMetadataContext extends TextItemDetailStateContext {
interface MessageStreamRenderContext {
activeThreadId: string | null;
turnLifecycle: ChatTurnLifecycleState;
turnLifecycle: MessageStreamTurnLifecycleState;
workspaceRoot?: string | null;
loadOlderTurns: () => void;
pendingRequests?: PendingRequestBlockContext;

View file

@ -6,7 +6,7 @@ import {
type PendingUserInputQuestionViewModel,
type PendingUserInputViewModel,
} from "../../presentation/pending-requests/view-model";
import type { PendingRequestBlockActions } from "../../application/pending-requests/block";
import type { PendingRequestBlockActions } from "./context";
import { createWorkMessageClassName } from "./work-message";
export function pendingRequestBlockNode(

View file

@ -1,7 +1,6 @@
import { type ComponentChild as UiNode } from "preact";
import { useEffect, useRef } from "preact/hooks";
import { activeTurnId } from "../../application/state/reducer";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { IconButton } from "../../../../shared/ui/components";
import { listenDomEvent } from "../../../../shared/ui/dom-events";
@ -117,6 +116,6 @@ function displayRoleLabel(item: MessageStreamItem): string {
function isMessageCopyActionVisible(item: MessageStreamItem, context: Pick<TextItemActionContext, "turnLifecycle">): boolean {
if (item.kind !== "message" || item.copyText === undefined) return false;
const activeTurn = activeTurnId({ lifecycle: context.turnLifecycle });
const activeTurn = context.turnLifecycle.kind === "running" ? context.turnLifecycle.turnId : null;
return !(activeTurn && item.role === "assistant" && item.turnId === activeTurn);
}

View file

@ -3,10 +3,10 @@ import { useLayoutEffect, useRef } from "preact/hooks";
import { type ToolResultDetailSection, type ToolResultView } from "../../presentation/message-stream/tool-result-view";
import { renderRawDiffLines } from "../../../../shared/diff/render";
import type { ChatDisclosureUiState } from "../../application/state/reducer";
import type { MessageStreamDisclosureState } from "./context";
export interface ToolResultRenderContext {
disclosures: ChatDisclosureUiState;
disclosures: MessageStreamDisclosureState;
onDisclosureToggle?: (bucket: "toolResults", id: string, open: boolean) => void;
}

View file

@ -2,11 +2,11 @@ import type { ComponentChild as UiNode } from "preact";
import type { ExecutionState, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
import type { AgentRunSummaryView, MessageStreamWorkView } from "../../presentation/message-stream/work-view";
import type { ChatDisclosureUiState } from "../../application/state/reducer";
import type { MessageStreamDisclosureState } from "./context";
import { createWorkMessageClassName } from "./work-message";
export interface WorkItemContext {
disclosures: ChatDisclosureUiState;
disclosures: MessageStreamDisclosureState;
onDisclosureToggle?: (bucket: "agentDetails", id: string, open: boolean) => void;
}

View file

@ -4,13 +4,13 @@ import type { App } from "obsidian";
import { describe, expect, it, vi } from "vitest";
import { h } from "preact";
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../../src/features/chat/ui/composer-controller";
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../../src/features/chat/panel/composer-controller";
import { ComposerShell } from "../../../../../src/features/chat/ui/composer";
import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/application/state/reducer";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
import { installObsidianDomShims } from "../../../../support/dom";
import type { ChatPanelComposerShellState } from "../../../../../src/features/chat/ui/shell-state";
import type { ChatPanelComposerShellState } from "../../../../../src/features/chat/panel/shell-state";
installObsidianDomShims();

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { TFile, type App } from "obsidian";
import { noteCandidates, resolveWikiLinkMention } from "../../../../../src/features/chat/application/composer/obsidian-context";
import { noteCandidates, resolveWikiLinkMention } from "../../../../src/features/chat/panel/composer-obsidian-context";
describe("Obsidian composer context", () => {
it("builds note candidates from markdown files", () => {

View file

@ -5,8 +5,8 @@ import { act } from "preact/test-utils";
import { createChatStateStore } from "../../../../src/features/chat/application/state/reducer";
import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/ui/shell";
import type { ChatPanelComposerShellState, ChatPanelMessageStreamShellState } from "../../../../src/features/chat/ui/shell-state";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/panel/shell";
import type { ChatPanelComposerShellState, ChatPanelMessageStreamShellState } from "../../../../src/features/chat/panel/shell-state";
import type { ChatPanelSurface } from "../../../../src/features/chat/panel/surface/model";
import { installObsidianDomShims } from "../../../support/dom";

View file

@ -1,33 +1,30 @@
import { describe, expect, it } from "vitest";
import { createChatMessageScrollIntentState } from "../../../../../src/features/chat/ui/message-stream/scroll-intent-state";
import { createChatMessageScrollIntentState } from "../../../../../src/features/chat/panel/surface/message-stream-scroll-intent";
describe("createChatMessageScrollIntentState", () => {
it("consumes one-shot scroll intents", () => {
it("starts with automatic scrolling", () => {
const scrollIntent = createChatMessageScrollIntentState();
expect(scrollIntent.consumeIntent()).toBe("auto");
expect(scrollIntent.consumeIntent()).toBe("auto");
});
it("consumes force bottom intent once", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.forceBottom();
expect(scrollIntent.consumeIntent()).toBe("force-bottom");
expect(scrollIntent.consumeIntent()).toBe("auto");
});
it("keeps the latest requested intent", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.followBottom();
scrollIntent.preservePosition();
expect(scrollIntent.consumeIntent()).toBe("preserve");
expect(scrollIntent.consumeIntent()).toBe("auto");
});
it("emits a one-shot force-bottom intent", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.forceBottom();
expect(scrollIntent.consumeIntent()).toBe("force-bottom");
});
it("uses the latest scroll intent before consumption", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.preservePosition();
scrollIntent.followBottom();
scrollIntent.forceBottom();
expect(scrollIntent.consumeIntent()).toBe("force-bottom");
expect(scrollIntent.consumeIntent()).toBe("auto");
});
});

View file

@ -1,26 +1,30 @@
import { describe, expect, it } from "vitest";
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
import { createServerDiagnostics } from "../../../../../src/domain/server/diagnostics";
import {
runtimeConfigSnapshotFromAppServerConfig,
type ConfigReadResult,
type RuntimeConfigSnapshot,
} from "../../../src/app-server/protocol/runtime-config";
import { createChatState } from "../../../src/features/chat/application/state/reducer";
import { chatPanelComposerProjection, composerMetaViewModel, composerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
import { effortStatusLines, modelStatusLines, statusSummaryLines } from "../../../src/features/chat/presentation/runtime/status";
import { runtimeComposerChoices } from "../../../src/features/chat/panel/surface/composer";
import { runtimeSnapshotForChatState } from "../../../src/features/chat/application/runtime/snapshot";
import { chatPanelToolbarProjection, toolbarStateProjection } from "../../../src/features/chat/panel/surface/toolbar";
import type { ChatState } from "../../../src/features/chat/application/state/reducer";
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";
import type { Thread } from "../../../src/domain/threads/model";
import { chatPanelGoalProjection, chatPanelGoalViewModel } from "../../../src/features/chat/panel/surface/goal";
import type { ChatPanelComposerSurface, ChatPanelGoalSurface } from "../../../src/features/chat/panel/surface/model";
import type { ThreadGoal } from "../../../src/domain/threads/goal";
import { setChatStateMessageStreamItems } from "./support/message-stream";
} from "../../../../../src/app-server/protocol/runtime-config";
import { createChatState } from "../../../../../src/features/chat/application/state/reducer";
import {
chatPanelComposerProjection,
composerMetaViewModel,
composerPlaceholder,
} from "../../../../../src/features/chat/panel/surface/composer-projection";
import { effortStatusLines, modelStatusLines, statusSummaryLines } from "../../../../../src/features/chat/presentation/runtime/status";
import { runtimeComposerChoices } from "../../../../../src/features/chat/panel/surface/composer-projection";
import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot";
import { chatPanelToolbarProjection, toolbarStateProjection } from "../../../../../src/features/chat/panel/surface/toolbar-projection";
import type { ChatState } from "../../../../../src/features/chat/application/state/reducer";
import type { ModelMetadata } from "../../../../../src/domain/catalog/metadata";
import type { Thread } from "../../../../../src/domain/threads/model";
import { chatPanelGoalProjection, chatPanelGoalViewModel } from "../../../../../src/features/chat/panel/surface/goal-projection";
import type { ChatPanelComposerSurface, ChatPanelGoalSurface } from "../../../../../src/features/chat/panel/surface/model";
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
import { setChatStateMessageStreamItems } from "../../support/message-stream";
describe("chat view model", () => {
describe("chat panel surface projections", () => {
it("builds toolbar rows from immutable chat state snapshots", () => {
const state = createChatState();
state.activeThread.id = "thread-1";

View file

@ -2,9 +2,9 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/application/state/reducer";
import { createToolbarPanelActions } from "../../../../../src/features/chat/panel/toolbar-actions";
import type { ThreadManagementActions } from "../../../../../src/features/chat/application/threads/thread-management-actions";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/application/state/reducer";
import { createToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions";
import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions";
describe("createToolbarPanelActions", () => {
it("tracks archive confirmation and delegates archive actions", async () => {

View file

@ -8,7 +8,7 @@ import { createChatStateStore } from "../../../../src/features/chat/application/
import { createToolbarPanelActions, type ToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions";
import type { ChatPanelSurface, ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/model";
import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/ui/shell";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/panel/shell";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();

View file

@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EventRef } from "obsidian";
import { closeChatView, openChatView, type ChatViewLifecycleHost } from "../../../../src/features/chat/panel/view-lifecycle";
import { unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
import { unmountChatPanelShell } from "../../../../src/features/chat/panel/shell";
vi.mock("../../../../src/features/chat/ui/shell", () => ({
vi.mock("../../../../src/features/chat/panel/shell", () => ({
unmountChatPanelShell: vi.fn(),
}));

View file

@ -4,15 +4,15 @@ import { act } from "preact/test-utils";
import type { PendingApproval, PendingUserInput } from "../../../../../src/features/chat/domain/pending-requests/model";
import { pendingRequestBlockSnapshotFromState } from "../../../../../src/features/chat/presentation/pending-requests/snapshot";
import type { PendingRequestBlockActions } from "../../../../../src/features/chat/application/pending-requests/block";
import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/message-stream/pending-request-block";
import {
activeTurnId,
type ChatDisclosureUiState,
type ChatTurnLifecycleState,
} from "../../../../../src/features/chat/application/state/reducer";
import { messageStreamBlocks as rawMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
import type { MessageStreamBlock, MessageStreamContext } from "../../../../../src/features/chat/ui/message-stream/context";
import type {
MessageStreamBlock,
MessageStreamContext,
MessageStreamDisclosureState,
MessageStreamTurnLifecycleState,
PendingRequestBlockActions,
} from "../../../../../src/features/chat/ui/message-stream/context";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { messageStreamViewBlocks } from "../../../../../src/features/chat/presentation/message-stream/view-model";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
@ -24,7 +24,7 @@ export function messageStreamBlocks(
const normalized = normalizeMessageStreamContext(context);
const viewBlocks = messageStreamViewBlocks({
activeThreadId: normalized.activeThreadId,
activeTurnId: activeTurnId({ lifecycle: normalized.turnLifecycle }),
activeTurnId: activeTurnIdForMessageStream(normalized.turnLifecycle),
historyCursor: context.historyCursor,
loadingHistory: context.loadingHistory,
items: context.items,
@ -48,11 +48,13 @@ type TestMessageStreamContext = Omit<MessageStreamContext, "disclosures" | "fork
turnDiffs?: ReadonlyMap<string, string>;
};
export function emptyDisclosures(): ChatDisclosureUiState {
export function emptyDisclosures(): MessageStreamDisclosureState {
return testDisclosures();
}
export function testDisclosures(overrides: Partial<Record<keyof ChatDisclosureUiState, readonly string[]>> = {}): ChatDisclosureUiState {
export function testDisclosures(
overrides: Partial<Record<keyof MessageStreamDisclosureState, readonly string[]>> = {},
): MessageStreamDisclosureState {
return {
toolResults: new Set(overrides.toolResults),
activityGroups: new Set(overrides.activityGroups),
@ -194,18 +196,22 @@ export function pendingRequestActions(overrides: Partial<PendingRequestBlockActi
};
}
export function idleTurnLifecycle(): ChatTurnLifecycleState {
export function idleTurnLifecycle(): MessageStreamTurnLifecycleState {
return { kind: "idle" };
}
export function runningTurnLifecycle(turnId = "turn"): ChatTurnLifecycleState {
export function runningTurnLifecycle(turnId = "turn"): MessageStreamTurnLifecycleState {
return { kind: "running", turnId };
}
export function startingTurnLifecycle(): ChatTurnLifecycleState {
export function startingTurnLifecycle(): MessageStreamTurnLifecycleState {
return { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] } };
}
function activeTurnIdForMessageStream(lifecycle: MessageStreamTurnLifecycleState): string | null {
return lifecycle.kind === "running" ? lifecycle.turnId : null;
}
export function withMessageContentScrollHeight<T>(scrollHeight: number, fn: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {

View file

@ -8,8 +8,8 @@ import {
scrollComposerSuggestionIntoView,
syncComposerHeight,
type ComposerCallbacks,
type ComposerSuggestion,
} from "../../../../../src/features/chat/ui/composer";
import type { ComposerSuggestion } from "../../../../../src/features/chat/application/composer/suggestions";
import type { ComposerMetaViewModel } from "../../../../../src/features/chat/ui/composer";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { waitForAsyncWork } from "../../../../support/async";

View file

@ -68,7 +68,7 @@ export function setBoxValue(box: Box): void {
it("does not confuse signal value writes with DOM writes", async () => {
const messages = await lintSource(
"src/features/chat/ui/shell-state.tsx",
"src/features/chat/panel/shell-state.tsx",
`
import { signal } from "@preact/signals";
@ -247,7 +247,7 @@ export function mutateState(store: ChatStateStore): void {
it("allows signals in the chat shell-state adapter", async () => {
const messages = await lintSource(
"src/features/chat/ui/shell-state.tsx",
"src/features/chat/panel/shell-state.tsx",
`
import { signal } from "@preact/signals";
@ -339,7 +339,7 @@ export const render = renderUiRoot;
it("allows Preact root adapter imports in explicit root bridge files", async () => {
const messages = await lintSource(
"src/features/chat/ui/shell.tsx",
"src/features/chat/panel/shell.tsx",
`
import { renderUiRoot } from "../../../shared/ui/ui-root";