Consolidate small chat helper modules

This commit is contained in:
murashit 2026-06-08 09:50:39 +09:00
parent 5dca73b0f2
commit 079f997c5b
22 changed files with 246 additions and 291 deletions

View file

@ -4,7 +4,7 @@ import type { Thread } from "../../generated/app-server/v2/Thread";
import type { PendingApproval } from "./requests/approvals/model";
import type { PendingUserInput } from "./requests/user-input/model";
import type { DisplayItem } from "./display/types";
import { implementPlanCandidateFromState } from "./display/plan-implementation";
import { implementPlanCandidateFromState } from "./display/action-candidates";
export interface PendingRequestSnapshot {
approvals: readonly PendingApproval[];

View file

@ -0,0 +1,102 @@
import {
chatTurnBusy,
type ChatActiveThreadState,
type ChatComposerState,
type ChatRuntimeState,
type ChatTranscriptState,
type ChatTurnState,
} from "../chat-state";
import type { DisplayItem, MessageDisplayItem } from "./types";
import { isCompletedTurnOutcomeMessage } from "./turn-outcome-message";
export interface ForkCandidate {
itemId: string;
turnId: string;
}
export interface RollbackCandidate {
turnId: string;
itemId: string;
text: string;
}
export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly ForkCandidate[] {
const turnOutcomeItemsByTurn = new Map<string, ForkCandidate>();
for (const item of items) {
if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue;
turnOutcomeItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId });
}
return [...turnOutcomeItemsByTurn.values()];
}
export function isForkCandidateItem(item: DisplayItem, candidates: readonly ForkCandidate[]): boolean {
return candidates.some((candidate) => item.id === candidate.itemId && item.turnId === candidate.turnId);
}
export function turnsAfterTurnId(items: readonly DisplayItem[], turnId: string): number | null {
const turnIds = orderedTurnIds(items);
const index = turnIds.indexOf(turnId);
return index === -1 ? null : turnIds.length - index - 1;
}
export function rollbackCandidateFromItems(items: readonly DisplayItem[]): RollbackCandidate | null {
const lastTurnId = latestTurnId(items);
if (!lastTurnId) return null;
const userMessage = items.find((item): item is MessageDisplayItem => isUserMessageForTurn(item, lastTurnId));
if (!userMessage) return null;
return {
turnId: lastTurnId,
itemId: userMessage.id,
text: userMessage.text,
};
}
export function isRollbackCandidateItem(item: DisplayItem, candidate: RollbackCandidate | null): boolean {
return Boolean(
candidate && item.kind === "message" && item.role === "user" && item.id === candidate.itemId && item.turnId === candidate.turnId,
);
}
export function implementPlanCandidateFromState(state: {
activeThread: Pick<ChatActiveThreadState, "id">;
turn: ChatTurnState;
composer: Pick<ChatComposerState, "draft">;
runtime: Pick<ChatRuntimeState, "selectedCollaborationMode">;
transcript: Pick<ChatTranscriptState, "displayItems">;
}): DisplayItem | null {
if (
!state.activeThread.id ||
chatTurnBusy(state) ||
state.composer.draft.trim().length > 0 ||
state.runtime.selectedCollaborationMode !== "plan"
) {
return null;
}
return (
[...state.transcript.displayItems].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null
);
}
function orderedTurnIds(items: readonly DisplayItem[]): string[] {
const turnIds: string[] = [];
const seen = new Set<string>();
for (const item of items) {
if (!item.turnId || seen.has(item.turnId)) continue;
seen.add(item.turnId);
turnIds.push(item.turnId);
}
return turnIds;
}
function latestTurnId(items: readonly DisplayItem[]): string | null {
for (const item of [...items].reverse()) {
if (item.turnId) return item.turnId;
}
return null;
}
function isUserMessageForTurn(item: DisplayItem, turnId: string): item is MessageDisplayItem {
return item.kind === "message" && item.role === "user" && item.turnId === turnId;
}

View file

@ -1,37 +0,0 @@
import type { DisplayItem } from "./types";
import { isCompletedTurnOutcomeMessage } from "./turn-outcome-message";
export interface ForkCandidate {
itemId: string;
turnId: string;
}
export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly ForkCandidate[] {
const turnOutcomeItemsByTurn = new Map<string, ForkCandidate>();
for (const item of items) {
if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue;
turnOutcomeItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId });
}
return [...turnOutcomeItemsByTurn.values()];
}
export function isForkCandidateItem(item: DisplayItem, candidates: readonly ForkCandidate[]): boolean {
return candidates.some((candidate) => item.id === candidate.itemId && item.turnId === candidate.turnId);
}
export function turnsAfterTurnId(items: readonly DisplayItem[], turnId: string): number | null {
const turnIds = orderedTurnIds(items);
const index = turnIds.indexOf(turnId);
return index === -1 ? null : turnIds.length - index - 1;
}
function orderedTurnIds(items: readonly DisplayItem[]): string[] {
const turnIds: string[] = [];
const seen = new Set<string>();
for (const item of items) {
if (!item.turnId || seen.has(item.turnId)) continue;
seen.add(item.turnId);
turnIds.push(item.turnId);
}
return turnIds;
}

View file

@ -1,29 +0,0 @@
import {
chatTurnBusy,
type ChatActiveThreadState,
type ChatComposerState,
type ChatRuntimeState,
type ChatTranscriptState,
type ChatTurnState,
} from "../chat-state";
import type { DisplayItem } from "./types";
export function implementPlanCandidateFromState(state: {
activeThread: Pick<ChatActiveThreadState, "id">;
turn: ChatTurnState;
composer: Pick<ChatComposerState, "draft">;
runtime: Pick<ChatRuntimeState, "selectedCollaborationMode">;
transcript: Pick<ChatTranscriptState, "displayItems">;
}): DisplayItem | null {
if (
!state.activeThread.id ||
chatTurnBusy(state) ||
state.composer.draft.trim().length > 0 ||
state.runtime.selectedCollaborationMode !== "plan"
) {
return null;
}
return (
[...state.transcript.displayItems].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null
);
}

View file

@ -1,38 +0,0 @@
import type { DisplayItem, MessageDisplayItem } from "./types";
export interface RollbackCandidate {
turnId: string;
itemId: string;
text: string;
}
export function rollbackCandidateFromItems(items: readonly DisplayItem[]): RollbackCandidate | null {
const lastTurnId = latestTurnId(items);
if (!lastTurnId) return null;
const userMessage = items.find((item): item is MessageDisplayItem => isUserMessageForTurn(item, lastTurnId));
if (!userMessage) return null;
return {
turnId: lastTurnId,
itemId: userMessage.id,
text: userMessage.text,
};
}
export function isRollbackCandidateItem(item: DisplayItem, candidate: RollbackCandidate | null): boolean {
return Boolean(
candidate && item.kind === "message" && item.role === "user" && item.id === candidate.itemId && item.turnId === candidate.turnId,
);
}
function latestTurnId(items: readonly DisplayItem[]): string | null {
for (const item of [...items].reverse()) {
if (item.turnId) return item.turnId;
}
return null;
}
function isUserMessageForTurn(item: DisplayItem, turnId: string): item is MessageDisplayItem {
return item.kind === "message" && item.role === "user" && item.turnId === turnId;
}

View file

@ -1,12 +0,0 @@
import { connectionDiagnosticSections } from "../../diagnostics";
import type { ConnectionDiagnosticsModelInput } from "./types";
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
return connectionDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,
initializeResponse: input.state.connection.initializeResponse,
activeThreadCreationCliVersion: input.state.activeThread.creationCliVersion,
diagnostics: input.state.connection.appServerDiagnostics,
});
}

View file

@ -1,6 +1,6 @@
export type { ComposerMetaViewModel, RestoredThreadTitleSnapshot, RuntimeChoice } from "./types";
export { composerMetaViewModel, composerPlaceholder } from "./composer";
export { connectionDiagnosticsModel } from "./diagnostics";
export { connectionDiagnosticsModel } from "./toolbar";
export { activeComposerThreadName, activeThreadTitle, chatViewDisplayTitle } from "./thread-title";
export { toolbarViewModel } from "./toolbar";
export { effortStatusLines, modelStatusLines, runtimeComposerChoices, runtimeSnapshotForChatState, statusSummaryLines } from "./runtime";

View file

@ -7,9 +7,8 @@ import {
supportedReasoningEfforts,
} from "../../../../runtime/effective-settings";
import { sortedAvailableModels } from "../../../../runtime/models";
import { contextSummary, rateLimitSummary } from "../../../../runtime/status-summary";
import { contextSummary, rateLimitSummary, type RateLimitSummary } from "../../../../runtime/status-summary";
import type { ChatState } from "../../chat-state";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import type { RuntimeChoice, RuntimeComposerChoicesInput, RuntimeSnapshotInput } from "./types";
export function runtimeSnapshotForChatState({ state }: RuntimeSnapshotInput) {
@ -104,3 +103,22 @@ export function effortStatusLines(state: ChatState, snapshot: ReturnType<typeof
`Supported: ${supportedReasoningEfforts(snapshot).join(", ")}`,
];
}
function statusValue(value: unknown, fallback: string): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
if (value === null || value === undefined) return fallback;
return jsonPreview(value, fallback);
}
function usageLimitStatusLines(limit: RateLimitSummary): string[] {
return ["Usage limits", ...limit.rows.map((row) => `${row.label}: ${row.value}${row.resetLabel ? ` (${row.resetLabel})` : ""}`)];
}
function jsonPreview(value: unknown, fallback: string): string {
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}

View file

@ -1,20 +0,0 @@
import type { RateLimitSummary } from "../../../../runtime/status-summary";
export function statusValue(value: unknown, fallback: string): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
if (value === null || value === undefined) return fallback;
return jsonPreview(value, fallback);
}
export function usageLimitStatusLines(limit: RateLimitSummary): string[] {
return ["Usage limits", ...limit.rows.map((row) => `${row.label}: ${row.value}${row.resetLabel ? ` (${row.resetLabel})` : ""}`)];
}
function jsonPreview(value: unknown, fallback: string): string {
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}

View file

@ -1,40 +0,0 @@
import type { EffectiveConfigSection, RateLimitSummary } from "../../../../runtime/status-summary";
type ToolbarPanelKind = "history" | "chat-actions" | "status";
export interface ToolbarThreadRow {
title: string;
threadId: string;
selected: boolean;
disabled: boolean;
canArchive: boolean;
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
rename: {
draft: string;
generating: boolean;
} | null;
}
interface ToolbarDiagnosticRow {
label: string;
value: string;
level?: "normal" | "warning" | "error";
}
export interface ToolbarDiagnosticSection {
title: string;
rows: ToolbarDiagnosticRow[];
}
export interface ToolbarViewModel {
newChatDisabled: boolean;
chatActionsOpen: boolean;
historyOpen: boolean;
statusPanelOpen: boolean;
rateLimit: RateLimitSummary | null;
configSections: EffectiveConfigSection[];
openPanel: ToolbarPanelKind | null;
threads: ToolbarThreadRow[];
connectLabel: string;
diagnostics: ToolbarDiagnosticSection[];
}

View file

@ -1,9 +1,8 @@
import type { Thread } from "../../../../generated/app-server/v2/Thread";
import { getThreadTitle } from "../../../../domain/threads/model";
import { effectiveConfigSections, rateLimitSummary } from "../../../../runtime/status-summary";
import type { ToolbarThreadRow, ToolbarViewModel } from "./toolbar-model";
import { connectionDiagnosticsModel } from "./diagnostics";
import type { ToolbarViewModelInput } from "./types";
import { connectionDiagnosticSections } from "../../diagnostics";
import type { ConnectionDiagnosticsModelInput, ToolbarThreadRow, ToolbarViewModel, ToolbarViewModelInput } from "./types";
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
@ -60,3 +59,13 @@ function toolbarThreadRows(input: {
};
});
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
return connectionDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,
initializeResponse: input.state.connection.initializeResponse,
activeThreadCreationCliVersion: input.state.activeThread.creationCliVersion,
diagnostics: input.state.connection.appServerDiagnostics,
});
}

View file

@ -1,7 +1,7 @@
import type { ReasoningEffort } from "../../../../generated/app-server/ReasoningEffort";
import type { RuntimeSnapshot } from "../../../../runtime/effective-settings";
import type { EffectiveConfigSection, RateLimitSummary } from "../../../../runtime/status-summary";
import type { ChatState } from "../../chat-state";
import type { ToolbarThreadRow } from "./toolbar-model";
export interface RuntimeSnapshotInput {
state: ChatState;
@ -38,6 +38,43 @@ export interface ComposerContextMeterViewModel {
percent: string;
}
export interface ToolbarThreadRow {
title: string;
threadId: string;
selected: boolean;
disabled: boolean;
canArchive: boolean;
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
rename: {
draft: string;
generating: boolean;
} | null;
}
interface ToolbarDiagnosticRow {
label: string;
value: string;
level?: "normal" | "warning" | "error";
}
export interface ToolbarDiagnosticSection {
title: string;
rows: ToolbarDiagnosticRow[];
}
export interface ToolbarViewModel {
newChatDisabled: boolean;
chatActionsOpen: boolean;
historyOpen: boolean;
statusPanelOpen: boolean;
rateLimit: RateLimitSummary | null;
configSections: EffectiveConfigSection[];
openPanel: "history" | "chat-actions" | "status" | null;
threads: ToolbarThreadRow[];
connectLabel: string;
diagnostics: ToolbarDiagnosticSection[];
}
export interface ToolbarViewModelInput {
state: ChatState;
snapshot: RuntimeSnapshot;

View file

@ -2,7 +2,7 @@ import type { ReasoningEffort } from "../../../../generated/app-server/Reasoning
import type { RuntimeSnapshot } from "../../../../runtime/effective-settings";
import type { SendShortcut } from "../../../../shared/ui/keyboard";
import type { ChatState } from "../../chat-state";
import type { ToolbarThreadRow } from "../model/toolbar-model";
import type { ToolbarThreadRow } from "../model/types";
import type { RestoredThreadTitleSnapshot } from "../model";
interface ChatViewToolbarActions {

View file

@ -6,8 +6,7 @@ import type { ArchiveExportAdapter } from "../../../domain/threads/export";
import { inheritedForkThreadName, upsertThread } from "../../../domain/threads/model";
import type { CodexPanelSettings } from "../../../settings/model";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
import { turnsAfterTurnId } from "../display/fork";
import { rollbackCandidateFromItems } from "../display/rollback";
import { rollbackCandidateFromItems, turnsAfterTurnId } from "../display/action-candidates";
import type { ThreadHistoryController } from "./thread-history-controller";
export interface ChatThreadActionsHost {

View file

@ -3,9 +3,13 @@ import type { ComponentChild as UiNode } from "preact";
import type { ChatState } from "../../chat-state";
import { chatTurnBusy } from "../../chat-state";
import type { DisplayItem } from "../../display/types";
import { forkCandidatesFromItems, isForkCandidateItem } from "../../display/fork";
import { implementPlanCandidateFromState } from "../../display/plan-implementation";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "../../display/rollback";
import {
forkCandidatesFromItems,
implementPlanCandidateFromState,
isForkCandidateItem,
isRollbackCandidateItem,
rollbackCandidateFromItems,
} from "../../display/action-candidates";
import type { ChatTurnDiffViewState } from "../turn-diff";
import type { MessageStreamContext } from "./context";

View file

@ -4,7 +4,7 @@ import { useLayoutEffect, useRef } from "preact/hooks";
import type { EffectiveConfigSection, RateLimitSummary } from "../../../runtime/status-summary";
import { IconButton } from "../../../shared/ui/components";
import { renderUiRoot } from "../../../shared/ui/ui-root";
import type { ToolbarDiagnosticSection, ToolbarThreadRow, ToolbarViewModel } from "../panel/model/toolbar-model";
import type { ToolbarDiagnosticSection, ToolbarThreadRow, ToolbarViewModel } from "../panel/model/types";
type ButtonProps = ButtonHTMLAttributes & {
disabled?: boolean | undefined;

View file

@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { forkCandidatesFromItems, isForkCandidateItem, turnsAfterTurnId } from "../../../../src/features/chat/display/fork";
import {
forkCandidatesFromItems,
isForkCandidateItem,
isRollbackCandidateItem,
rollbackCandidateFromItems,
turnsAfterTurnId,
} from "../../../../src/features/chat/display/action-candidates";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
describe("fork candidates", () => {
@ -63,6 +69,58 @@ describe("fork candidates", () => {
});
});
describe("rollback candidate", () => {
it("selects the first user message from the latest turn", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "older answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "latest", turnId: "turn-2" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "latest answer",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const candidate = rollbackCandidateFromItems(items);
expect(candidate).toEqual({ turnId: "turn-2", itemId: "u2", text: "latest" });
expect(isRollbackCandidateItem(expectPresent(items[2]), candidate)).toBe(true);
expect(isRollbackCandidateItem(expectPresent(items[0]), candidate)).toBe(false);
expect(isRollbackCandidateItem({ ...expectPresent(items[2]), turnId: "turn-other" }, candidate)).toBe(false);
});
it("returns null when there is no completed user turn in display items", () => {
expect(rollbackCandidateFromItems([])).toBeNull();
expect(rollbackCandidateFromItems([{ id: "system", kind: "system", role: "system", text: "Idle" }])).toBeNull();
expect(
rollbackCandidateFromItems([
{
id: "a1",
kind: "message",
role: "assistant",
text: "answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toBeNull();
});
});
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;

View file

@ -1,61 +0,0 @@
import { describe, expect, it } from "vitest";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "../../../../src/features/chat/display/rollback";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}
describe("rollback candidate", () => {
it("selects the first user message from the latest turn", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "older answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "latest", turnId: "turn-2" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "latest answer",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const candidate = rollbackCandidateFromItems(items);
expect(candidate).toEqual({ turnId: "turn-2", itemId: "u2", text: "latest" });
expect(isRollbackCandidateItem(expectPresent(items[2]), candidate)).toBe(true);
expect(isRollbackCandidateItem(expectPresent(items[0]), candidate)).toBe(false);
expect(isRollbackCandidateItem({ ...expectPresent(items[2]), turnId: "turn-other" }, candidate)).toBe(false);
});
it("returns null when there is no completed user turn in display items", () => {
expect(rollbackCandidateFromItems([])).toBeNull();
expect(rollbackCandidateFromItems([{ id: "system", kind: "system", role: "system", text: "Idle" }])).toBeNull();
expect(
rollbackCandidateFromItems([
{
id: "a1",
kind: "message",
role: "assistant",
text: "answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toBeNull();
});
});

View file

@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import { statusValue, usageLimitStatusLines } from "../../../../src/features/chat/panel/model/status-lines";
import type { RateLimitSummary } from "../../../../src/runtime/status-summary";
describe("status line helpers", () => {
it("formats primitive and structured diagnostic values", () => {
expect(statusValue("gpt-5.5", "(default)")).toBe("gpt-5.5");
expect(statusValue(42, "(default)")).toBe("42");
expect(statusValue(false, "(default)")).toBe("false");
expect(statusValue(null, "(default)")).toBe("(default)");
expect(statusValue({ provider: "openai" }, "(default)")).toBe('{"provider":"openai"}');
});
it("formats usage limit rows for slash command output", () => {
const summary: RateLimitSummary = {
title: "Codex usage limits",
level: "warn",
rows: [
{
label: "5h",
value: "72%",
percent: 72,
meterDivisions: 5,
level: "warn",
title: "5h usage",
resetLabel: "reset in 2h",
},
{ label: "1w", value: "15%", percent: 15, meterDivisions: 7, level: "ok", title: "1w usage", resetLabel: null },
],
};
expect(usageLimitStatusLines(summary)).toEqual(["Usage limits", "5h: 72% (reset in 2h)", "1w: 15%"]);
});
});

View file

@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/client";
import { createChatState, createChatStateStore, type ChatStateStore } from "../../../../src/features/chat/chat-state";
import { implementPlanCandidateFromState } from "../../../../src/features/chat/display/plan-implementation";
import { implementPlanCandidateFromState } from "../../../../src/features/chat/display/action-candidates";
import {
createPlanImplementationActions,
type PlanImplementationActionsHost,

View file

@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { act } from "preact/test-utils";
import type { DisplayItem } from "../../../../../src/features/chat/display/types";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/display/plan-implementation";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/display/action-candidates";
import { topLevelDetailsSummaries } from "../../../../support/dom";
import "./setup";
import {

View file

@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import type { ToolbarViewModel } from "../../../../../src/features/chat/panel/model/toolbar-model";
import type { ToolbarViewModel } from "../../../../../src/features/chat/panel/model/types";
import { renderToolbar } from "../../../../../src/features/chat/ui/toolbar";
import { changeInputValue, installObsidianDomShims } from "../../../../support/dom";