mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Consolidate small chat helper modules
This commit is contained in:
parent
5dca73b0f2
commit
079f997c5b
22 changed files with 246 additions and 291 deletions
|
|
@ -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[];
|
||||
|
|
|
|||
102
src/features/chat/display/action-candidates.ts
Normal file
102
src/features/chat/display/action-candidates.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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%"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue