diff --git a/.changeset/agent-download-applet.md b/.changeset/agent-download-applet.md new file mode 100644 index 0000000000..fa05625bb4 --- /dev/null +++ b/.changeset/agent-download-applet.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +The agent now hands over files it creates instead of describing where to find them. A new `offer-download` action resolves a workspace file to an access-scoped download URL and renders it as a compact download card in chat, the resources route supports `?download` for a real save-to-disk response, and a framework rule tells the agent to hand over artifacts rather than give app-navigation directions. diff --git a/.changeset/chat-queue-and-model-picker.md b/.changeset/chat-queue-and-model-picker.md new file mode 100644 index 0000000000..6038da8d57 --- /dev/null +++ b/.changeset/chat-queue-and-model-picker.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/toolkit": patch +--- + +Queued chat messages are now manageable: each shows a Queued chip and its position, and can be edited in place, reordered, removed, or sent immediately (interrupting the active run). A turn that ended in an error keeps a compact error marker in the transcript instead of losing it as soon as the next message is sent, and the model picker states when a family is listed cheapest first and notes an in-session model switch in the transcript. diff --git a/.changeset/chat-transcript-work-accuracy.md b/.changeset/chat-transcript-work-accuracy.md new file mode 100644 index 0000000000..edf4c1c11a --- /dev/null +++ b/.changeset/chat-transcript-work-accuracy.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Chat transcript now reports work accurately. Each work group in a turn shows its own duration instead of every group repeating the whole-turn time, a `tool_done` that matches no in-flight card settles that card rather than being dropped (which left tools spinning "Still working" for the rest of the turn), and a tool that never reported back renders as an unknown outcome instead of a spinner or a clean success. diff --git a/.changeset/context-meter-freshness.md b/.changeset/context-meter-freshness.md new file mode 100644 index 0000000000..2b5b79cec3 --- /dev/null +++ b/.changeset/context-meter-freshness.md @@ -0,0 +1,11 @@ +--- +"@agent-native/core": patch +"@agent-native/toolkit": patch +--- + +Context meter now reports its own freshness. `context-manifest-get` returns the +thread's newest turn id, `writeContextManifest` returns a typed persist outcome +instead of a swallowed rejection, and the meter dims with an explanation when +the manifest trails the running turn or shows an em dash when no usable reading +exists. The meter also refetches while a run is streaming, which is what kept it +frozen on the first turn's percentage. diff --git a/packages/core/src/action-ui.ts b/packages/core/src/action-ui.ts index a2da3c28cc..0adfe3afed 100644 --- a/packages/core/src/action-ui.ts +++ b/packages/core/src/action-ui.ts @@ -3,6 +3,8 @@ export const ACTION_CHAT_UI_DATA_CHART_RENDERER = "core.data-chart"; export const ACTION_CHAT_UI_DATA_INSIGHTS_RENDERER = "core.data-insights"; export const ACTION_CHAT_UI_DATA_WIDGET_RENDERER = "core.data-widget"; export const ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER = "core.inline-extension"; +export const ACTION_CHAT_UI_DOWNLOAD_ARTIFACT_RENDERER = + "core.download-artifact"; export interface ActionChatUIConfig { /** diff --git a/packages/core/src/agent/context-xray/actions/context-manifest-get.ts b/packages/core/src/agent/context-xray/actions/context-manifest-get.ts index 6e2304b820..3ae94d0dfd 100644 --- a/packages/core/src/agent/context-xray/actions/context-manifest-get.ts +++ b/packages/core/src/agent/context-xray/actions/context-manifest-get.ts @@ -9,7 +9,9 @@ import { type ContextManifest, } from "../../../shared/context-xray.js"; import { callerOwnsThread } from "../../run-ownership.js"; +import { getRunByThread } from "../../run-store.js"; import { readContextManifest } from "../directives-store.js"; +import { getContextManifestWriteOutcome } from "../manifest.js"; import { contextXrayAuthError, contextXrayThreadNotFoundError, @@ -62,14 +64,29 @@ export default defineAction({ if (!ownerEmail) throw contextXrayAuthError(); const ownsThread = await callerOwnsThread(ownerEmail, args.threadId); if (!ownsThread) throw contextXrayThreadNotFoundError(); + const stored = await readContextManifest(args.threadId); const manifest = - (await readContextManifest(args.threadId)) ?? - emptyContextManifest(args.threadId, { enforceable: true }); + stored ?? emptyContextManifest(args.threadId, { enforceable: true }); + const latestRun = await getRunByThread(args.threadId, { + includeTerminal: true, + }); + const latestTurnId = latestRun + ? (latestRun.turnId ?? latestRun.id) + : undefined; + // A persist failure recorded after the stored manifest was written means + // the stored manifest describes an older turn than the one that just ran. + const outcome = getContextManifestWriteOutcome(args.threadId); + const writeFailedAfterStored = + outcome?.status === "failed" && + outcome.failedAt > (stored?.updatedAt ?? 0); return { ...manifest, url: contextXrayDeepLink(args.threadId), enforceable: manifest.enforceable ?? true, source: manifest.source ?? "structured", + ...(latestTurnId ? { latestTurnId } : {}), + ...(latestRun ? { latestTurnStartedAt: latestRun.startedAt } : {}), + ...(writeFailedAfterStored ? { writeStatus: "failed" as const } : {}), }; }, }); diff --git a/packages/core/src/agent/context-xray/manifest-write.spec.ts b/packages/core/src/agent/context-xray/manifest-write.spec.ts new file mode 100644 index 0000000000..f9e28b1841 --- /dev/null +++ b/packages/core/src/agent/context-xray/manifest-write.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + _resetContextManifestWriteOutcomes, + buildManifest, + getContextManifestWriteOutcome, + writeContextManifest, +} from "./manifest.js"; + +const appStatePut = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../../application-state/store.js", () => ({ appStatePut })); + +describe("writeContextManifest outcomes", () => { + it("reports a failed persist instead of looking like a completed one", async () => { + _resetContextManifestWriteOutcomes(); + appStatePut.mockRejectedValueOnce(new Error("store unavailable")); + const manifest = await buildManifest({ + threadId: "thread-write", + turnId: "turn-2", + rawMessages: [], + sentMessages: [], + appliedStatus: new Map(), + directives: new Map(), + }); + + const failed = await writeContextManifest("thread-write", manifest); + expect(failed).toMatchObject({ + status: "failed", + turnId: "turn-2", + error: "store unavailable", + }); + expect(getContextManifestWriteOutcome("thread-write")).toBe(failed); + + const written = await writeContextManifest("thread-write", manifest); + expect(written.status).toBe("written"); + expect(appStatePut).toHaveBeenLastCalledWith( + "thread-write", + expect.any(String), + expect.objectContaining({ writeStatus: "written", turnId: "turn-2" }), + expect.objectContaining({ requestSource: "context-xray" }), + ); + _resetContextManifestWriteOutcomes(); + }); +}); diff --git a/packages/core/src/agent/context-xray/manifest.ts b/packages/core/src/agent/context-xray/manifest.ts index 55491e8055..ebe47ee56e 100644 --- a/packages/core/src/agent/context-xray/manifest.ts +++ b/packages/core/src/agent/context-xray/manifest.ts @@ -239,18 +239,101 @@ export async function buildManifest( }; } +export type ContextManifestWriteOutcome = + | { status: "written"; turnId?: string; updatedAt: number } + | { status: "failed"; turnId?: string; failedAt: number; error: string }; + +/** + * Last persist outcome per thread. A failed write leaves the previous turn's + * manifest in application state, which reads back as a perfectly plausible + * manifest — this map is the only thing that can tell a reader the newest + * turn never made it to storage. + */ +const writeOutcomes = new Map(); +const MAX_TRACKED_WRITE_OUTCOMES = 500; + +function rememberWriteOutcome( + threadId: string, + outcome: ContextManifestWriteOutcome, +): void { + writeOutcomes.delete(threadId); + writeOutcomes.set(threadId, outcome); + while (writeOutcomes.size > MAX_TRACKED_WRITE_OUTCOMES) { + const oldest = writeOutcomes.keys().next(); + if (oldest.done) break; + writeOutcomes.delete(oldest.value); + } +} + +export function getContextManifestWriteOutcome( + threadId: string, +): ContextManifestWriteOutcome | undefined { + return writeOutcomes.get(threadId); +} + +/** + * Record that no manifest could be produced for a turn (the transform threw + * before it ever reached `writeContextManifest`). + */ +export function recordContextManifestWriteFailure( + threadId: string, + error: unknown, + turnId?: string, +): void { + rememberWriteOutcome(threadId, { + status: "failed", + ...(turnId ? { turnId } : {}), + failedAt: Date.now(), + error: error instanceof Error ? error.message : String(error), + }); +} + +/** @internal test helper */ +export function _resetContextManifestWriteOutcomes(): void { + writeOutcomes.clear(); +} + +/** + * Persist a manifest and report the outcome. Returns a typed failure instead + * of throwing so the fire-and-forget caller cannot drop it silently, and so + * `context-manifest-get` can tell readers the meter is showing an older turn. + */ export async function writeContextManifest( threadId: string, manifest: ContextManifest, -): Promise { - await appStatePut( - threadId, - CONTEXT_XRAY_MANIFEST_KEY, - manifest as unknown as Record, - { - requestSource: "context-xray", - }, - ); +): Promise { + const updatedAt = Date.now(); + const persisted: ContextManifest = { + ...manifest, + updatedAt, + writeStatus: "written", + }; + try { + await appStatePut( + threadId, + CONTEXT_XRAY_MANIFEST_KEY, + persisted as unknown as Record, + { + requestSource: "context-xray", + }, + ); + } catch (err) { + const outcome: ContextManifestWriteOutcome = { + status: "failed", + ...(manifest.turnId ? { turnId: manifest.turnId } : {}), + failedAt: Date.now(), + error: err instanceof Error ? err.message : String(err), + }; + rememberWriteOutcome(threadId, outcome); + return outcome; + } + const outcome: ContextManifestWriteOutcome = { + status: "written", + ...(manifest.turnId ? { turnId: manifest.turnId } : {}), + updatedAt, + }; + rememberWriteOutcome(threadId, outcome); + return outcome; } export function updateManifestSegmentStatus( diff --git a/packages/core/src/agent/engine/context-directives-transform.ts b/packages/core/src/agent/engine/context-directives-transform.ts index c838169402..35a1649713 100644 --- a/packages/core/src/agent/engine/context-directives-transform.ts +++ b/packages/core/src/agent/engine/context-directives-transform.ts @@ -3,6 +3,7 @@ import { applyContextDirectives } from "../context-xray/apply-directives.js"; import { loadContextDirectives } from "../context-xray/directives-store.js"; import { buildManifest, + recordContextManifestWriteFailure, writeContextManifest, } from "../context-xray/manifest.js"; import { computeProtectedSegmentIds } from "../context-xray/segments.js"; @@ -20,6 +21,11 @@ import type { EngineMessage } from "./types.js"; * break the turn. The manifest write is fire-and-forget (not awaited) so it * never adds latency to the model-call path. * + * Both failure paths record the miss through `recordContextManifestWriteFailure` + * / `writeContextManifest`'s typed outcome. Without that record the thread + * keeps its previous turn's manifest, and the context meter renders that + * older percentage as if it described the turn now running. + * * Moved verbatim out of `runAgentLoop`'s per-iteration setup — behavior * unchanged. Caller is still responsible for gating this on `threadId` being * present and for the separate Observational Memory pass that runs after it. @@ -56,14 +62,14 @@ export async function applyContextXrayTransformForIteration(opts: { source: "structured", enforceable: true, }); - void writeContextManifest(threadId, manifest).catch((err) => { - console.warn( - "[context-xray] failed to write manifest:", - err instanceof Error ? err.message : String(err), - ); + void writeContextManifest(threadId, manifest).then((outcome) => { + if (outcome.status === "failed") { + console.warn("[context-xray] failed to write manifest:", outcome.error); + } }); return transformedMessages; } catch (err) { + recordContextManifestWriteFailure(threadId, err, turnId); console.warn( "[context-xray] context transform skipped:", err instanceof Error ? err.message : String(err), diff --git a/packages/core/src/agent/run-manager.ts b/packages/core/src/agent/run-manager.ts index 8b621c54b0..53752c150f 100644 --- a/packages/core/src/agent/run-manager.ts +++ b/packages/core/src/agent/run-manager.ts @@ -1045,7 +1045,11 @@ export function startRun( const send = (event: AgentChatEvent) => { if (run.status === "aborted" && abort.signal.aborted) return; - const runEvent: RunEvent = { seq: run.events.length, event }; + const runEvent: RunEvent = { + seq: run.events.length, + event, + at: Date.now(), + }; if (isTerminalRunEvent(event)) { pendingTerminalEvent = runEvent; return; diff --git a/packages/core/src/agent/thread-data-builder.ts b/packages/core/src/agent/thread-data-builder.ts index ffdbe174e5..716b2c145b 100644 --- a/packages/core/src/agent/thread-data-builder.ts +++ b/packages/core/src/agent/thread-data-builder.ts @@ -24,6 +24,8 @@ interface ContentPart { completedSideEffect?: boolean; mcpApp?: AgentMcpAppPayload; chatUI?: ActionChatUIConfig; + startedAt?: number; + completedAt?: number; } interface BuildAssistantMessageOptions { @@ -129,16 +131,21 @@ export function buildAssistantMessage( } }; - const appendReasoning = (text: string) => { + const appendReasoning = (text: string, at?: number) => { const last = content[content.length - 1]; if (last && last.type === "reasoning") { last.text = (last.text ?? "") + text; + if (typeof at === "number") last.completedAt = at; } else { - content.push({ type: "reasoning", text }); + content.push({ + type: "reasoning", + text, + ...(typeof at === "number" ? { startedAt: at, completedAt: at } : {}), + }); } }; - for (const [index, { event }] of events.entries()) { + for (const [index, { event, at: eventAt }] of events.entries()) { if (event.type === "clear") { // A live stream always follows `clear` with the chunk that re-emits the // wiped content. A rebuild has no successor, so applying a TRAILING @@ -154,7 +161,7 @@ export function buildAssistantMessage( } if (event.type === "thinking") { - appendReasoning(event.text ?? ""); + appendReasoning(event.text ?? "", eventAt); continue; } @@ -170,6 +177,7 @@ export function buildAssistantMessage( toolName: event.tool ?? "unknown", argsText: JSON.stringify(args), args, + ...(typeof eventAt === "number" ? { startedAt: eventAt } : {}), }); continue; } @@ -209,6 +217,7 @@ export function buildAssistantMessage( const part = content[matchingIndex]; if (part?.type === "tool-call") { part.result = event.result ?? ""; + if (typeof eventAt === "number") part.completedAt = eventAt; if (event.isError !== undefined) part.isError = event.isError; if (event.completedSideEffect !== undefined) { part.completedSideEffect = event.completedSideEffect; @@ -411,11 +420,21 @@ function normalizeAttachmentIdentity(attachments: unknown): unknown { // turn rendered twice. The id never participates in message identity (history // replay regenerates its own ids), so hashing content without it is the correct // notion of "same message". +// Work timestamps are stripped for the same reason: the client stamps browser +// clock time as events arrive and the server stamps its own clock as they are +// emitted, so the same turn would otherwise hash two ways and render twice. function normalizeContentForFingerprint(content: unknown): unknown { if (!Array.isArray(content)) return content; return content.map((part: any) => - part && typeof part === "object" && part.type === "tool-call" - ? { ...part, toolCallId: undefined } + part && + typeof part === "object" && + (part.type === "tool-call" || part.type === "reasoning") + ? { + ...part, + ...(part.type === "tool-call" ? { toolCallId: undefined } : {}), + startedAt: undefined, + completedAt: undefined, + } : part, ); } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 5bf7794234..3b872f60d9 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -408,6 +408,13 @@ export function isContinuationTerminalReason(reason: unknown): boolean { export interface RunEvent { seq: number; event: AgentChatEvent; + /** + * Epoch ms the run emitted this event. Persisted transcripts are rebuilt + * from these events, so without a per-event time the rebuilt tool cards + * carry no duration and a reloaded thread falls back to printing the + * whole-turn duration. + */ + at?: number; } /** diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index 5be36dca4a..290c9dfc4f 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -106,6 +106,10 @@ import { isHiddenUserMessage, ServerRunActiveContext, } from "./chat/message-components.js"; +import { + QueuedMessageList, + reorderQueuedMessages, +} from "./chat/QueuedMessageList.js"; import { repoHasAssistantMessage, getRepoMessages, @@ -160,6 +164,7 @@ import { } from "./components/ui/tooltip.js"; import { AgentComposerFrame, + compactComposerModelName, TiptapComposer, type AgentComposerLayoutVariant, type ComposerSubmitIntent, @@ -1477,6 +1482,10 @@ export function resolveAssistantChatSubmitIntent({ isRunning: boolean; requestedIntent?: ComposerSubmitIntent; }): ComposerSubmitIntent { + // Always queue while a run is active. Plain Enter reports "immediate" (that + // is the composer default, not a request to interrupt), so honouring it here + // would make every follow-up abort the turn in progress. Interrupting is an + // explicit, per-message action on the queued row instead. if (isRunning) return "queued"; return requestedIntent ?? "immediate"; } @@ -4758,6 +4767,77 @@ const AssistantChatInner = forwardRef< ], ); + // Records a mid-thread model switch inline so the transcript shows which + // turns came after it. In-session only: messages carry no model metadata, so + // this cannot be reconstructed after a reload. + const [modelSwitchNotice, setModelSwitchNotice] = useState( + null, + ); + const notedModelRef = useRef(selectedModel); + useEffect(() => { + const previous = notedModelRef.current; + notedModelRef.current = selectedModel; + if (!selectedModel || !previous || previous === selectedModel) return; + if (messages.length === 0) return; + setModelSwitchNotice(selectedModel); + }, [selectedModel, messages.length]); + + const visibleQueuedMessages = useMemo( + () => + queuedMessages + .filter((msg) => !msg.hideUserMessage) + .map((msg) => ({ + id: msg.id, + text: displayableUserMessageText(msg.text), + imageSources: queuedMessageImageSources(msg), + })), + [queuedMessages], + ); + + const handleQueuedMessageEdit = useCallback( + (id: string, text: string) => { + applyLocalQueuedMessages((prev) => + prev.map((msg) => (msg.id === id ? { ...msg, text } : msg)), + ); + }, + [applyLocalQueuedMessages], + ); + + const handleQueuedMessageMove = useCallback( + (id: string, direction: -1 | 1) => { + applyLocalQueuedMessages((prev) => + reorderQueuedMessages(prev, id, direction), + ); + }, + [applyLocalQueuedMessages], + ); + + const handleQueuedMessageRemove = useCallback( + (id: string) => { + applyLocalQueuedMessages((prev) => prev.filter((msg) => msg.id !== id)); + }, + [applyLocalQueuedMessages], + ); + + /** + * Promote a queued message to the front and interrupt the active run. The + * abort preserves the rest of the queue, and the existing auto-dequeue path + * sends this message as soon as the run is clear. + */ + const handleQueuedMessageSendNow = useCallback( + (id: string) => { + applyLocalQueuedMessages((prev) => { + const index = prev.findIndex((msg) => msg.id === id); + if (index < 0) return prev; + const next = [...prev]; + const [promoted] = next.splice(index, 1); + return [promoted!, ...next]; + }); + stopActiveRunRef.current({ preserveQueuedMessages: true }); + }, + [applyLocalQueuedMessages], + ); + const mcpResumeTimerRef = useRef(null); const scheduleMcpConnectionResume = useCallback( (request: McpConnectionResumeRequest) => { @@ -5539,52 +5619,32 @@ const AssistantChatInner = forwardRef< /> )} - {queuedMessages - .filter((msg) => !msg.hideUserMessage) - .map((msg) => { - const displayText = - displayableUserMessageText(msg.text); - const imageSources = - queuedMessageImageSources(msg); - return ( - -
- -
- {displayText} - {imageSources.length > 0 && ( -
- {imageSources.map((img, j) => ( - - ))} -
- )} -
-
-
- ); - })} + {modelSwitchNotice && ( + +

+ Switched to{" "} + {compactComposerModelName( + modelSwitchNotice, + )} +

+
+ )} + ( + + {node} + + )} + /> {resolvedThreadFooterSlot ? (
diff --git a/packages/core/src/client/agent-chat-adapter.ts b/packages/core/src/client/agent-chat-adapter.ts index 957092eb23..abe794f917 100644 --- a/packages/core/src/client/agent-chat-adapter.ts +++ b/packages/core/src/client/agent-chat-adapter.ts @@ -3604,6 +3604,9 @@ export function createAgentChatAdapter( } const reason = authErrorReasonFromMessage(errMsg); dispatchAuthError(reason); + settleInterruptedToolCalls(content, undefined, { + includeActivity: true, + }); content.push({ type: "text", text: authErrorText(reason, errMsg), @@ -3629,6 +3632,9 @@ export function createAgentChatAdapter( }), ); } + settleInterruptedToolCalls(content, undefined, { + includeActivity: true, + }); content.push({ type: "text", text: failure.text }); yield { content: [...content], @@ -3819,6 +3825,11 @@ export function createAgentChatAdapter( }), ); } + // Every path that ends the run must settle: a tool card left + // unresolved here has no later chance to record its outcome. + settleInterruptedToolCalls(content, undefined, { + includeActivity: true, + }); content.push({ type: "text", text: errMsg.startsWith("Server error:") diff --git a/packages/core/src/client/chat-model-groups.spec.ts b/packages/core/src/client/chat-model-groups.spec.ts index c2fbd00d59..c44b417e3f 100644 --- a/packages/core/src/client/chat-model-groups.spec.ts +++ b/packages/core/src/client/chat-model-groups.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildChatModelGroups } from "./chat-model-groups.js"; +import { + buildChatModelGroups, + modelsAreCostRanked, +} from "./chat-model-groups.js"; describe("buildChatModelGroups", () => { it("groups every Builder gateway model family shown in the composer", () => { @@ -31,20 +34,29 @@ describe("buildChatModelGroups", () => { label: "OpenAI", models: ["gpt-5-6-luna", "gpt-5-6-terra", "gpt-5-6-sol"], configured: true, + costRanked: true, }, { engine: "builder", label: "Claude", models: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-4-8"], configured: true, + costRanked: true, }, { engine: "builder", label: "Gemini", models: ["gemini-3-1-pro"], configured: true, + costRanked: false, + }, + { + engine: "builder", + label: "More", + models: ["auto"], + configured: true, + costRanked: false, }, - { engine: "builder", label: "More", models: ["auto"], configured: true }, ]); }); @@ -170,6 +182,7 @@ describe("buildChatModelGroups", () => { label: "Groq", models: ["llama-3.3-70b-versatile"], configured: false, + costRanked: false, }, ]); }); @@ -218,6 +231,7 @@ describe("buildChatModelGroups", () => { label: "Claude", models: ["claude-sonnet-5"], configured: false, + costRanked: false, }, ]); }); @@ -242,7 +256,26 @@ describe("buildChatModelGroups", () => { label: "Custom", models: ["custom/provider-model"], configured: false, + costRanked: false, }, ]); }); }); + +describe("modelsAreCostRanked", () => { + it("is true when every model matches a known cost tier", () => { + expect(modelsAreCostRanked(["claude-haiku-4-5", "claude-sonnet-5"])).toBe( + true, + ); + }); + + it("is false when any model is unrecognised, because it sorts last regardless of price", () => { + expect(modelsAreCostRanked(["claude-haiku-4-5", "some-new-model"])).toBe( + false, + ); + }); + + it("is false for a single model, where there is no order to claim", () => { + expect(modelsAreCostRanked(["claude-sonnet-5"])).toBe(false); + }); +}); diff --git a/packages/core/src/client/chat-model-groups.ts b/packages/core/src/client/chat-model-groups.ts index 1ebf6020a9..b46a8f85c5 100644 --- a/packages/core/src/client/chat-model-groups.ts +++ b/packages/core/src/client/chat-model-groups.ts @@ -3,6 +3,13 @@ export interface EngineModelGroup { label: string; models: string[]; configured: boolean; + /** + * True when every model in `models` matched a known cost tier, so the list + * really does run cheapest to most expensive. False when at least one model + * is unrecognised: unrecognised models sort to the end regardless of what + * they actually cost, and the picker must not claim an order it cannot back. + */ + costRanked: boolean; } export interface ChatModelEngineEntry { @@ -62,6 +69,13 @@ function sortModelsByCost(models: readonly string[]): string[] { return [...models].sort((a, b) => modelCostRank(a) - modelCostRank(b)); } +export function modelsAreCostRanked(models: readonly string[]): boolean { + return ( + models.length > 1 && + models.every((model) => modelCostRank(model) < MODEL_COST_ORDER.length) + ); +} + function groupBuilderModels(models: readonly string[]): EngineModelGroup[] { const claude = sortModelsByCost( models.filter((model) => model.startsWith("claude-")), @@ -89,6 +103,7 @@ function groupBuilderModels(models: readonly string[]): EngineModelGroup[] { label: "OpenAI", models: openai, configured: true, + costRanked: modelsAreCostRanked(openai), }, ] : []), @@ -99,6 +114,7 @@ function groupBuilderModels(models: readonly string[]): EngineModelGroup[] { label: "Claude", models: claude, configured: true, + costRanked: modelsAreCostRanked(claude), }, ] : []), @@ -109,6 +125,7 @@ function groupBuilderModels(models: readonly string[]): EngineModelGroup[] { label: "Gemini", models: gemini, configured: true, + costRanked: modelsAreCostRanked(gemini), }, ] : []), @@ -119,6 +136,7 @@ function groupBuilderModels(models: readonly string[]): EngineModelGroup[] { label: "More", models: other, configured: true, + costRanked: modelsAreCostRanked(other), }, ] : []), @@ -191,17 +209,19 @@ export function buildChatModelGroups({ .sort(sortModelPickerEngines) .map((engine) => { const requiredEnvVars = engine.requiredEnvVars ?? []; + const models = sortModelsByCost( + addCurrentModel( + engine.supportedModels ?? [], + engine.name, + currentEngineName, + currentModel, + ), + ); return { engine: engine.name, label: engine.label, - models: sortModelsByCost( - addCurrentModel( - engine.supportedModels ?? [], - engine.name, - currentEngineName, - currentModel, - ), - ), + models, + costRanked: modelsAreCostRanked(models), configured: requiredEnvVars.length === 0 || requiredEnvVars.some((key) => configured.has(key)), diff --git a/packages/core/src/client/chat/QueuedMessageList.spec.ts b/packages/core/src/client/chat/QueuedMessageList.spec.ts new file mode 100644 index 0000000000..76c3ce74f6 --- /dev/null +++ b/packages/core/src/client/chat/QueuedMessageList.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { reorderQueuedMessages } from "./QueuedMessageList.js"; + +const queue = [{ id: "a" }, { id: "b" }, { id: "c" }]; + +describe("reorderQueuedMessages", () => { + it("moves a message earlier", () => { + expect(reorderQueuedMessages(queue, "c", -1).map((m) => m.id)).toEqual([ + "a", + "c", + "b", + ]); + }); + + it("moves a message later", () => { + expect(reorderQueuedMessages(queue, "a", 1).map((m) => m.id)).toEqual([ + "b", + "a", + "c", + ]); + }); + + it("keeps every message when the move runs off either end", () => { + expect(reorderQueuedMessages(queue, "a", -1).map((m) => m.id)).toEqual([ + "a", + "b", + "c", + ]); + expect(reorderQueuedMessages(queue, "c", 1).map((m) => m.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("keeps every message when the id is unknown", () => { + expect(reorderQueuedMessages(queue, "missing", 1).map((m) => m.id)).toEqual( + ["a", "b", "c"], + ); + }); + + it("does not mutate the input", () => { + const original = [...queue]; + reorderQueuedMessages(queue, "a", 1); + expect(queue).toEqual(original); + }); +}); diff --git a/packages/core/src/client/chat/QueuedMessageList.tsx b/packages/core/src/client/chat/QueuedMessageList.tsx new file mode 100644 index 0000000000..83e3db8ec9 --- /dev/null +++ b/packages/core/src/client/chat/QueuedMessageList.tsx @@ -0,0 +1,242 @@ +import { + IconArrowDown, + IconArrowUp, + IconCheck, + IconPencil, + IconPlayerPlay, + IconX, +} from "@tabler/icons-react"; +import React, { useEffect, useRef, useState } from "react"; + +export interface QueuedMessageView { + id: string; + text: string; + imageSources: string[]; +} + +export interface QueuedMessageListProps { + messages: readonly QueuedMessageView[]; + onEdit: (id: string, text: string) => void; + onMove: (id: string, direction: -1 | 1) => void; + onRemove: (id: string) => void; + /** + * Interrupt the active run and send this message now. Omitted when there is + * no run to interrupt, which is also when the affordance makes no sense. + */ + onSendNow?: (id: string) => void; + /** + * Lets the caller keep each row inside its own scroller item without this + * component depending on the scroller. + */ + wrap?: (id: string, node: React.ReactNode) => React.ReactNode; +} + +const ACTION_BUTTON_CLASS = + "flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"; + +function QueuedMessageRow({ + message, + position, + total, + onEdit, + onMove, + onRemove, + onSendNow, +}: { + message: QueuedMessageView; + position: number; + total: number; + onEdit: (id: string, text: string) => void; + onMove: (id: string, direction: -1 | 1) => void; + onRemove: (id: string) => void; + onSendNow?: (id: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(message.text); + const textareaRef = useRef(null); + + useEffect(() => { + if (!editing) setDraft(message.text); + }, [editing, message.text]); + + useEffect(() => { + if (editing) textareaRef.current?.focus(); + }, [editing]); + + const commit = () => { + const next = draft.trim(); + // An empty edit would silently destroy the message; treat it as a cancel + // and make the user use Remove if that is what they meant. + if (next.length > 0 && next !== message.text) onEdit(message.id, next); + setEditing(false); + }; + + return ( +
+
+ + Queued + + {position + 1}/{total} + + + {onSendNow && ( + + )} + + + + +
+ +
+ {editing ? ( +
+