Route chat view UI state through ChatState reducer

This commit is contained in:
murashit 2026-05-27 22:40:41 +09:00
parent bfc784a05d
commit 4eef00387b
10 changed files with 293 additions and 227 deletions

View file

@ -14,12 +14,12 @@ import {
import { userInputWithWikiLinkMentionsAndSkills } from "./composer/wikilink-context";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import { renderComposerShell, renderComposerSuggestions, syncComposerHeight } from "./ui/composer";
import type { ChatState } from "./chat-state";
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
import { unmountReactRoot } from "../../shared/ui/react-root";
export interface ChatComposerControllerOptions {
app: App;
state: ChatState;
stateStore: ChatStateStore;
viewId: string;
sendShortcut: () => SendShortcut;
canInterrupt: () => boolean;
@ -40,8 +40,16 @@ export class ChatComposerController {
constructor(private readonly options: ChatComposerControllerOptions) {}
private get state(): ChatState {
return this.options.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.options.stateStore.dispatch(action);
}
get trimmedDraft(): string {
return this.composer?.value.trim() ?? this.options.state.composerDraft.trim();
return this.composer?.value.trim() ?? this.state.composerDraft.trim();
}
registerNoteIndexInvalidation(registerEvent: (eventRef: EventRef) => void): void {
@ -58,17 +66,17 @@ export class ChatComposerController {
render(parent: HTMLElement): void {
this.parent = parent;
const state = this.state;
const elements = renderComposerShell(
parent,
this.options.viewId,
this.options.state.composerDraft,
this.options.state.busy,
state.composerDraft,
state.busy,
this.options.canInterrupt(),
this.options.composerPlaceholder(),
{
onInput: () => {
this.options.state.composerDraft = this.composer?.value ?? "";
this.options.state.composerSuggestionsDismissedSignature = null;
this.dispatch({ type: "composer/draft-set", draft: this.composer?.value ?? "", resetDismissedSignature: true });
this.options.onDraftChange();
this.updateSuggestions();
this.refreshControls();
@ -106,9 +114,12 @@ export class ChatComposerController {
}
setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; renderIfDetached?: boolean } = {}): void {
this.options.state.composerDraft = text;
this.dispatch({
type: "composer/draft-set",
draft: text,
...(options.clearSuggestions === undefined ? {} : { clearSuggestions: options.clearSuggestions }),
});
this.options.onDraftChange();
if (options.clearSuggestions) this.clearSuggestions();
if (!this.composer) {
if (options.renderIfDetached) this.options.renderIfDetached();
return;
@ -140,22 +151,23 @@ export class ChatComposerController {
return userInputWithWikiLinkMentionsAndSkills(
text,
(target) => resolveAppWikiLinkMention(this.options.app, target),
this.options.state.availableSkills,
this.state.availableSkills,
);
}
private handleSuggestionKeydown(event: KeyboardEvent): boolean {
if (event.isComposing) return false;
if (this.options.state.composerSuggestions.length === 0) return false;
const state = this.state;
if (state.composerSuggestions.length === 0) return false;
const direction = composerSuggestionNavigationDirection(event);
if (direction) {
event.preventDefault();
this.options.state.composerSuggestSelected = nextComposerSuggestionIndex(
this.options.state.composerSuggestSelected,
this.options.state.composerSuggestions.length,
direction,
);
this.dispatch({
type: "composer/suggestions-set",
suggestions: state.composerSuggestions,
selected: nextComposerSuggestionIndex(state.composerSuggestSelected, state.composerSuggestions.length, direction),
});
this.renderSuggestions();
return true;
}
@ -163,7 +175,7 @@ export class ChatComposerController {
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
this.insertSuggestion(this.options.state.composerSuggestions[this.options.state.composerSuggestSelected]);
this.insertSuggestion(state.composerSuggestions[state.composerSuggestSelected]);
return true;
}
@ -184,8 +196,9 @@ export class ChatComposerController {
const cursor = this.composer.selectionStart;
const signature = this.suggestionSignature();
if (this.options.state.composerSuggestionsDismissedSignature === signature) {
this.options.state.composerSuggestions = [];
const state = this.state;
if (state.composerSuggestionsDismissedSignature === signature) {
this.dispatch({ type: "composer/suggestions-set", suggestions: [] });
this.renderSuggestions();
return;
}
@ -193,26 +206,28 @@ export class ChatComposerController {
const suggestions = activeComposerSuggestions(
beforeCursor,
this.noteCandidates(),
this.options.state.availableSkills,
this.options.state.listedThreads,
this.options.state.availableModels,
state.availableSkills,
state.listedThreads,
state.availableModels,
this.options.currentModelForSuggestions(),
);
this.options.state.composerSuggestions = suggestions;
if (this.options.state.composerSuggestSelected >= this.options.state.composerSuggestions.length) {
this.options.state.composerSuggestSelected = 0;
}
this.dispatch({
type: "composer/suggestions-set",
suggestions,
selected: state.composerSuggestSelected >= suggestions.length ? 0 : state.composerSuggestSelected,
});
this.renderSuggestions();
}
private renderSuggestions(): void {
const state = this.state;
renderComposerSuggestions(
this.suggestionsEl,
this.composer,
this.options.viewId,
this.options.state.composerSuggestions,
this.options.state.composerSuggestSelected,
state.composerSuggestions,
state.composerSuggestSelected,
{
onSuggestionHover: (index) => {
this.selectSuggestion(index);
@ -225,8 +240,8 @@ export class ChatComposerController {
}
private selectSuggestion(index: number): void {
if (this.options.state.composerSuggestSelected === index) return;
this.options.state.composerSuggestSelected = index;
if (this.state.composerSuggestSelected === index) return;
this.dispatch({ type: "composer/suggestions-set", suggestions: this.state.composerSuggestions, selected: index });
this.renderSuggestions();
}
@ -237,7 +252,7 @@ export class ChatComposerController {
const value = this.composer.value;
const insertion = applyComposerSuggestionInsertion(value, cursor, suggestion);
this.options.state.composerDraft = insertion.value;
this.dispatch({ type: "composer/draft-set", draft: insertion.value });
this.options.onDraftChange();
this.composer.value = insertion.value;
syncComposerHeight(this.composer);
@ -247,8 +262,7 @@ export class ChatComposerController {
}
private clearSuggestions(): void {
this.options.state.composerSuggestSelected = 0;
this.options.state.composerSuggestions = [];
this.dispatch({ type: "composer/suggestions-set", suggestions: [], selected: 0 });
this.composer?.setAttr("aria-expanded", "false");
this.composer?.removeAttribute("aria-activedescendant");
unmountReactRoot(this.suggestionsEl);
@ -256,7 +270,11 @@ export class ChatComposerController {
}
private dismissSuggestions(): void {
this.options.state.composerSuggestionsDismissedSignature = this.suggestionSignature();
this.dispatch({
type: "composer/suggestions-set",
suggestions: this.state.composerSuggestions,
dismissedSignature: this.suggestionSignature(),
});
this.clearSuggestions();
}

View file

@ -9,13 +9,13 @@ import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScroll
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "./markdown-file-links";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
import type { ChatState } from "./chat-state";
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
import { unmountReactRoot } from "../../shared/ui/react-root";
export interface ChatMessageRendererOptions {
app: App;
owner: Component;
state: ChatState;
stateStore: ChatStateStore;
vaultPath: string;
blockSignatures: Map<string, string>;
consumeScrollIntent: () => ChatMessageScrollIntent;
@ -35,13 +35,21 @@ export class ChatMessageRenderer {
constructor(private readonly options: ChatMessageRendererOptions) {}
private get state(): ChatState {
return this.options.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.options.stateStore.dispatch(action);
}
render(parent: HTMLElement): void {
const generation = ++this.renderGeneration;
const { state } = this.options;
const state = this.state;
const messagesEl = parent.querySelector<HTMLElement>(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" });
this.messagesEl = messagesEl;
messagesEl.onscroll = () => {
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
this.dispatch({ type: "ui/messages-pinned-set", pinned: isNearScrollBottom(messagesEl) });
};
const scrollIntent = this.options.consumeScrollIntent();
const shouldPreserveScroll = scrollIntent === "preserve";
@ -49,7 +57,7 @@ export class ChatMessageRenderer {
const shouldScrollToBottom =
!shouldPreserveScroll && (scrollIntent === "force-bottom" || state.messagesPinnedToBottom || wasNearBottom);
const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl);
state.messagesPinnedToBottom = shouldScrollToBottom;
this.dispatch({ type: "ui/messages-pinned-set", pinned: shouldScrollToBottom });
const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems);
const implementPlanCandidate = implementPlanCandidateFromState(state);
@ -63,9 +71,10 @@ export class ChatMessageRenderer {
turnDiffs: state.turnDiffs,
workspaceRoot: state.activeThreadCwd ?? this.options.vaultPath,
openDetails: state.openDetails,
onDetailsToggle: () => {
onDetailsToggle: (key, open) => {
this.setOpenDetail(key, open);
messagesEl.win.requestAnimationFrame(() => {
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
this.dispatch({ type: "ui/messages-pinned-set", pinned: isNearScrollBottom(messagesEl) });
});
},
loadOlderTurns: () => {
@ -97,11 +106,11 @@ export class ChatMessageRenderer {
messagesEl.win.requestAnimationFrame(() => {
if (generation !== this.renderGeneration) return;
if (shouldScrollToBottom) {
if (!state.messagesPinnedToBottom) return;
if (!this.state.messagesPinnedToBottom) return;
this.pinMessagesToBottom(messagesEl);
} else {
restoreScrollAnchor(messagesEl, scrollAnchor);
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
this.dispatch({ type: "ui/messages-pinned-set", pinned: isNearScrollBottom(messagesEl) });
}
});
}
@ -132,18 +141,25 @@ export class ChatMessageRenderer {
}
private scrollMarkdownMessageIntoPinnedBottom(parent: HTMLElement): void {
if (!this.options.state.messagesPinnedToBottom) return;
if (!this.state.messagesPinnedToBottom) return;
const messagesEl = parent.closest<HTMLElement>(".codex-panel__messages");
if (!messagesEl) return;
messagesEl.win.requestAnimationFrame(() => {
if (!this.options.state.messagesPinnedToBottom) return;
if (!this.state.messagesPinnedToBottom) return;
this.pinMessagesToBottom(messagesEl);
});
}
private pinMessagesToBottom(messagesEl: HTMLElement): void {
messagesEl.scrollTop = bottomScrollTop(messagesEl);
this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
this.dispatch({ type: "ui/messages-pinned-set", pinned: isNearScrollBottom(messagesEl) });
}
private setOpenDetail(key: string, open: boolean): void {
const openDetails = open
? new Set([...this.state.openDetails, key])
: new Set([...this.state.openDetails].filter((item) => item !== key));
this.dispatch({ type: "state/patched", patch: { openDetails } });
}
private bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string): void {

View file

@ -2,7 +2,7 @@ import type { AppServerClient } from "../../app-server/client";
import type { Thread } from "../../generated/app-server/v2/Thread";
import type { Turn } from "../../generated/app-server/v2/Turn";
import type { CodexPanelSettings } from "../../settings/model";
import type { ChatState } from "./chat-state";
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
import { getThreadTitle } from "../../domain/threads/model";
import {
findThreadNamingContext,
@ -19,7 +19,7 @@ export interface ThreadRenameEditState {
}
export interface ThreadRenameControllerHost {
state: ChatState;
stateStore: ChatStateStore;
vaultPath: string;
settings: () => CodexPanelSettings;
ensureConnected: () => Promise<void>;
@ -41,6 +41,14 @@ export class ThreadRenameController {
constructor(private readonly host: ThreadRenameControllerHost) {}
private get state(): ChatState {
return this.host.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
resetThreadTurnPresence(hadTurns: boolean): void {
this.activeThreadHadTurns = hadTurns;
}
@ -92,9 +100,10 @@ export class ThreadRenameController {
try {
await client.setThreadName(threadId, title);
this.host.state.listedThreads = this.host.state.listedThreads.map((thread) =>
thread.id === threadId ? { ...thread, name: title } : thread,
);
this.dispatch({
type: "thread/list-applied",
threads: this.state.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)),
});
this.clear();
this.host.notifyThreadRenamed(threadId, title);
} catch (error) {
@ -141,7 +150,7 @@ export class ThreadRenameController {
if (hadTurnsBeforeThisCompletion || turn.status !== "completed") return;
if (this.threadHasName(threadId)) return;
if (this.autoNameAttemptedThreadIds.has(threadId) || this.autoNameInFlightThreadIds.has(threadId)) return;
const context = namingContextFromTurn(turn) ?? namingContextFromDisplayItems(turn.id, this.host.state.displayItems);
const context = namingContextFromTurn(turn) ?? namingContextFromDisplayItems(turn.id, this.state.displayItems);
if (!context) return;
this.autoNameAttemptedThreadIds.add(threadId);
@ -157,9 +166,10 @@ export class ThreadRenameController {
const client = this.host.currentClient();
if (!client) return;
await client.setThreadName(threadId, title);
this.host.state.listedThreads = this.host.state.listedThreads.map((thread) =>
thread.id === threadId ? { ...thread, name: title } : thread,
);
this.dispatch({
type: "thread/list-applied",
threads: this.state.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)),
});
this.host.notifyThreadRenamed(threadId, title);
} catch {
// Auto-naming is best-effort metadata. Leave the thread preview untouched on failure.
@ -176,9 +186,7 @@ export class ThreadRenameController {
threadId,
readTurns: (id, cursor, limit, sortDirection) => client.threadTurnsList(id, cursor, limit, sortDirection),
});
return (
context ?? (this.host.state.activeThreadId === threadId ? firstNamingContextFromDisplayItems(this.host.state.displayItems) : null)
);
return context ?? (this.state.activeThreadId === threadId ? firstNamingContextFromDisplayItems(this.state.displayItems) : null);
}
private async generateTitle(context: ThreadNamingContext): Promise<string | null> {
@ -201,6 +209,6 @@ export class ThreadRenameController {
}
private thread(threadId: string): Thread | undefined {
return this.host.state.listedThreads.find((item) => item.id === threadId);
return this.state.listedThreads.find((item) => item.id === threadId);
}
}

View file

@ -38,7 +38,7 @@ export interface MessageStreamContext {
turnDiffs?: ReadonlyMap<string, string>;
workspaceRoot?: string | null;
openDetails: Set<string>;
onDetailsToggle?: () => void;
onDetailsToggle?: (key: string, open: boolean) => void;
loadOlderTurns: () => void;
renderMarkdown: (parent: HTMLElement, text: string) => void;
renderTextWithWikiLinks: (parent: HTMLElement, text: string) => void;
@ -300,9 +300,8 @@ function renderUserMessageCollapse(parent: HTMLElement, content: HTMLElement, it
details.ontoggle = () => {
if (!details.open) return;
details.open = false;
context.openDetails.add(key);
context.onDetailsToggle?.(key, true);
update();
context.onDetailsToggle?.();
};
content.addEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update);

View file

@ -17,6 +17,8 @@ export interface PendingRequestMessageActions {
resolveApproval: (approval: PendingApproval, action: ApprovalAction) => void;
resolveUserInput: (input: PendingUserInput) => void;
cancelUserInput: (input: PendingUserInput) => void;
setOpenDetail?: (key: string, open: boolean) => void;
setUserInputDraft?: (key: string, value: string) => void;
}
export interface PendingRequestMessageDrafts {
@ -84,7 +86,7 @@ function ApprovalCard({
<div className="codex-panel__pending-request-info">
<div className="codex-panel__pending-request-title">{approvalTitle(approval)}</div>
<div className="codex-panel__pending-request-body">{approvalSummary(approval)}</div>
<ApprovalDetails approval={approval} openDetails={openDetails} />
<ApprovalDetails approval={approval} openDetails={openDetails} actions={actions} />
</div>
<div className="codex-panel__pending-request-actions">
{approvalActionOptions(approval).map((option) => (
@ -102,18 +104,22 @@ function ApprovalCard({
);
}
function ApprovalDetails({ approval, openDetails }: { approval: PendingApproval; openDetails: Set<string> }): ReactNode {
function ApprovalDetails({
approval,
openDetails,
actions,
}: {
approval: PendingApproval;
openDetails: Set<string>;
actions: PendingRequestMessageActions;
}): ReactNode {
const key = `approval:${String(approval.requestId)}:details`;
return (
<details
className="codex-panel__approval-details"
open={openDetails.has(key)}
onToggle={(event) => {
if (event.currentTarget.open) {
openDetails.add(key);
} else {
openDetails.delete(key);
}
actions.setOpenDetail?.(key, event.currentTarget.open);
}}
>
<summary>Request details</summary>
@ -142,7 +148,7 @@ function UserInputCard({
<div className="codex-panel__pending-request-body">
Answer {String(input.params.questions.length)} Plan mode question{input.params.questions.length === 1 ? "" : "s"} to continue.
</div>
<UserInputQuestions input={input} drafts={drafts} />
<UserInputQuestions input={input} drafts={drafts} actions={actions} />
</div>
<div className="codex-panel__pending-request-actions">
<ActionButton
@ -168,7 +174,15 @@ function PendingRequestCard({ className, children }: { className: string; childr
return <div className={`codex-panel__pending-request-card ${className}`}>{children}</div>;
}
function UserInputQuestions({ input, drafts }: { input: PendingUserInput; drafts: PendingRequestMessageDrafts }): ReactNode {
function UserInputQuestions({
input,
drafts,
actions,
}: {
input: PendingUserInput;
drafts: PendingRequestMessageDrafts;
actions: PendingRequestMessageActions;
}): ReactNode {
return (
<>
{input.params.questions.map((question) => {
@ -192,7 +206,7 @@ function UserInputQuestions({ input, drafts }: { input: PendingUserInput; drafts
value={option.label}
defaultChecked={current === option.label}
onChange={(event) => {
if (event.currentTarget.checked) drafts.values.set(draftKey, option.label);
if (event.currentTarget.checked) actions.setUserInputDraft?.(draftKey, option.label);
}}
/>
<span className="codex-panel__user-input-option-label">{option.label}</span>
@ -209,11 +223,19 @@ function UserInputQuestions({ input, drafts }: { input: PendingUserInput; drafts
groupName={`codex-panel-${String(input.requestId)}-${question.id}`}
current={current}
drafts={drafts}
actions={actions}
/>
) : null}
</>
) : (
<FreeformUserInput input={input} questionId={question.id} isSecret={question.isSecret} current={current} drafts={drafts} />
<FreeformUserInput
input={input}
questionId={question.id}
isSecret={question.isSecret}
current={current}
drafts={drafts}
actions={actions}
/>
)}
</div>
</div>
@ -229,12 +251,14 @@ function OtherUserInputOption({
groupName,
current,
drafts,
actions,
}: {
input: PendingUserInput;
questionId: string;
groupName: string;
current: string;
drafts: PendingRequestMessageDrafts;
actions: PendingRequestMessageActions;
}): ReactNode {
const draftKey = drafts.draftKey(input.requestId, questionId);
const otherKey = drafts.otherDraftKey(input.requestId, questionId);
@ -250,7 +274,7 @@ function OtherUserInputOption({
value="__other__"
defaultChecked={current === otherValue && otherValue.length > 0}
onChange={(event) => {
if (event.currentTarget.checked) drafts.values.set(draftKey, drafts.values.get(otherKey) ?? "");
if (event.currentTarget.checked) actions.setUserInputDraft?.(draftKey, drafts.values.get(otherKey) ?? "");
}}
/>
<span className="codex-panel__user-input-option-label">Other</span>
@ -260,8 +284,8 @@ function OtherUserInputOption({
defaultValue={otherValue}
placeholder="Other answer"
onInput={(event) => {
drafts.values.set(otherKey, event.currentTarget.value);
drafts.values.set(draftKey, event.currentTarget.value);
actions.setUserInputDraft?.(otherKey, event.currentTarget.value);
actions.setUserInputDraft?.(draftKey, event.currentTarget.value);
if (radioRef.current) radioRef.current.checked = true;
}}
/>
@ -275,12 +299,14 @@ function FreeformUserInput({
isSecret,
current,
drafts,
actions,
}: {
input: PendingUserInput;
questionId: string;
isSecret: boolean;
current: string;
drafts: PendingRequestMessageDrafts;
actions: PendingRequestMessageActions;
}): ReactNode {
const draftKey = drafts.draftKey(input.requestId, questionId);
return (
@ -289,7 +315,7 @@ function FreeformUserInput({
type={isSecret ? "password" : "text"}
defaultValue={current}
onInput={(event) => {
drafts.values.set(draftKey, event.currentTarget.value);
actions.setUserInputDraft?.(draftKey, event.currentTarget.value);
}}
/>
);

View file

@ -6,7 +6,7 @@ import { renderRawDiffLines } from "../../../shared/diff/render";
export interface ToolResultRenderContext {
workspaceRoot?: string | null;
openDetails: Set<string>;
onDetailsToggle?: () => void;
onDetailsToggle?: (key: string, open: boolean) => void;
renderTextWithWikiLinks: (parent: HTMLElement, text: string) => void;
}
@ -39,12 +39,7 @@ function createToolResultContainer(
setToolResultOpenClass(root, details.open);
details.ontoggle = () => {
setToolResultOpenClass(root, details.open);
if (details.open) {
context.openDetails.add(view.detailsKey);
} else {
context.openDetails.delete(view.detailsKey);
}
context.onDetailsToggle?.();
context.onDetailsToggle?.(view.detailsKey, details.open);
};
return { root, detailsParent: details };
}

View file

@ -30,14 +30,11 @@ import {
currentModel,
currentReasoningEffort,
currentServiceTier,
defaultRuntimeOverride,
requestedTurnRuntimeSettings,
resetRuntimeOverride,
runtimeOverridePayload,
runtimeOverrideLabel,
runtimeSummaryLabel,
serviceTierLabel,
setRuntimeOverride,
supportedReasoningEfforts,
type RuntimeSnapshot,
} from "../../runtime/state";
@ -56,7 +53,7 @@ import type { CodexPanelSettings } from "../../settings/model";
import { questionDefaultAnswer, type PendingUserInput } from "./user-input/model";
import { ChatComposerController } from "./chat-composer-controller";
import { attachHookRunsToTurn } from "./hook-display";
import { createChatStateStore, type ChatState } from "./chat-state";
import { createChatStateStore, type ChatAction, type ChatState } from "./chat-state";
import { codexPanelDisplayTitle, explicitThreadName, getThreadTitle, upsertThread } from "../../domain/threads/model";
import {
referencedThreadDisplay,
@ -137,7 +134,7 @@ export class CodexChatView extends ItemView {
this.messageRenderer = new ChatMessageRenderer({
app: this.app,
owner: this,
state: this.state,
stateStore: this.chatState,
vaultPath: this.plugin.vaultPath,
blockSignatures: this.blockSignatures,
consumeScrollIntent: () => {
@ -154,7 +151,7 @@ export class CodexChatView extends ItemView {
});
this.composerController = new ChatComposerController({
app: this.app,
state: this.state,
stateStore: this.chatState,
viewId: this.viewId,
sendShortcut: () => this.plugin.settings.sendShortcut,
canInterrupt: () => this.state.busy && Boolean(this.state.activeThreadId && this.state.activeTurnId),
@ -279,7 +276,7 @@ export class CodexChatView extends ItemView {
},
});
this.threadRename = new ThreadRenameController({
state: this.state,
stateStore: this.chatState,
vaultPath: this.plugin.vaultPath,
settings: () => this.plugin.settings,
ensureConnected: () => this.ensureConnected(),
@ -301,6 +298,10 @@ export class CodexChatView extends ItemView {
return this.chatState.getState();
}
private dispatch(action: ChatAction): void {
this.chatState.dispatch(action);
}
override getViewType(): string {
return VIEW_TYPE_CODEX_PANEL;
}
@ -359,7 +360,7 @@ export class CodexChatView extends ItemView {
}
applyAvailableModelsSnapshot(models: Model[]): void {
this.state.availableModels = models;
this.dispatch({ type: "thread/list-applied", availableModels: models });
this.render();
}
@ -401,11 +402,12 @@ export class CodexChatView extends ItemView {
notifyThreadRenamed(threadId: string, name: string | null): void {
let changed = false;
this.state.listedThreads = this.state.listedThreads.map((thread) => {
const listedThreads = this.state.listedThreads.map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
this.dispatch({ type: "thread/list-applied", threads: listedThreads });
if (this.restoredThread?.threadId === threadId && (this.restoredThread.title !== name || this.restoredThread.explicitName !== name)) {
this.restoredThread = { ...this.restoredThread, title: name, explicitName: name };
changed = true;
@ -496,7 +498,7 @@ export class CodexChatView extends ItemView {
private async initializeConnection(generation: number): Promise<void> {
this.setStatus("Starting Codex app-server...");
try {
this.state.initializeResponse = await this.connection.connect();
this.dispatch({ type: "state/patched", patch: { initializeResponse: await this.connection.connect() } });
if (this.isStaleConnectionGeneration(generation)) return;
this.client = this.connection.currentClient();
if (!this.client) throw new Error("Codex app-server connection did not initialize.");
@ -589,7 +591,7 @@ export class CodexChatView extends ItemView {
await this.history.loadLatest(response.thread.id);
if (this.isStaleResumeWork(resumeGeneration)) return;
if (this.state.displayItems.length === 0) {
this.state.displayItems.push(this.systemItem(`Resumed thread ${response.thread.id}`));
this.addSystemMessage(`Resumed thread ${response.thread.id}`);
this.queueMessagesBottomScroll();
this.render();
}
@ -613,19 +615,17 @@ export class CodexChatView extends ItemView {
}
private applyResumedThread(response: ThreadResumeResponse): void {
this.state.activeThreadId = response.thread.id;
this.state.activeThreadCwd = response.cwd;
this.state.activeTurnId = null;
this.state.activeModel = response.model;
this.state.activeReasoningEffort = response.reasoningEffort;
this.state.activeServiceTier = reportedServiceTier(response.serviceTier);
this.state.activeApprovalsReviewer = response.approvalsReviewer;
this.state.activeThreadCreationCliVersion = response.thread.cliVersion;
this.state.tokenUsage = null;
this.state.displayItems = [this.systemItem("Loading thread...")];
this.state.turnDiffs.clear();
this.state.historyCursor = null;
this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread);
this.dispatch({
type: "thread/resumed",
thread: response.thread,
cwd: response.cwd,
model: response.model,
reasoningEffort: response.reasoningEffort,
serviceTier: reportedServiceTier(response.serviceTier),
approvalsReviewer: response.approvalsReviewer,
displayItems: [this.systemItem("Loading thread...")],
listedThreads: upsertThread(this.state.listedThreads, response.thread),
});
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
this.threadRename.resetThreadTurnPresence(false);
@ -704,7 +704,7 @@ export class CodexChatView extends ItemView {
const codexInput = codexInputOverride ?? this.composerController.codexInput(text);
const mentionedFiles = fileMentionsFromInput(codexInput);
optimisticUserId = `local-user-${String(Date.now())}`;
this.state.displayItems.push({
const optimisticUserItem: DisplayItem = {
id: optimisticUserId,
kind: "message",
role: "user",
@ -713,37 +713,45 @@ export class CodexChatView extends ItemView {
...(referencedThread ? { referencedThread } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
markdown: true,
};
this.dispatch({
type: "state/patched",
patch: {
displayItems: [...this.state.displayItems, optimisticUserItem],
pendingTurnStart: { anchorItemId: optimisticUserId, promptSubmitHookItemIds: [] },
busy: true,
},
});
this.state.pendingTurnStart = { anchorItemId: optimisticUserId, promptSubmitHookItemIds: [] };
this.queueMessagesBottomScroll();
this.composerController.setDraft("");
this.state.busy = true;
this.render();
const response = await client.startTurn(activeThreadId, this.plugin.vaultPath, codexInput);
this.state.activeTurnId = response.turn.id;
const pendingTurnStart = this.state.pendingTurnStart as typeof this.state.pendingTurnStart | null;
this.state.displayItems = this.state.displayItems.map((item) =>
const pendingTurnStart = this.state.pendingTurnStart;
let displayItems = this.state.displayItems.map((item) =>
item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item,
);
if (pendingTurnStart) {
this.state.displayItems = attachHookRunsToTurn(
this.state.displayItems,
displayItems = attachHookRunsToTurn(
displayItems,
response.turn.id,
pendingTurnStart.promptSubmitHookItemIds,
pendingTurnStart.anchorItemId,
);
this.state.pendingTurnStart = null;
}
this.dispatch({ type: "state/patched", patch: { activeTurnId: response.turn.id, displayItems, pendingTurnStart: null } });
this.setStatus("Turn running...");
} catch (error) {
this.state.busy = false;
if (optimisticUserId) this.state.displayItems = this.state.displayItems.filter((item) => item.id !== optimisticUserId);
let displayItems = optimisticUserId
? this.state.displayItems.filter((item) => item.id !== optimisticUserId)
: this.state.displayItems;
let pendingTurnStart = this.state.pendingTurnStart;
if (this.state.pendingTurnStart) {
const hookIds = new Set(this.state.pendingTurnStart.promptSubmitHookItemIds);
this.state.displayItems = this.state.displayItems.filter((item) => !hookIds.has(item.id));
this.state.pendingTurnStart = null;
displayItems = displayItems.filter((item) => !hookIds.has(item.id));
pendingTurnStart = null;
}
this.dispatch({ type: "state/patched", patch: { busy: false, displayItems, pendingTurnStart } });
this.composerController.setDraft(text);
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
@ -769,16 +777,19 @@ export class CodexChatView extends ItemView {
try {
await this.client.steerTurn(threadId, expectedTurnId, codexInput);
this.state.displayItems.push({
id: `local-steer-${String(Date.now())}`,
kind: "message",
role: "user",
text,
copyText: text,
turnId: expectedTurnId,
...(referencedThread ? { referencedThread } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
markdown: true,
this.dispatch({
type: "system/message-added",
item: {
id: `local-steer-${String(Date.now())}`,
kind: "message",
role: "user",
text,
copyText: text,
turnId: expectedTurnId,
...(referencedThread ? { referencedThread } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
markdown: true,
},
});
this.queueMessagesBottomScroll();
this.setStatus("Steered current turn.");
@ -795,8 +806,8 @@ export class CodexChatView extends ItemView {
await this.ensureConnected();
if (!this.client || !this.state.activeThreadId) return;
this.state.requestedCollaborationMode = "default";
this.state.runtimePicker = null;
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
this.dispatch({ type: "ui/panel-set", panel: null });
await this.sendTurnText("Please implement this plan.");
}
@ -926,42 +937,22 @@ export class CodexChatView extends ItemView {
}
private commitPendingThreadSettings(update: Omit<ThreadSettingsUpdateParams, "threadId">): void {
if ("model" in update) {
this.state.activeModel = update.model ?? null;
this.state.requestedModel = defaultRuntimeOverride();
}
if ("effort" in update) {
this.state.activeReasoningEffort = update.effort ?? null;
this.state.requestedReasoningEffort = defaultRuntimeOverride();
}
if ("serviceTier" in update) {
const serviceTier = update.serviceTier;
this.state.activeServiceTier = this.state.requestedServiceTier ?? reportedServiceTier(serviceTier);
this.state.requestedServiceTier = null;
}
if ("approvalsReviewer" in update) {
this.state.activeApprovalsReviewer = update.approvalsReviewer ?? null;
this.state.requestedApprovalsReviewer = null;
}
if (update.collaborationMode) {
this.state.activeCollaborationMode = update.collaborationMode.mode;
}
this.dispatch({ type: "runtime/pending-thread-settings-committed", update });
}
private async toggleFastMode(): Promise<void> {
const current = currentServiceTier(this.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig));
const next: ServiceTier = current === "fast" ? "standard" : "fast";
this.state.requestedServiceTier = next;
this.state.activeServiceTier = next;
this.state.runtimePicker = null;
this.dispatch({ type: "runtime/requested-service-tier-set", serviceTier: next, activate: true });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
}
private async toggleCollaborationMode(): Promise<void> {
const next = nextCollaborationMode(this.state.requestedCollaborationMode);
this.state.requestedCollaborationMode = next;
this.state.runtimePicker = null;
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.addSystemMessage(collaborationModeToggleMessage(next));
}
@ -970,9 +961,8 @@ export class CodexChatView extends ItemView {
const next: ApprovalsReviewer = autoReviewActive(this.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig))
? "user"
: "auto_review";
this.state.requestedApprovalsReviewer = next;
this.state.activeApprovalsReviewer = next;
this.state.runtimePicker = null;
this.dispatch({ type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next, activate: true });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
}
@ -985,33 +975,29 @@ export class CodexChatView extends ItemView {
}
private toggleRuntimePicker(picker: NonNullable<ChatState["runtimePicker"]>): void {
this.state.runtimePicker = this.state.runtimePicker === picker ? null : picker;
if (this.state.runtimePicker !== null) {
this.state.openDetails.delete("history");
this.state.openDetails.delete("status-panel");
}
this.dispatch({ type: "ui/panel-set", panel: picker, toggle: true });
this.render();
}
private async setRequestedModelFromUi(model: string | null): Promise<void> {
await this.setRequestedModel(model);
this.state.runtimePicker = null;
this.dispatch({ type: "ui/panel-set", panel: null });
this.addSystemMessage(modelOverrideMessage(model));
}
private async setRequestedModel(model: string | null): Promise<void> {
this.state.requestedModel = model === null ? resetRuntimeOverride() : setRuntimeOverride(model);
this.dispatch({ type: "runtime/requested-model-set", model });
await this.applyPendingThreadSettings();
}
private async setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): Promise<void> {
await this.setRequestedReasoningEffort(effort);
this.state.runtimePicker = null;
this.dispatch({ type: "ui/panel-set", panel: null });
this.addSystemMessage(reasoningEffortOverrideMessage(effort));
}
private async setRequestedReasoningEffort(effort: ReasoningEffort | null): Promise<void> {
this.state.requestedReasoningEffort = effort === null ? resetRuntimeOverride() : setRuntimeOverride(effort);
this.dispatch({ type: "runtime/requested-effort-set", effort });
await this.applyPendingThreadSettings();
}
@ -1071,32 +1057,37 @@ export class CodexChatView extends ItemView {
}
private setStatus(status: string): void {
this.state.status = status;
this.dispatch({ type: "status/set", status });
}
private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void {
this.invalidateResumeWork();
this.restoredThread = restoredThread;
this.state.activeThreadId = restoredThread.threadId;
this.state.activeThreadCwd = null;
this.state.activeTurnId = null;
this.state.activeModel = null;
this.state.activeReasoningEffort = null;
this.state.activeCollaborationMode = "default";
this.state.activeServiceTier = null;
this.state.activeApprovalsReviewer = null;
this.state.activeThreadCreationCliVersion = null;
this.state.tokenUsage = null;
this.state.busy = false;
this.state.displayItems = [this.systemItem("Thread restored. Send a message to resume it.")];
this.state.pendingTurnStart = null;
this.state.turnDiffs.clear();
this.state.approvals = [];
this.state.pendingUserInputs = [];
this.state.userInputDrafts.clear();
this.state.historyCursor = null;
this.state.loadingHistory = false;
this.state.messagesPinnedToBottom = true;
this.dispatch({
type: "state/patched",
patch: {
activeThreadId: restoredThread.threadId,
activeThreadCwd: null,
activeTurnId: null,
activeModel: null,
activeReasoningEffort: null,
activeCollaborationMode: "default",
activeServiceTier: null,
activeApprovalsReviewer: null,
activeThreadCreationCliVersion: null,
tokenUsage: null,
busy: false,
displayItems: [this.systemItem("Thread restored. Send a message to resume it.")],
pendingTurnStart: null,
turnDiffs: new Map(),
approvals: [],
pendingUserInputs: [],
userInputDrafts: new Map(),
historyCursor: null,
loadingHistory: false,
messagesPinnedToBottom: true,
},
});
this.setStatus("Thread ready to resume.");
this.refreshTabHeader();
this.scheduleDeferredRestoredThreadHydration();
@ -1235,12 +1226,12 @@ export class CodexChatView extends ItemView {
connect: () => void this.reconnectFromToolbar(),
refreshDiagnostics: () => void this.refreshDiagnostics(),
refreshThreads: () => {
this.state.openDetails.delete("status-panel");
this.dispatch({ type: "ui/panel-set", panel: null });
void this.refreshThreads();
},
resumeThread: (threadId) => {
if (this.state.busy && threadId !== this.state.activeThreadId) return;
this.state.openDetails.delete("history");
this.dispatch({ type: "ui/panel-set", panel: null });
void this.selectThread(threadId);
},
startArchiveThread: (threadId) => {
@ -1315,18 +1306,23 @@ export class CodexChatView extends ItemView {
private async reconnectFromToolbar(): Promise<void> {
const threadId = this.state.activeThreadId;
this.state.openDetails.delete("status-panel");
this.dispatch({ type: "ui/panel-set", panel: null });
this.connectionGeneration += 1;
this.invalidateResumeWork();
this.connectingPromise = null;
this.clearDeferredDiagnostics();
this.connection.reconnect();
this.client = null;
this.state.busy = false;
this.state.activeTurnId = null;
this.state.approvals = [];
this.state.pendingUserInputs = [];
this.state.userInputDrafts.clear();
this.dispatch({
type: "state/patched",
patch: {
busy: false,
activeTurnId: null,
approvals: [],
pendingUserInputs: [],
userInputDrafts: new Map(),
},
});
this.setStatus("Reconnecting...");
this.render();
@ -1373,13 +1369,7 @@ export class CodexChatView extends ItemView {
}
private toggleHistoryPanel(): void {
if (this.state.openDetails.has("history")) {
this.state.openDetails.delete("history");
} else {
this.state.openDetails.delete("status-panel");
this.state.runtimePicker = null;
this.state.openDetails.add("history");
}
this.dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
this.scheduleRender();
}
@ -1437,9 +1427,7 @@ export class CodexChatView extends ItemView {
private closeToolbarPanel(): void {
if (!this.hasOpenToolbarPanel()) return;
this.state.openDetails.delete("history");
this.state.openDetails.delete("status-panel");
this.state.runtimePicker = null;
this.dispatch({ type: "ui/panel-set", panel: null });
this.archiveConfirmThreadId = null;
this.scheduleRender();
}
@ -1484,13 +1472,7 @@ export class CodexChatView extends ItemView {
}
private toggleStatusPanel(): void {
if (this.state.openDetails.has("status-panel")) {
this.state.openDetails.delete("status-panel");
} else {
this.state.openDetails.delete("history");
this.state.runtimePicker = null;
this.state.openDetails.add("status-panel");
}
this.dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
this.scheduleRender();
}
@ -1597,6 +1579,16 @@ export class CodexChatView extends ItemView {
resolveApproval: (approval, action) => void this.resolveApproval(approval, action),
resolveUserInput: (input) => void this.resolveUserInput(input),
cancelUserInput: (input) => void this.cancelUserInput(input),
setOpenDetail: (key, open) => {
const openDetails = open
? new Set([...this.state.openDetails, key])
: new Set([...this.state.openDetails].filter((item) => item !== key));
this.dispatch({ type: "state/patched", patch: { openDetails } });
},
setUserInputDraft: (key, value) => {
const userInputDrafts = new Map([...this.state.userInputDrafts, [key, value]]);
this.dispatch({ type: "state/patched", patch: { userInputDrafts } });
},
},
);
}
@ -1611,7 +1603,7 @@ export class CodexChatView extends ItemView {
}
private queueMessagesBottomScroll(): void {
this.state.messagesPinnedToBottom = true;
this.dispatch({ type: "ui/messages-pinned-set", pinned: true });
this.nextMessageScrollIntent = "force-bottom";
}

View file

@ -23,18 +23,13 @@ export function createRememberedDetails(
cls: string,
summary: string,
defaultOpen = false,
onToggle?: () => void,
onToggle?: (key: string, open: boolean) => void,
): HTMLDetailsElement {
const details = parent.createEl("details", { cls });
details.open = openDetails.has(key) || defaultOpen;
details.createEl("summary", { text: summary });
details.ontoggle = () => {
if (details.open) {
openDetails.add(key);
} else {
openDetails.delete(key);
}
onToggle?.();
onToggle?.(key, details.open);
};
return details;
}

View file

@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { TFile } from "obsidian";
import { ChatMessageRenderer } from "../../../src/features/chat/chat-message-renderer";
import { createChatState } from "../../../src/features/chat/chat-state";
import { chatReducer, createChatState, type ChatAction, type ChatState, type ChatStateStore } from "../../../src/features/chat/chat-state";
import { installObsidianDomShims } from "./ui/dom-test-helpers";
import { notices } from "../../mocks/obsidian";
@ -225,7 +225,7 @@ function chatMessageRenderer(
},
} as never,
owner: {} as never,
state,
stateStore: testStoreForState(state),
vaultPath,
blockSignatures,
consumeScrollIntent: () => "auto",
@ -238,6 +238,17 @@ function chatMessageRenderer(
});
}
function testStoreForState(state: ChatState): ChatStateStore {
return {
getState: () => state,
dispatch(action: ChatAction) {
const next = chatReducer(state, action);
Object.assign(state, next);
return state;
},
};
}
function bindRenderedWikiLinks(renderer: ChatMessageRenderer, parent: HTMLElement, sourcePath: string): void {
(renderer as unknown as { bindRenderedWikiLinks: (parent: HTMLElement, sourcePath: string) => void }).bindRenderedWikiLinks(
parent,

View file

@ -668,7 +668,13 @@ describe("message stream block identity and message actions", () => {
withMessageContentScrollHeight(500, () => {
const copyText = vi.fn();
const openDetails = new Set<string>();
const onDetailsToggle = vi.fn();
const onDetailsToggle = vi.fn((key: string, open: boolean) => {
if (open) {
openDetails.add(key);
} else {
openDetails.delete(key);
}
});
const block = messageRenderBlocks({
activeThreadId: "thread",
activeTurnId: null,