mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Route chat view UI state through ChatState reducer
This commit is contained in:
parent
bfc784a05d
commit
4eef00387b
10 changed files with 293 additions and 227 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue