From 4350c925ded94ec414575ea0f769356569ce4d07 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Tue, 29 Sep 2026 19:57:24 +0200 Subject: [PATCH 01/62] feat(web): add favorite model shortcuts above conversations --- apps/web/src/components/ChatView.tsx | 28 ++- .../chat/FavoriteModelShortcuts.test.tsx | 172 ++++++++++++++++++ .../chat/FavoriteModelShortcuts.tsx | 103 +++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/FavoriteModelShortcuts.test.tsx create mode 100644 apps/web/src/components/chat/FavoriteModelShortcuts.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61f1770c4b..07c2b489bc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -303,6 +303,7 @@ import { markPromotedDraftThreadByRef, restoreFailedBackgroundDraftThread, useComposerDraftStore, + useEffectiveComposerModelState, DraftId, } from "../composerDraftStore"; import { @@ -374,6 +375,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; +import { FavoriteModelShortcuts } from "./chat/FavoriteModelShortcuts"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -2845,7 +2847,7 @@ export default function ChatView(props: ChatViewProps) { ), [providerStatuses, settings], ); - const { selectedProviderEntry, requestedDriverKind } = useMemo( + const { selectedProviderEntry, requestedDriverKind, lockedContinuationGroupKey } = useMemo( () => resolveComposerProviderSelection({ entries: providerInstanceEntries, @@ -2870,6 +2872,15 @@ export default function ChatView(props: ChatViewProps) { ); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; + const { selectedModel: shortcutSelectedModel } = useEffectiveComposerModelState({ + threadRef: composerDraftTarget, + providers: providerStatuses, + selectedProvider, + selectedInstanceId: selectedProviderEntry?.instanceId, + threadModelSelection: activeThread?.modelSelection, + projectModelSelection: activeProjectDefaultModelSelection, + settings, + }); const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, @@ -9786,6 +9797,21 @@ export default function ChatView(props: ChatViewProps) { onDragLeave={workspaceFileDropHandlers.onDragLeave} onDrop={workspaceFileDropHandlers.onDrop} > + { + setMultipleModelSelections(null); + onProviderModelSelect(instanceId, model); + }} + /> {isWorkspaceFileDragActive ? (
({ favorites: [] as { provider: string; model: string }[] })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof client) => unknown) => select(client), +})); + +const instanceId = ProviderInstanceId.make("codex_work"); +const driver = ProviderDriverKind.make("codex"); +const entries = deriveProviderInstanceEntries([ + { + instanceId, + driver, + displayName: "Work", + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-09-29T00:00:00.000Z", + models: [ + { slug: "first", name: "First model", isCustom: false, capabilities: {} }, + { slug: "second", name: "Second model", isCustom: false, capabilities: {} }, + { slug: "third", name: "Third model", isCustom: false, capabilities: {} }, + ], + slashCommands: [], + skills: [], + }, +]); + +let container: HTMLDivElement; +let root: Root; +let resize: () => void; +let rows: number; +let disconnect: ReturnType; +let onSelect = vi.fn<(instanceId: ProviderInstanceId, model: string) => void>(); + +function render(overrides: Partial> = {}) { + act(() => + root.render( + null} + onSelect={onSelect} + {...overrides} + />, + ), + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + rows = 1; + disconnect = vi.fn(); + onSelect = vi.fn(); + client.favorites = ["first", "second", "third"].map((model) => ({ provider: instanceId, model })); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect = disconnect; + }, + ); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(() => rows * 32); + vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function ( + this: HTMLElement, + ) { + const index = Array.from(this.parentElement?.children ?? []).indexOf(this); + return Math.min(index, rows - 1) * 32; + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("favorite model shortcuts", () => { + it("switches the exact provider instance and updates the selected model", () => { + render(); + const buttons = container.querySelectorAll("button"); + expect(buttons[0]?.getAttribute("aria-pressed")).toBe("true"); + act(() => buttons[1]?.click()); + expect(onSelect).toHaveBeenCalledWith(instanceId, "second"); + render({ model: "second" }); + expect(buttons[0]?.getAttribute("aria-pressed")).toBe("false"); + expect(buttons[1]?.getAttribute("aria-pressed")).toBe("true"); + }); + + it.each([ + { disabled: true }, + { lockedProvider: ProviderDriverKind.make("claudeAgent") }, + { lockedProvider: driver, lockedContinuationGroupKey: "other-account" }, + { getModelDisabledReason: () => "Start a new thread." }, + { instanceEntries: entries.map((entry) => ({ ...entry, enabled: false })) }, + ])("prevents selection when restricted: %j", (overrides) => { + render(overrides); + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.every((button) => button.disabled)).toBe(true); + act(() => buttons.forEach((button) => button.click())); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("renders no group without favorites or when stored favorites no longer resolve", () => { + client.favorites = []; + render(); + expect(container.childElementCount).toBe(0); + client.favorites = [{ provider: "removed-instance", model: "first" }]; + render(); + expect(container.childElementCount).toBe(0); + client.favorites = [{ provider: instanceId, model: "removed-model" }]; + render(); + expect(container.childElementCount).toBe(0); + }); + + it("keeps two rows visible, hides three rows without oscillation, and restores on resize", () => { + rows = 2; + render(); + const group = container.querySelector('[role="group"]')!; + expect(group.style.visibility).toBe("visible"); + expect(group.parentElement?.style.height).toBe("64px"); + rows = 3; + act(() => resize()); + expect(group.style.visibility).toBe("hidden"); + expect(group.hasAttribute("inert")).toBe(true); + expect(group.parentElement?.style.height).toBe("0px"); + act(() => resize()); + expect(group.style.visibility).toBe("hidden"); + expect(group.children).toHaveLength(3); + rows = 1; + act(() => resize()); + expect(group.style.visibility).toBe("visible"); + expect(group.hasAttribute("inert")).toBe(false); + expect(group.parentElement?.style.height).toBe("32px"); + }); + + it("observes favorites added after mount and disconnects observation when removed", () => { + client.favorites = []; + render(); + client.favorites = [{ provider: instanceId, model: "first" }]; + render(); + expect(container.querySelector('[role="group"]')?.style.visibility).toBe( + "visible", + ); + client.favorites = []; + render(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/FavoriteModelShortcuts.tsx b/apps/web/src/components/chat/FavoriteModelShortcuts.tsx new file mode 100644 index 0000000000..682e4a4835 --- /dev/null +++ b/apps/web/src/components/chat/FavoriteModelShortcuts.tsx @@ -0,0 +1,103 @@ +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { useLayoutEffect, useRef, useState } from "react"; +import { useClientSettings } from "~/hooks/useSettings"; +import { type UnifiedSettings } from "@t3tools/contracts/settings"; +import { getAppModelOptionsForInstance } from "../../modelSelection"; +import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { ComposerControl } from "./ComposerControl"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; + +export function FavoriteModelShortcuts(props: { + instanceEntries: ReadonlyArray; + settings: UnifiedSettings; + activeInstanceId: ProviderInstanceId | null; + model: string; + selectedModels: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }> | null; + lockedProvider: ProviderDriverKind | null; + lockedContinuationGroupKey: string | null; + disabled: boolean; + getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; + onSelect: (instanceId: ProviderInstanceId, model: string) => void; +}) { + const favorites = useClientSettings((settings) => settings.favorites); + const groupRef = useRef(null); + const [height, setHeight] = useState(0); + const shortcuts = (favorites ?? []).flatMap((favorite) => { + const entry = props.instanceEntries.find((entry) => entry.instanceId === favorite.provider); + if (!entry) return []; + const model = getAppModelOptionsForInstance(props.settings, entry).find( + (model) => model.slug === favorite.model, + ); + if (!model) return []; + const disabledReason = + (!isProviderInstancePickerReady(entry) || model.isUnavailable + ? "Provider or model is unavailable." + : null) ?? + (props.lockedProvider !== null && + (entry.driverKind !== props.lockedProvider || + (props.lockedContinuationGroupKey !== null && + entry.continuationGroupKey !== props.lockedContinuationGroupKey)) + ? "Start a new thread to use this provider." + : null) ?? + props.getModelDisabledReason(entry.instanceId, model.slug); + return [{ entry, model, disabledReason }]; + }); + + useLayoutEffect(() => { + const group = groupRef.current; + if (!group || shortcuts.length === 0) return; + const measure = () => { + const rows = new Set(Array.from(group.children, (child) => (child as HTMLElement).offsetTop)); + setHeight(rows.size > 0 && rows.size <= 2 ? group.offsetHeight : 0); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(group); + return () => observer.disconnect(); + }, [shortcuts.length]); + + if (shortcuts.length === 0) return null; + + return ( +
+ {/* Keep the same width and layout while hidden so measuring cannot toggle visibility. */} +
+ {shortcuts.map(({ entry, model, disabledReason }) => ( + + selection.instanceId === entry.instanceId && selection.model === model.slug, + ) + : props.activeInstanceId === entry.instanceId && props.model === model.slug + } + disabled={props.disabled || disabledReason !== null} + title={disabledReason ?? `${entry.displayName}: ${model.name}`} + onClick={() => props.onSelect(entry.instanceId, model.slug)} + > + + {model.shortName ?? model.name} + + ))} +
+
+ ); +} From 9fc359cc51d88871dd79e1db5bbf94afe275dca2 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Tue, 29 Sep 2026 20:04:31 +0200 Subject: [PATCH 02/62] feat(web): add composer reasoning effort shortcuts --- apps/web/src/components/chat/ChatComposer.tsx | 8 + .../chat/ReasoningEffortShortcuts.test.tsx | 211 ++++++++++++++++++ .../chat/ReasoningEffortShortcuts.tsx | 79 +++++++ apps/web/src/components/chat/TraitsPicker.tsx | 36 ++- 4 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/ReasoningEffortShortcuts.test.tsx create mode 100644 apps/web/src/components/chat/ReasoningEffortShortcuts.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e..639cb7a3e8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,4 @@ +import { ReasoningEffortShortcuts } from "./ReasoningEffortShortcuts"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; @@ -6966,6 +6967,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerControlsInStrip ? null : composerControls}
+ {!isComposerResting && providerTraitsPicker ? ( + + ) : null} + {/* Right side: send / stop button */}
void; +let rows: number; +let disconnect: ReturnType; +let onPromptChange = vi.fn<(prompt: string) => void>(); + +function render( + overrides: Partial> = {}, + descriptors = [effort, fast], +) { + act(() => + root.render( + , + ), + ); +} + +function click(label: string) { + const button = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === label, + ); + expect(button).toBeDefined(); + act(() => button?.click()); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + rows = 1; + disconnect = vi.fn(); + onPromptChange = vi.fn(); + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect = disconnect; + }, + ); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(() => rows * 28); + vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return Math.min(Array.from(this.parentElement?.children ?? []).indexOf(this), rows - 1) * 28; + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("reasoning effort shortcuts", () => { + it("uses model labels and defaults, preserving other traits in the draft and sticky selection", () => { + render(); + expect( + Array.from(container.querySelectorAll("button"), (button) => button.textContent), + ).toEqual(["Quick", "Deep", "Maximum"]); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Deep"); + click("Maximum"); + const state = useComposerDraftStore.getState(); + const selection = state.stickyModelSelectionByProvider[instanceId]; + expect(selection).toEqual({ + instanceId, + model: "test-model", + options: [ + { id: "reasoningEffort", value: "max" }, + { id: "fastMode", value: true }, + ], + }); + expect(Object.values(state.draftsByThreadKey)[0]?.modelSelectionByProvider[instanceId]).toEqual( + selection, + ); + render({ modelOptions: selection?.options }); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Maximum"); + }); + + it.each(["effort", "variant", "reasoning"])( + "uses the %s descriptor without exposing unrelated selectors", + (id) => { + render({}, [ + { ...effort, id }, + { id: "agent", label: "Agent", type: "select", options: [{ id: "build", label: "Build" }] }, + ]); + click("Quick"); + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[instanceId]?.options, + ).toContainEqual({ id, value: "low" }); + expect(container.textContent).not.toContain("Build"); + }, + ); + + it("hides unsupported models and unavailable OpenCode metadata", () => { + render({}, [fast]); + expect(container.querySelector("button")).toBeNull(); + render({ + provider: ProviderDriverKind.make("opencode"), + models: [], + modelOptions: [{ id: "variant", value: "max" }], + }); + expect(container.querySelector("button")).toBeNull(); + }); + + const claude: ProviderOptionDescriptor = { + ...effort, + id: "effort", + type: "select", + options: [ + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }; + + it("inserts and removes the Claude prefix without persisting prompt-injected values", () => { + render({ provider: ProviderDriverKind.make("claudeAgent"), prompt: "Explain" }, [claude]); + click("Ultrathink"); + expect(onPromptChange).toHaveBeenLastCalledWith("Ultrathink:\nExplain"); + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[instanceId], + ).toBeUndefined(); + render({ prompt: "Ultrathink:\nExplain" }, [claude]); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Ultrathink"); + click("High"); + expect(onPromptChange).toHaveBeenLastCalledWith("Explain"); + }); + + it("locks effort when ultrathink remains in the prompt body and respects injection restrictions", () => { + render({ prompt: "Please ultrathink about this" }, [claude]); + expect(Array.from(container.querySelectorAll("button"), (button) => button.disabled)).toEqual([ + true, + true, + ]); + click("High"); + expect(onPromptChange).not.toHaveBeenCalled(); + render({ allowPromptInjectedEffort: false }, [claude]); + click("Ultrathink"); + expect(onPromptChange).not.toHaveBeenCalled(); + }); + + it("hides the entire group at three rows and restores it at two without remounting", () => { + render(); + const group = container.querySelector('[role="group"]'); + expect(group?.getAttribute("aria-hidden")).toBe("false"); + rows = 3; + act(() => resize()); + expect(group?.getAttribute("aria-hidden")).toBe("true"); + expect(group?.hasAttribute("inert")).toBe(true); + rows = 2; + act(() => resize()); + expect(group?.getAttribute("aria-hidden")).toBe("false"); + act(() => root.unmount()); + expect(disconnect).toHaveBeenCalled(); + root = createRoot(container); + }); +}); diff --git a/apps/web/src/components/chat/ReasoningEffortShortcuts.tsx b/apps/web/src/components/chat/ReasoningEffortShortcuts.tsx new file mode 100644 index 0000000000..48bd538da8 --- /dev/null +++ b/apps/web/src/components/chat/ReasoningEffortShortcuts.tsx @@ -0,0 +1,79 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; +import { ComposerControl } from "./ComposerControl"; +import { + type TraitsMenuContentProps, + type TraitsPersistence, + useTraitsSelection, +} from "./TraitsPicker"; + +export function ReasoningEffortShortcuts(props: TraitsMenuContentProps & TraitsPersistence) { + const traits = useTraitsSelection(props); + const descriptor = traits.selectDescriptors.find( + ({ id }) => + id === "reasoningEffort" || id === "effort" || id === "variant" || id === "reasoning", + ); + const groupRef = useRef(null); + const [height, setHeight] = useState(0); + const options = traits.modelIsUnavailable ? undefined : descriptor?.options; + + useLayoutEffect(() => { + const group = groupRef.current; + if (!group || !options?.length) return; + const measure = () => { + const rows = new Set(Array.from(group.children, (child) => (child as HTMLElement).offsetTop)); + setHeight( + rows.size > 0 && rows.size <= 2 && group.scrollWidth <= group.clientWidth + ? group.offsetHeight + : 0, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(group); + return () => observer.disconnect(); + }, [options]); + + if (!descriptor || !options?.length) return null; + const promptControlled = + descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkPromptControlled; + const disabled = + descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; + const selectedValue = promptControlled ? "ultrathink" : getProviderOptionCurrentValue(descriptor); + + return ( +
+ {/* Retain the available width while hidden so the measurement cannot oscillate. */} +
+ {options.map((option) => ( + traits.handleSelectChange(descriptor, option.id)} + > + {option.label} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 78c7015271..0149ae401d 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -78,7 +78,7 @@ export function buildUnavailableModelOptionDescriptors( ); } -type TraitsPersistence = +export type TraitsPersistence = | { threadRef?: ScopedThreadRef; draftId?: DraftId; @@ -279,7 +279,7 @@ export interface TraitsMenuContentProps { isComposerOwned?: boolean; } -export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ +export function useTraitsSelection({ provider, instanceId, models, @@ -310,16 +310,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ }, [instanceId, model, persistence, provider, setProviderModelOptions], ); - const { - descriptors, - selectDescriptors, - booleanDescriptors, - primarySelectDescriptor, - ultrathinkPromptControlled, - ultrathinkInBodyText, - hasAnyControls, - modelIsUnavailable, - } = getTraitsSectionVisibility({ + const selected = getTraitsSectionVisibility({ provider, models, model, @@ -328,6 +319,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ allowPromptInjectedEffort, planModeEnabled, }); + const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled, ultrathinkInBodyText } = + selected; const updateDescriptors = (nextDescriptors: ReadonlyArray) => { updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); }; @@ -353,6 +346,25 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); }; + return { ...selected, updateDescriptors, handleSelectChange }; +} + +export const TraitsMenuContent = memo(function TraitsMenuContentImpl( + props: TraitsMenuContentProps & TraitsPersistence, +) { + const { + descriptors, + selectDescriptors, + booleanDescriptors, + primarySelectDescriptor, + ultrathinkPromptControlled, + ultrathinkInBodyText, + hasAnyControls, + modelIsUnavailable, + updateDescriptors, + handleSelectChange, + } = useTraitsSelection(props); + if (!hasAnyControls) { return null; } From 5497608b501cec00955c9b84d175f5f2e3dca04f Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Thu, 1 Oct 2026 07:36:39 +0200 Subject: [PATCH 03/62] docs(usage): propose request-level token analysis boundary --- docs/internals/usage-request-analysis.md | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/internals/usage-request-analysis.md diff --git a/docs/internals/usage-request-analysis.md b/docs/internals/usage-request-analysis.md new file mode 100644 index 0000000000..a41c277c0b --- /dev/null +++ b/docs/internals/usage-request-analysis.md @@ -0,0 +1,27 @@ +# Request-level usage analysis boundary + +This document proposes a future request-analysis seam. It does not add runtime behavior or change the current Usage page. The standalone token-info tool and its personal report website are the first consumer; Jones Code integration remains a draft. + +The current transcript reducers and incremental scan cache answer spending questions by time, provider, model and source. Cache diagnostics need a different unit: individual model requests within a user turn, with the preceding turn retained as evidence. Summing a turn can hide a cold first request followed by warm tool continuations. A low cached-input ratio is an observation, not proof that a cache expired. + +## Preserve identity before aggregation + +A portable record should retain a provider request identity where available, native session and turn identities, observed usage time and its timing basis, numeric counters and their availability, and source byte/record provenance. Do not identify requests by equal token vectors: distinct requests may have identical counts, and copied transcripts may repeat the same provider request. Conflicting observations and ID-less legacy snapshots need explicit partitions rather than being added to verified totals. Claude streaming message blocks require provider-specific reconciliation rather than one request per block. + +Cached reads and cache writes are subsets of Codex total input. Claude ordinary input, reads and writes must be combined to obtain total input. Reasoning tokens are already included in output. Missing counters remain unknown. Modeled API prices, purchased credits and observed account charges are different accounting units. + +## Account and boundary evidence + +Provider instance IDs identify configured routing slots. They are not authenticated-account IDs. Apply an explicitly attested account mapping to the instance that served the request, using historical session/model-selection events. Current runtime bindings overwrite previous bindings, so they cannot attribute an entire history after an instance switch. Codex authentication overlays share transcript directories; source-directory names alone do not establish the serving instance. A variable-account default instance stays separate unless contemporaneous account evidence exists. + +Retain turn start/completion and compaction boundaries before reducing usage. Native turn IDs can join request observations to T3 turn records; compaction activities often identify a turn without identifying an API request. Model, effort, settings, session and instance changes remain separate confounders. An idle gap derived from usage-report timestamps must be labeled as a proxy when true request-start/completion times are unavailable. + +## Incremental and portable consumers + +Keep provider parsing ordered within each file, with resumable context and cumulative baselines. A cursor becomes durable in the same transaction as its observations. Read only a captured file prefix and complete newline records; unfinished tails remain for the next scan. File replacement, truncation and guard failure invalidate that source generation. An append-only fast path is an explicit validation assumption, not proof that arbitrary historical bytes never changed. Independent files can be parsed by bounded workers while one index writer commits results. + +Use a versioned metadata-only JSON seam for external reports rather than exposing server settings, Effect services or raw transcript payloads. A report binds its query window, captured source snapshot, mapping/rate/detector revisions, coverage and unknowns. Filters must conserve totals. Resolve the chronological predecessor before applying display filters, even when the predecessor is outside the selected window. + +Candidate detection should initially favor recall. Immutable packets retain the entire suspected turn and the preceding turn, including every captured request, boundary evidence and selection membership. Separate annotations record true-positive, false-positive or unknown labels against packet hashes. Labels do not rewrite measurements or turn an idle association into a cache-lifetime guarantee. + +The standalone implementation owns ingestion and report generation; its static website browses saved reports. A future Jones Code consumer can reuse that data boundary without adding periodic log scans or sending raw histories over the WebSocket. Existing reducers, guarded readers and scan-cache code are reusable references, while request identity, missingness and boundary evidence require richer contracts. From 83330dfa77c37d797386db757bf5638316e424de Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Thu, 1 Oct 2026 11:42:27 +0200 Subject: [PATCH 04/62] feat(usage): add short presets and custom date ranges --- apps/server/src/usage/UsageService.test.ts | 77 ++++- apps/server/src/usage/UsageService.ts | 22 +- .../server/src/usage/usageAggregation.test.ts | 26 ++ apps/server/src/usage/usageAggregation.ts | 35 ++- .../src/components/usage/UsagePage.test.tsx | 92 +++++- apps/web/src/components/usage/UsagePage.tsx | 271 ++++++++++++++++-- .../components/usage/usageDateRange.test.ts | 93 ++++++ .../src/components/usage/usageDateRange.ts | 84 ++++++ packages/contracts/src/usage.ts | 8 +- 9 files changed, 658 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/usage/usageDateRange.test.ts create mode 100644 apps/web/src/components/usage/usageDateRange.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index f7286a11b6..d3b0d32a8b 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -35,10 +35,15 @@ import * as UsageService from "./UsageService.ts"; const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + timestamp = "2026-08-01T10:00:00Z", +): string { return `${JSON.stringify({ type: "assistant", - timestamp: "2026-08-01T10:00:00Z", + timestamp, requestId: `req_${id}`, sessionId: "session-1", message: { @@ -121,6 +126,74 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("honors exact time bounds for a multi-day daily usage window", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(home, "claude", "projects", "proj", "session.jsonl"), + [ + claudeLine(1, 5, "claude-fable-5", "2026-08-01T09:59:59.999Z"), + claudeLine(2, 7, "claude-fable-5", "2026-08-01T10:00:00.000Z"), + claudeLine(3, 11, "claude-fable-5", "2026-08-02T09:59:59.999Z"), + claudeLine(4, 17, "claude-fable-5", "2026-08-02T21:59:59.999Z"), + claudeLine(5, 13, "claude-fable-5", "2026-08-02T22:00:00.000Z"), + ].join(""), + ), + ); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-exact-daily-window", home, settings }), + ), + ); + const summary = yield* service.readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + resolution: "exactDay", + sinceTime: "2026-08-01T10:00:00.000Z", + untilTime: "2026-08-02T22:00:00.000Z", + }); + + assert.strictEqual(totalOutputTokens(summary), 35); + assert.deepEqual( + summary.buckets.map((bucket) => [bucket.day, bucket.hourStart]), + [ + ["2026-08-01", undefined], + ["2026-08-02", undefined], + ], + ); + }).pipe(Effect.scoped), + ); + + it.live("rejects exact usage windows whose end does not follow their start", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-invalid-exact-window", home, settings }), + ), + ); + const reason = yield* service + .readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + resolution: "exactDay", + sinceTime: "2026-08-02T10:00:00.000Z", + untilTime: "2026-08-01T10:00:00.000Z", + }) + .pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => null, + }), + ); + + assert.strictEqual(reason, "invalidWindow"); + }).pipe(Effect.scoped), + ); + for (const explicitDefault of [true, false]) { it.live( `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 9e8cda9960..8e9bd2c183 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -683,8 +683,8 @@ export const make = Effect.gen(function* () { }); } - let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; - if (input.resolution === "hour") { + let exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.resolution === "hour" || input.resolution === "exactDay") { const sinceTime = input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); const untilTime = @@ -692,19 +692,25 @@ export const make = Effect.gen(function* () { if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: "Hourly usage requires valid sinceTime and untilTime instants", + detail: "An exact usage window requires valid sinceTime and untilTime instants", }); } const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); const untilTimeMs = DateTime.toEpochMillis(untilTime.value); const durationMs = untilTimeMs - sinceTimeMs; - if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { + if (durationMs <= 0) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: "Hourly usage window must be greater than zero and at most 24 hours", + detail: "An exact usage window must end after it starts", }); } - hourlyWindow = { sinceTimeMs, untilTimeMs }; + if (input.resolution === "hour" && durationMs > MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage window must be at most 24 hours", + }); + } + exactWindow = { sinceTimeMs, untilTimeMs }; } const startedAtMs = yield* Clock.currentTimeMillis; @@ -719,7 +725,7 @@ export const make = Effect.gen(function* () { }); } const windowStartMs = - (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; @@ -736,7 +742,7 @@ export const make = Effect.gen(function* () { sinceDay: input.sinceDay, untilDay: input.untilDay, resolution: input.resolution ?? "day", - ...hourlyWindow, + ...exactWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 75435de08f..1e0020acd1 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -139,6 +139,32 @@ describe("UsageAggregator", () => { ]); }); + it("filters exact daily windows by an inclusive start and exclusive end", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-06", + untilDay: "2026-08-07", + resolution: "exactDay", + sinceTimeMs: Date.parse("2026-08-06T12:00:00.000Z"), + untilTimeMs: Date.parse("2026-08-07T12:00:00.000Z"), + rates, + }); + const result = [ + record({ timestampMs: Date.parse("2026-08-06T11:59:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-06T12:00:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-07T11:59:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-07T12:00:00.000Z") }), + ].map((item) => aggregator.add(item)); + const summary = aggregator.finish(); + + expect(result).toEqual([false, true, true, false]); + expect(summary.outOfWindow).toBe(2); + expect(summary.buckets.map((bucket) => [bucket.day, bucket.hourStart])).toEqual([ + ["2026-08-06", undefined], + ["2026-08-07", undefined], + ]); + }); + it("keeps daily payloads collapsed when hourly resolution is not requested", () => { const result = aggregate([ record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 92abfef741..e15948895b 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -86,6 +86,7 @@ export class UsageAggregator { readonly #buckets = new Map(); readonly #seen = new Set(); readonly #toDay: (timestampMs: number) => string; + readonly #exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #duplicatesDropped = 0; @@ -94,17 +95,25 @@ export class UsageAggregator { constructor(options: AggregateOptions) { this.#options = options; this.#toDay = makeDayFormatter(options.timeZone); - if (options.resolution === "hour") { - if (options.sinceTimeMs === undefined || options.untilTimeMs === undefined) { - throw new Error("Hourly usage aggregation requires exact time bounds"); - } - this.#hourlyWindow = { - sinceTimeMs: options.sinceTimeMs, - untilTimeMs: options.untilTimeMs, - }; - } else { - this.#hourlyWindow = null; + const hasSinceTime = options.sinceTimeMs !== undefined; + const hasUntilTime = options.untilTimeMs !== undefined; + if (hasSinceTime !== hasUntilTime) { + throw new Error("Exact usage aggregation requires both time bounds"); + } + if (options.resolution === "hour" && !hasSinceTime) { + throw new Error("Hourly usage aggregation requires exact time bounds"); + } + if (options.resolution === "exactDay" && !hasSinceTime) { + throw new Error("Exact-day usage aggregation requires exact time bounds"); } + this.#exactWindow = + hasSinceTime && hasUntilTime + ? { + sinceTimeMs: options.sinceTimeMs, + untilTimeMs: options.untilTimeMs, + } + : null; + this.#hourlyWindow = options.resolution === "hour" ? this.#exactWindow : null; } /** @@ -122,9 +131,9 @@ export class UsageAggregator { } if ( - this.#hourlyWindow !== null && - (record.timestampMs < this.#hourlyWindow.sinceTimeMs || - record.timestampMs >= this.#hourlyWindow.untilTimeMs) + this.#exactWindow !== null && + (record.timestampMs < this.#exactWindow.sinceTimeMs || + record.timestampMs >= this.#exactWindow.untilTimeMs) ) { this.#outOfWindow += 1; return false; diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 6d8caac6a3..a57c54b80f 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,9 +1,12 @@ import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { act } from "react"; +import type { ReactElement, ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { saveUsagePagePreferences } from "./usagePagePreferences"; + const testState = vi.hoisted(() => ({ useUsage: vi.fn(), navigate: vi.fn(), @@ -25,7 +28,32 @@ vi.mock("../ui/select", () => ({ SelectValue: "div", })); vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); -vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../ui/toggle-group", async () => { + const React = await import("react"); + return { + Toggle: "button", + ToggleGroup: ({ + children, + onValueChange, + ...props + }: { + readonly children?: ReactNode; + readonly onValueChange?: (value: readonly string[]) => void; + readonly [key: string]: unknown; + }) => + React.createElement( + "div", + props, + React.Children.map(children, (child) => { + if (!React.isValidElement<{ value: string }>(child)) return child; + const toggle = child as ReactElement<{ value: string; onClick?: () => void }>; + return React.cloneElement(toggle, { + onClick: () => onValueChange?.([toggle.props.value]), + }); + }), + ), + }; +}); vi.mock("../WorkspaceBreadcrumb", () => ({ WorkspaceBreadcrumb: "div", WorkspaceBreadcrumbItem: "div", @@ -84,6 +112,7 @@ describe("UsagePage Escape navigation", () => { let back: ReturnType; beforeEach(async () => { + saveUsagePagePreferences({ metric: "tokens", windowDays: 30 }); back = vi.spyOn(window.history, "back").mockImplementation(() => {}); testState.navigate.mockClear(); testState.canGoBack = true; @@ -147,6 +176,67 @@ describe("UsagePage Escape navigation", () => { expect(back).not.toHaveBeenCalled(); expect(testState.navigate).not.toHaveBeenCalled(); }); + + it("selects an exact three-hour range from the overflow panel", async () => { + const trigger = container.querySelector( + '[aria-label="Additional usage ranges"]', + ); + expect(trigger).not.toBeNull(); + + await act(() => trigger?.click()); + const threeHours = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "3h", + ); + expect(threeHours).toBeDefined(); + await act(() => threeHours?.click()); + + const input = testState.useUsage.mock.calls.at(-1)?.[0]; + expect(input).toMatchObject({ resolution: "hour", timeZone: expect.any(String) }); + expect(Date.parse(input.untilTime) - Date.parse(input.sinceTime)).toBe(3 * 60 * 60 * 1000); + }); + + it("applies and clears an exact multi-day custom range", async () => { + vi.stubEnv("TZ", "UTC"); + const trigger = container.querySelector( + '[aria-label="Additional usage ranges"]', + ); + expect(trigger).not.toBeNull(); + await act(() => trigger?.click()); + + const setInputValue = (label: string, value: string) => { + const input = document.querySelector(`[aria-label="${label}"]`); + expect(input).not.toBeNull(); + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + if (!input || !valueSetter) throw new Error("The custom range input is unavailable."); + valueSetter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + await act(() => setInputValue("Custom range start", "2026-09-15T10:30")); + await act(() => setInputValue("Custom range end", "2026-09-18T12:37")); + const apply = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Apply range", + ); + expect(apply?.disabled).toBe(false); + await act(() => apply?.click()); + + const customInput = testState.useUsage.mock.calls.at(-1)?.[0]; + expect(customInput).toMatchObject({ + sinceDay: "2026-09-15", + untilDay: "2026-09-18", + timeZone: "UTC", + resolution: "exactDay", + sinceTime: "2026-09-15T10:30:00.000Z", + untilTime: "2026-09-18T12:37:00.000Z", + }); + + await act(() => trigger?.click()); + const clear = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Clear custom selection", + ); + expect(clear?.disabled).toBe(false); + await act(() => clear?.click()); + expect(testState.useUsage.mock.calls.at(-1)?.[0]).not.toHaveProperty("sinceTime"); + }); }); // @vitest-environment jsdom diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 09611858b1..e4539100b2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -6,12 +6,14 @@ import { ProviderDriverKind, USAGE_CONTRACT_VERSION, type EnvironmentId, + type UsageSummaryInput, type UsageProviderKind, } from "@t3tools/contracts"; import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, + EllipsisIcon, InfoIcon, SlidersHorizontalIcon, } from "lucide-react"; @@ -52,6 +54,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { Button, InlineButton } from "../ui/button"; +import { Input } from "../ui/input"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Menu, @@ -92,6 +95,23 @@ import { saveUsagePagePreferences, type UsagePagePreferences, } from "./usagePagePreferences"; +import { + makeRollingUsageWindow, + toLocalDateTimeValue, + validateCustomUsageWindow, + type CustomUsageWindowValidation, +} from "./usageDateRange"; + +type UsageWindowSelection = + | { + readonly kind: "day"; + readonly days: UsagePagePreferences["windowDays"]; + readonly window: UsageSummaryInput; + } + | { readonly kind: "hours"; readonly hours: number; readonly window: UsageSummaryInput } + | { readonly kind: "custom"; readonly window: UsageSummaryInput }; + +const QUICK_USAGE_HOUR_OPTIONS = [1, 3, 6, 12] as const; function isUsageMetric(value: string | null | undefined): value is UsageMetric { return METRIC_OPTIONS.some((option) => option.value === value); @@ -113,7 +133,8 @@ export function UsagePage() { }); return shortcut ? `${option.label} (${shortcut})` : option.label; }; - const [windowSelection, setWindowSelection] = useState(() => ({ + const [windowSelection, setWindowSelection] = useState(() => ({ + kind: "day", days: preferences.windowDays, window: makeWindow( preferences.windowDays, @@ -123,14 +144,18 @@ export function UsagePage() { })); const metric = preferences.metric; const showingLimits = metric === "limits"; + const windowDays = windowSelection.kind === "day" ? windowSelection.days : preferences.windowDays; + const { window } = windowSelection; + const isHourly = window.resolution === "hour"; const [isRefreshing, setIsRefreshing] = useState(false); const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [customSinceValue, setCustomSinceValue] = useState(""); + const [customUntilValue, setCustomUntilValue] = useState(""); + const customWindowValidation = validateCustomUsageWindow(customSinceValue, customUntilValue); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( window, selectedEnvironmentIds, @@ -163,16 +188,16 @@ export function UsagePage() { ); const hours = useMemo( () => - window.sinceTime === undefined || window.untilTime === undefined + !isHourly || window.sinceTime === undefined || window.untilTime === undefined ? [] : enumerateHourStarts(window.sinceTime, window.untilTime), - [window.sinceTime, window.untilTime], + [isHourly, window.sinceTime, window.untilTime], ); - // Newest first: the window can run 90 periods, so the interesting end + // Newest first: the window can run 90 days, so the interesting end // belongs at the top of the table. const breakdownPeriods = useMemo( - () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), - [isPast24Hours, merged.daily, merged.hourly], + () => (isHourly ? merged.hourly : merged.daily).toReversed(), + [isHourly, merged.daily, merged.hourly], ); const breakdownModels = useMemo( () => @@ -200,11 +225,32 @@ export function UsagePage() { const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); + setCustomSinceValue(""); + setCustomUntilValue(""); setWindowSelection({ + kind: "day", days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectHourWindow = (hours: (typeof QUICK_USAGE_HOUR_OPTIONS)[number]) => { + const nextWindow = makeRollingUsageWindow(hours); + setWindowSelection({ kind: "hours", hours, window: nextWindow }); + if (nextWindow.sinceTime !== undefined && nextWindow.untilTime !== undefined) { + setCustomSinceValue(toLocalDateTimeValue(new Date(nextWindow.sinceTime))); + setCustomUntilValue(toLocalDateTimeValue(new Date(nextWindow.untilTime))); + } + }; + const applyCustomWindow = () => { + const validation = validateCustomUsageWindow(customSinceValue, customUntilValue); + if (!validation.ok) return; + setWindowSelection({ kind: "custom", window: validation.window }); + }; + const clearCustomWindow = () => { + setCustomSinceValue(""); + setCustomUntilValue(""); + selectWindow(preferences.windowDays); + }; const selectMetric = (nextMetric: UsageMetric) => { if (nextMetric === "limits") setLimitsNow(Date.now()); const nextPreferences = { metric: nextMetric, windowDays }; @@ -268,14 +314,19 @@ export function UsagePage() { }); return; } - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); - if ( + const nextWindow = + windowSelection.kind === "day" + ? makeWindow(windowDays, undefined, windowDays === 1 ? "hour" : "day") + : windowSelection.kind === "hours" + ? makeRollingUsageWindow(windowSelection.hours) + : windowSelection.window; + const windowChanged = nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || - nextWindow.untilTime !== window.untilTime - ) { - setWindowSelection({ days: windowDays, window: nextWindow }); + nextWindow.untilTime !== window.untilTime; + if (windowChanged && windowSelection.kind !== "custom") { + setWindowSelection({ ...windowSelection, window: nextWindow }); } refreshingRef.current = true; setIsRefreshing(true); @@ -302,9 +353,29 @@ export function UsagePage() { }, [showingLimits, connectedLimitsEnvironments]); const windowLabel = - isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + window.sinceTime !== undefined && window.untilTime !== undefined ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; + const desktopWindowLabel = + windowSelection.kind === "hours" + ? `Past ${windowSelection.hours}h · ${windowLabel}` + : windowSelection.kind === "custom" + ? `Custom range · ${windowLabel}` + : windowLabel; + const windowPeriodValue = windowSelection.kind === "day" ? String(windowDays) : ""; + const rangePickerProps = { + selection: windowSelection, + timeZone: window.timeZone, + sinceValue: customSinceValue, + untilValue: customUntilValue, + validation: customWindowValidation, + disabled: showingLimits, + onSinceValueChange: setCustomSinceValue, + onUntilValueChange: setCustomUntilValue, + onSelectHours: selectHourWindow, + onApplyCustom: applyCustomWindow, + onClear: clearCustomWindow, + }; const topbarContent = (
@@ -327,7 +398,7 @@ export function UsagePage() { {!showingLimits ? ( - {windowLabel} + {desktopWindowLabel} ) : null}
@@ -351,7 +422,7 @@ export function UsagePage() { { const value = next[0]; @@ -364,6 +435,7 @@ export function UsagePage() { ))} + + } + /> + +
+
+

Short ranges

+ { + const selectedHours = Number(next[0]); + if ( + QUICK_USAGE_HOUR_OPTIONS.includes( + selectedHours as (typeof QUICK_USAGE_HOUR_OPTIONS)[number], + ) + ) { + onSelectHours(selectedHours as (typeof QUICK_USAGE_HOUR_OPTIONS)[number]); + setOpen(false); + } + }} + > + {QUICK_USAGE_HOUR_OPTIONS.map((hours) => ( + + {hours}h + + ))} + +
+ +
+ +
+
+

Custom range

+

+ Times use {timeZone}. The end is exclusive. +

+
+
+ + +
+ {validationMessage ? ( +

+ {validationMessage} +

+ ) : null} +
+ + +
+
+
+ + + ); +} + const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; function CursorEnableButton({ diff --git a/apps/web/src/components/usage/usageDateRange.test.ts b/apps/web/src/components/usage/usageDateRange.test.ts new file mode 100644 index 0000000000..3a04b9a84e --- /dev/null +++ b/apps/web/src/components/usage/usageDateRange.test.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics globalDate:off -- Date-time local inputs intentionally use the viewer's zone. +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeRollingUsageWindow, + toLocalDateTimeValue, + validateCustomUsageWindow, +} from "./usageDateRange"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("usage date ranges", () => { + it.each([1, 3, 6, 12])("builds an exact %ih rolling window", (hours) => { + vi.stubEnv("TZ", "UTC"); + const window = makeRollingUsageWindow(hours, new Date("2026-09-18T12:37:42.123Z")); + + expect(window.resolution).toBe("hour"); + expect(window.sinceTime).toBe(`2026-09-18T${String(12 - hours).padStart(2, "0")}:37:00.000Z`); + expect(window.untilTime).toBe("2026-09-18T12:37:00.000Z"); + }); + + it("preserves exact boundaries and switches to daily buckets for longer ranges", () => { + vi.stubEnv("TZ", "UTC"); + const validation = validateCustomUsageWindow( + "2026-09-15T10:30", + "2026-09-18T12:37", + new Date("2026-09-18T12:38:00.000Z"), + ); + + expect(validation).toMatchObject({ + ok: true, + window: { + sinceDay: "2026-09-15", + untilDay: "2026-09-18", + timeZone: "UTC", + resolution: "exactDay", + sinceTime: "2026-09-15T10:30:00.000Z", + untilTime: "2026-09-18T12:37:00.000Z", + }, + }); + }); + + it("uses the viewer's zone to build exact hourly bounds across DST", () => { + vi.stubEnv("TZ", "America/New_York"); + const validation = validateCustomUsageWindow( + "2026-03-08T01:00", + "2026-03-08T04:00", + new Date("2026-03-08T09:00:00.000Z"), + ); + + expect(validation).toMatchObject({ + ok: true, + window: { + sinceDay: "2026-03-08", + untilDay: "2026-03-08", + timeZone: "America/New_York", + resolution: "hour", + sinceTime: "2026-03-08T06:00:00.000Z", + untilTime: "2026-03-08T08:00:00.000Z", + }, + }); + }); + + it("rejects missing, reversed, and future ranges", () => { + vi.stubEnv("TZ", "UTC"); + const now = new Date("2026-09-18T12:00:00.000Z"); + + expect(validateCustomUsageWindow("", "2026-09-18T11:00", now)).toMatchObject({ + ok: false, + error: "Enter both a start and end date and time.", + }); + expect(validateCustomUsageWindow("2026-09-18T11:00", "2026-09-18T10:00", now)).toMatchObject({ + ok: false, + error: "End date and time must be after the start.", + }); + expect(validateCustomUsageWindow("2026-09-18T11:00", "2026-09-18T12:01", now)).toMatchObject({ + ok: false, + error: "End date and time cannot be in the future.", + }); + }); + + it("rejects nonexistent local times and formats an existing local value", () => { + vi.stubEnv("TZ", "America/New_York"); + + expect( + validateCustomUsageWindow("2026-03-08T02:30", "2026-03-08T03:30", new Date()), + ).toMatchObject({ + ok: false, + error: "Enter valid local dates and times.", + }); + expect(toLocalDateTimeValue(new Date("2026-09-18T12:37:00.000Z"))).toBe("2026-09-18T08:37"); + }); +}); diff --git a/apps/web/src/components/usage/usageDateRange.ts b/apps/web/src/components/usage/usageDateRange.ts new file mode 100644 index 0000000000..f4fe358f6b --- /dev/null +++ b/apps/web/src/components/usage/usageDateRange.ts @@ -0,0 +1,84 @@ +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; + +const HOUR_MS = 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; +const LOCAL_DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + +export type CustomUsageWindowValidation = + | { readonly ok: true; readonly window: UsageSummaryInput } + | { readonly ok: false; readonly error: string }; + +export function toLocalDateTimeValue(date: Date): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function parseLocalDateTime(value: string): Date | null { + if (!LOCAL_DATE_TIME_PATTERN.test(value)) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime()) || toLocalDateTimeValue(date) !== value) return null; + return date; +} + +function exactWindow(since: Date, until: Date, resolution: "exactDay" | "hour"): UsageSummaryInput { + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } + return { + sinceDay: UsageDay.make(format.format(since)), + untilDay: UsageDay.make(format.format(until)), + timeZone, + resolution, + sinceTime: since.toISOString(), + untilTime: until.toISOString(), + }; +} + +export function makeRollingUsageWindow(hours: number, now = new Date()): UsageSummaryInput { + if (!Number.isInteger(hours) || hours < 1 || hours > 24) { + throw new RangeError("A rolling usage window must be from 1 to 24 whole hours"); + } + const untilMs = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; + const until = new Date(untilMs); + const since = new Date(untilMs - hours * HOUR_MS); + return exactWindow(since, until, "hour"); +} + +export function validateCustomUsageWindow( + sinceValue: string, + untilValue: string, + now = new Date(), +): CustomUsageWindowValidation { + if (!sinceValue || !untilValue) { + return { ok: false, error: "Enter both a start and end date and time." }; + } + const since = parseLocalDateTime(sinceValue); + const until = parseLocalDateTime(untilValue); + if (since === null || until === null) { + return { ok: false, error: "Enter valid local dates and times." }; + } + if (until.getTime() <= since.getTime()) { + return { ok: false, error: "End date and time must be after the start." }; + } + if (until.getTime() > now.getTime()) { + return { ok: false, error: "End date and time cannot be in the future." }; + } + + const resolution = until.getTime() - since.getTime() <= 24 * HOUR_MS ? "hour" : "exactDay"; + return { ok: true, window: exactWindow(since, until, resolution) }; +} diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 97db859437..1b765558e7 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -53,7 +53,7 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P ); export type UsageDay = typeof UsageDay.Type; -export const UsageResolution = Schema.Literals(["day", "hour"]); +export const UsageResolution = Schema.Literals(["day", "hour", "exactDay"]); export type UsageResolution = typeof UsageResolution.Type; /** @@ -188,11 +188,11 @@ export const UsageSummaryInput = Schema.Struct({ * any window that crosses a DST boundary. */ timeZone: TrimmedNonEmptyString, - /** Defaults to daily for older clients. */ + /** Defaults to day; `exactDay` filters by paired instants and keeps day buckets. */ resolution: Schema.optional(UsageResolution), - /** Inclusive UTC instant for an hourly rolling window. */ + /** Inclusive UTC instant for hourly or exact-day windows. */ sinceTime: Schema.optional(TrimmedNonEmptyString), - /** Exclusive UTC instant for an hourly rolling window. */ + /** Exclusive UTC instant for hourly or exact-day windows. */ untilTime: Schema.optional(TrimmedNonEmptyString), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; From 1ec4639934e151f7af8b0cdc0215c96baf9237e8 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Fri, 2 Oct 2026 01:36:44 +0200 Subject: [PATCH 05/62] feat(web): review delayed voice prompts before sending --- apps/server/src/server.ts | 3 + apps/server/src/voiceReview/bridge.test.ts | 192 +++++++++++ apps/server/src/voiceReview/bridge.ts | 195 +++++++++++ apps/server/src/voiceReview/config.ts | 44 +++ apps/server/src/voiceReview/http.test.ts | 129 +++++++ apps/server/src/voiceReview/http.ts | 90 +++++ .../src/components/sidebar/SidebarChrome.tsx | 23 +- .../sidebar/mainAppLocation.test.ts | 1 + .../src/components/sidebar/mainAppLocation.ts | 3 +- .../voiceReview/VoiceReviewPage.tsx | 315 ++++++++++++++++++ .../components/voiceReview/useVoiceReview.ts | 88 +++++ .../voiceReview/voiceReviewActions.test.ts | 168 ++++++++++ .../voiceReview/voiceReviewActions.ts | 137 ++++++++ apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/voice-review.tsx | 4 + docs/user/composer.md | 14 + packages/client-runtime/package.json | 4 + .../client-runtime/src/voice-review.test.ts | 160 +++++++++ packages/client-runtime/src/voice-review.ts | 125 +++++++ packages/contracts/src/environmentHttp.ts | 103 ++++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/voiceReview.test.ts | 36 ++ packages/contracts/src/voiceReview.ts | 139 ++++++++ 23 files changed, 1993 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/voiceReview/bridge.test.ts create mode 100644 apps/server/src/voiceReview/bridge.ts create mode 100644 apps/server/src/voiceReview/config.ts create mode 100644 apps/server/src/voiceReview/http.test.ts create mode 100644 apps/server/src/voiceReview/http.ts create mode 100644 apps/web/src/components/voiceReview/VoiceReviewPage.tsx create mode 100644 apps/web/src/components/voiceReview/useVoiceReview.ts create mode 100644 apps/web/src/components/voiceReview/voiceReviewActions.test.ts create mode 100644 apps/web/src/components/voiceReview/voiceReviewActions.ts create mode 100644 apps/web/src/routes/voice-review.tsx create mode 100644 packages/client-runtime/src/voice-review.test.ts create mode 100644 packages/client-runtime/src/voice-review.ts create mode 100644 packages/contracts/src/voiceReview.test.ts create mode 100644 packages/contracts/src/voiceReview.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 672c76721b..65773669b8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -45,6 +45,7 @@ import { workstreamHttpApiLayer, workstreamResponseHeadersLayer, } from "./workstreams/http.ts"; +import { voiceReviewHttpApiLayer, voiceReviewResponseHeadersLayer } from "./voiceReview/http.ts"; import { hostStatusHttpApiLayer } from "./hostStatus/http.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; @@ -614,6 +615,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(pullRequestHttpApiLayer), Layer.provide(workstreamHttpApiLayer), Layer.provide(hostStatusHttpApiLayer), + Layer.provide(voiceReviewHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), @@ -636,6 +638,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(DesktopAppUpdateLayerLive))), Layer.provide(commandReadinessLayer), Layer.provide(workstreamResponseHeadersLayer), + Layer.provide(voiceReviewResponseHeadersLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), ); diff --git a/apps/server/src/voiceReview/bridge.test.ts b/apps/server/src/voiceReview/bridge.test.ts new file mode 100644 index 0000000000..31fb56ba84 --- /dev/null +++ b/apps/server/src/voiceReview/bridge.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { + AuthSessionId, + VoiceReviewForbiddenError, + VoiceReviewNotConfiguredError, + VoiceReviewNotFoundError, + VoiceReviewConflictError, + VoiceReviewUnavailableError, + type EnvironmentSessionPrincipalShape, + type VoiceReviewDraft, +} from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import { makeVoiceReviewBridge } from "./bridge.ts"; +import { voiceReviewConfigFromEnv } from "./config.ts"; + +const principal: EnvironmentSessionPrincipalShape = { + sessionId: AuthSessionId.make("owner-session"), + subject: "generic-subject", + method: "bearer-access-token", + scopes: new Set(["orchestration:read", "orchestration:operate"]), +}; +const draft: VoiceReviewDraft = { + id: "capture", + source_id: "microphone", + state: "held", + revision: 1, + text: "literal transcript", + transcript_provider: "fixture", + language: null, + edited: false, + created_at: "2026-10-02T00:00:00Z", + updated_at: "2026-10-02T00:00:00Z", + due_at: "2026-10-02T00:03:00Z", + remaining_ms: 180_000, + expires_at: "2026-10-03T00:00:00Z", + command_id: null, + command_status: null, + reason: null, + server_now: "2026-10-02T00:00:00Z", +}; +const withFixture = async (run: (tokenFile: string) => Promise) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "voice-review-")); + try { + const tokenFile = NodePath.join(root, "reviewer-token"); + await NodeFSP.writeFile(tokenFile, "fixture-reviewer-token\n", { mode: 0o600 }); + await run(tokenFile); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}; +const config = (reviewer_token_file: string) => ({ + broker_url: "http://127.0.0.1:7000", + reviewer_token_file, + source_id: "microphone", + allowed_session_ids: new Set(["owner-session"]), +}); + +describe("voice review server bridge", () => { + it("defaults deny and validates only a trusted origin and exact session enrollment", () => { + expect(voiceReviewConfigFromEnv({})).toBeNull(); + const env = { + T3CODE_VOICE_REVIEW_BROKER_URL: "http://127.0.0.1:7000", + T3CODE_VOICE_REVIEW_REVIEWER_TOKEN_FILE: "/fixture/token", + T3CODE_VOICE_REVIEW_SOURCE_ID: "microphone", + T3CODE_VOICE_REVIEW_ALLOWED_SESSION_IDS: '["owner-session"]', + }; + expect(voiceReviewConfigFromEnv(env)?.allowed_session_ids.has("owner-session")).toBe(true); + for (const url of [ + "http://remote.test", + "http://localhost", + "https://user:pass@remote.test", + "https://remote.test/path", + "https://remote.test/?url=other", + ]) { + expect(voiceReviewConfigFromEnv({ ...env, T3CODE_VOICE_REVIEW_BROKER_URL: url })).toBeNull(); + } + expect( + voiceReviewConfigFromEnv({ ...env, T3CODE_VOICE_REVIEW_ALLOWED_SESSION_IDS: '["*"]' }), + ).toBeNull(); + expect( + voiceReviewConfigFromEnv({ ...env, T3CODE_VOICE_REVIEW_ALLOWED_SESSION_IDS: "[]" }), + ).toBeNull(); + }); + it("checks session identity and read/operate scopes before credential access or requests", async () => { + const fetcher = vi.fn(); + await expect( + makeVoiceReviewBridge(null, fetcher).get(principal, "capture"), + ).rejects.toBeInstanceOf(VoiceReviewNotConfiguredError); + const bridge = makeVoiceReviewBridge(config("/not-read"), fetcher); + await expect( + bridge.get({ ...principal, sessionId: AuthSessionId.make("other") }, "capture"), + ).rejects.toBeInstanceOf(VoiceReviewForbiddenError); + await expect(bridge.get({ ...principal, scopes: new Set() }, "capture")).rejects.toBeInstanceOf( + VoiceReviewForbiddenError, + ); + await expect( + bridge.mutate({ ...principal, scopes: new Set(["orchestration:read"]) }, "capture", "pause", { + expected_revision: 1, + }), + ).rejects.toBeInstanceOf(VoiceReviewForbiddenError); + expect(fetcher).not.toHaveBeenCalled(); + }); + it("keeps credentials server-only, filters fixed source, and blocks a foreign draft before mutation", async () => + withFixture(async (tokenFile) => { + const calls: string[] = []; + const fetcher = vi.fn(async (url, init) => { + calls.push(String(url)); + expect(init?.redirect).toBe("error"); + expect(init?.cache).toBe("no-store"); + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer fixture-reviewer-token", + ); + return Response.json( + String(url).includes("?scope") + ? { + server_now: draft.server_now, + drafts: [draft, { ...draft, id: "foreign", source_id: "other" }], + } + : { ...draft, source_id: "other" }, + ); + }); + const bridge = makeVoiceReviewBridge(config(tokenFile), fetcher); + expect((await bridge.list(principal, "pending", 50)).drafts).toEqual([draft]); + await expect( + bridge.mutate(principal, "capture", "send-now", { expected_revision: 1 }), + ).rejects.toBeInstanceOf(VoiceReviewNotFoundError); + expect(calls).toHaveLength(2); + expect(fetcher.mock.calls.every(([, init]) => init?.method === "GET")).toBe(true); + })); + it("rejects nonprivate and symlinked credentials without contacting the broker", async () => + withFixture(async (tokenFile) => { + const fetcher = vi.fn(); + await NodeFSP.chmod(tokenFile, 0o644); + await expect( + makeVoiceReviewBridge(config(tokenFile), fetcher).get(principal, "capture"), + ).rejects.toBeInstanceOf(VoiceReviewUnavailableError); + await NodeFSP.chmod(tokenFile, 0o600); + const link = `${tokenFile}-link`; + await NodeFSP.symlink(tokenFile, link); + await expect( + makeVoiceReviewBridge(config(link), fetcher).get(principal, "capture"), + ).rejects.toBeInstanceOf(VoiceReviewUnavailableError); + expect(fetcher).not.toHaveBeenCalled(); + })); + it("forwards one strict revision mutation and sanitizes broker rejection without retry", async () => + withFixture(async (tokenFile) => { + const fetcher = vi.fn(async (_url, init) => + init?.method === "GET" + ? Response.json(draft) + : new Response("private exception and transcript", { status: 409 }), + ); + const bridge = makeVoiceReviewBridge(config(tokenFile), fetcher); + await expect( + bridge.mutate(principal, "capture", "pause", { expected_revision: 1 }), + ).rejects.toBeInstanceOf(VoiceReviewConflictError); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls[1]?.[1]?.body).toBe('{"expected_revision":1}'); + for (const status of [401, 403, 500, 302]) { + const rejected = makeVoiceReviewBridge( + config(tokenFile), + async () => new Response("private detail", { status }), + ); + await expect(rejected.get(principal, "capture")).rejects.toMatchObject({ + _tag: "VoiceReviewUnavailableError", + }); + } + })); + it("an ambiguous mutation has no automatic retry or replacement submission", async () => + withFixture(async (tokenFile) => { + const fetcher = vi.fn(async (_url, init) => { + if (init?.method === "GET") return Response.json(draft); + throw new Error("connection lost after possible effect: secret transcript"); + }); + await expect( + makeVoiceReviewBridge(config(tokenFile), fetcher).mutate(principal, "capture", "send-now", { + expected_revision: 1, + }), + ).rejects.toMatchObject({ _tag: "VoiceReviewUnavailableError" }); + expect(fetcher).toHaveBeenCalledTimes(2); + })); + it("rejects oversized response before decoding", async () => + withFixture(async (tokenFile) => { + const fetcher = vi.fn( + async () => new Response("ignored", { headers: { "content-length": "24000001" } }), + ); + await expect( + makeVoiceReviewBridge(config(tokenFile), fetcher).get(principal, "capture"), + ).rejects.toBeInstanceOf(VoiceReviewUnavailableError); + })); +}); diff --git a/apps/server/src/voiceReview/bridge.ts b/apps/server/src/voiceReview/bridge.ts new file mode 100644 index 0000000000..991ad9b147 --- /dev/null +++ b/apps/server/src/voiceReview/bridge.ts @@ -0,0 +1,195 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + type EnvironmentSessionPrincipalShape, + VoiceReviewDraft, + VoiceReviewDraftList, + VoiceReviewMutationResult, + VoiceReviewRevisionPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, + VoiceReviewNotConfiguredError, + VoiceReviewForbiddenError, + VoiceReviewNotFoundError, + VoiceReviewConflictError, + VoiceReviewUnavailableError, + type VoiceReviewAction, + type VoiceReviewMutationPayload, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import type { VoiceReviewConfig } from "./config.ts"; + +export const VOICE_REVIEW_MAX_REQUEST_BYTES = 610_000; +const MAX_RESPONSE_BYTES = 24_000_000; +const REQUEST_TIMEOUT_MS = 10_000; +const Token = Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(4096), + Schema.isPattern(/^[\x21-\x7e]+$/), +); + +const decodeToken = Schema.decodeUnknownSync(Token); +const decodeVoiceReviewDraft = Schema.decodeUnknownSync(VoiceReviewDraft); +const decodeVoiceReviewDraftList = Schema.decodeUnknownSync(VoiceReviewDraftList); +const decodeVoiceReviewMutationResult = Schema.decodeUnknownSync(VoiceReviewMutationResult); +const decodeVoiceReviewEditSavePayload = Schema.decodeUnknownSync(VoiceReviewEditSavePayload); +const decodeVoiceReviewEditCancelPayload = Schema.decodeUnknownSync(VoiceReviewEditCancelPayload); +const decodeVoiceReviewRevisionPayload = Schema.decodeUnknownSync(VoiceReviewRevisionPayload); + +const readToken = async (path: string): Promise => { + const file = await NodeFSP.open( + path, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK, + ); + try { + const info = await file.stat(); + if ( + !info.isFile() || + info.size > 4096 || + (info.mode & 0o077) !== 0 || + process.getuid === undefined || + info.uid !== process.getuid() + ) + throw new VoiceReviewUnavailableError({}); + const buffer = Buffer.alloc(4097); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > 4096) throw new VoiceReviewUnavailableError({}); + return decodeToken(buffer.subarray(0, bytesRead).toString("utf8").trim()); + } finally { + await file.close(); + } +}; + +const boundedJson = async (response: Response): Promise => { + const size = response.headers.get("content-length"); + if (size !== null && (!/^\d+$/.test(size) || Number(size) > MAX_RESPONSE_BYTES)) { + await response.body?.cancel(); + throw new VoiceReviewUnavailableError({}); + } + if (!response.body) throw new VoiceReviewUnavailableError({}); + const reader = response.body.getReader(); + try { + const chunks: Uint8Array[] = []; + let bytes = 0; + for (;;) { + const next = await reader.read(); + if (next.done) break; + bytes += next.value.byteLength; + if (bytes > MAX_RESPONSE_BYTES) throw new VoiceReviewUnavailableError({}); + chunks.push(next.value); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +}; + +export const makeVoiceReviewBridge = ( + config: VoiceReviewConfig | null, + fetcher: typeof fetch = globalThis.fetch, +) => { + const authorize = (principal: EnvironmentSessionPrincipalShape, mutation: boolean) => { + if (config === null) throw new VoiceReviewNotConfiguredError({}); + if ( + !config.allowed_session_ids.has(principal.sessionId) || + !principal.scopes.has(mutation ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope) + ) { + throw new VoiceReviewForbiddenError({}); + } + return config; + }; + const request = async ( + trusted: VoiceReviewConfig, + path: string, + payload?: VoiceReviewMutationPayload, + ) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const token = await readToken(trusted.reviewer_token_file); + const body = payload === undefined ? undefined : JSON.stringify(payload); + if (body !== undefined && Buffer.byteLength(body) > VOICE_REVIEW_MAX_REQUEST_BYTES) { + throw new VoiceReviewConflictError({}); + } + const response = await fetcher(`${trusted.broker_url}/v1/prompt-review/drafts${path}`, { + method: body === undefined ? "GET" : "POST", + redirect: "error", + cache: "no-store", + signal: controller.signal, + headers: { + authorization: `Bearer ${token}`, + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body }), + }); + if (!response.ok) { + await response.body?.cancel(); + if (response.status === 404) throw new VoiceReviewNotFoundError({}); + if (response.status === 409 || response.status === 422 || response.status === 400) { + throw new VoiceReviewConflictError({}); + } + throw new VoiceReviewUnavailableError({}); + } + return await boundedJson(response); + } catch (error) { + if (error instanceof VoiceReviewNotFoundError || error instanceof VoiceReviewConflictError) + throw error; + throw new VoiceReviewUnavailableError({}); + } finally { + clearTimeout(timeout); + } + }; + const get = async (principal: EnvironmentSessionPrincipalShape, id: string, mutation = false) => { + const trusted = authorize(principal, mutation); + const draft = decodeVoiceReviewDraft(await request(trusted, `/${encodeURIComponent(id)}`)); + if (draft.id !== id || draft.source_id !== trusted.source_id) + throw new VoiceReviewNotFoundError({}); + return draft; + }; + return { + list: async ( + principal: EnvironmentSessionPrincipalShape, + scope: "pending" | "recent", + limit: number, + ) => { + const trusted = authorize(principal, false); + const list = decodeVoiceReviewDraftList( + await request(trusted, `?scope=${scope}&limit=${limit}`), + ); + return { + ...list, + drafts: list.drafts.filter((draft) => draft.source_id === trusted.source_id), + }; + }, + get, + mutate: async ( + principal: EnvironmentSessionPrincipalShape, + id: string, + action: VoiceReviewAction, + payload: VoiceReviewMutationPayload, + ) => { + const trusted = authorize(principal, true); + const validated = + action === "edit-save" + ? decodeVoiceReviewEditSavePayload(payload) + : action === "edit-cancel" + ? decodeVoiceReviewEditCancelPayload(payload) + : decodeVoiceReviewRevisionPayload(payload); + await get(principal, id, true); + const result = decodeVoiceReviewMutationResult( + await request(trusted, `/${encodeURIComponent(id)}/${action}`, validated), + ); + if ( + result.draft.id !== id || + result.draft.source_id !== trusted.source_id || + (action !== "edit-begin" && result.edit_handle !== null) + ) + throw new VoiceReviewUnavailableError({}); + return result; + }, + }; +}; diff --git a/apps/server/src/voiceReview/config.ts b/apps/server/src/voiceReview/config.ts new file mode 100644 index 0000000000..e437b6a30f --- /dev/null +++ b/apps/server/src/voiceReview/config.ts @@ -0,0 +1,44 @@ +import * as Schema from "effect/Schema"; +import * as NodePath from "node:path"; + +export interface VoiceReviewConfig { + readonly broker_url: string; + readonly reviewer_token_file: string; + readonly source_id: string; + readonly allowed_session_ids: ReadonlySet; +} +const SessionIds = Schema.Array( + Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), +).check(Schema.isMinLength(1), Schema.isMaxLength(100)); + +const decodeSessionIds = Schema.decodeUnknownSync(SessionIds); + +export const voiceReviewConfigFromEnv = (env: NodeJS.ProcessEnv): VoiceReviewConfig | null => { + try { + const rawUrl = env.T3CODE_VOICE_REVIEW_BROKER_URL; + const tokenFile = env.T3CODE_VOICE_REVIEW_REVIEWER_TOKEN_FILE; + const sourceId = env.T3CODE_VOICE_REVIEW_SOURCE_ID; + const sessions = env.T3CODE_VOICE_REVIEW_ALLOWED_SESSION_IDS; + if (!rawUrl || !tokenFile || !sourceId || !sessions || !NodePath.isAbsolute(tokenFile)) + return null; + if (sourceId.trim() !== sourceId || sourceId.includes("*") || sourceId.length > 256) + return null; + const url = new URL(rawUrl); + if (url.username || url.password || url.search || url.hash || url.pathname !== "/") return null; + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname)) + ) + return null; + const ids = decodeSessionIds(JSON.parse(sessions)); + if (ids.some((id) => id.trim() !== id || id.includes("*"))) return null; + return { + broker_url: url.origin, + reviewer_token_file: tokenFile, + source_id: sourceId, + allowed_session_ids: new Set(ids), + }; + } catch { + return null; + } +}; diff --git a/apps/server/src/voiceReview/http.test.ts b/apps/server/src/voiceReview/http.test.ts new file mode 100644 index 0000000000..a587638712 --- /dev/null +++ b/apps/server/src/voiceReview/http.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; +import * as Etag from "effect/unstable/http/Etag"; +import { expect, it, vi } from "vite-plus/test"; +import { + AuthSessionId, + type AuthEnvironmentScope, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentAuthInvalidError, + EnvironmentHttpApi, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import { voiceReviewHttpApiLayer, voiceReviewResponseHeadersLayer } from "./http.ts"; + +class TestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.voiceReview) {} +const auth = Layer.succeed(EnvironmentAuthenticatedAuth, (handler) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (!request.headers.authorization) + return yield* new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "missing_credential", + traceId: "test", + }); + return yield* handler.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make( + request.headers.authorization === "Bearer owner" ? "owner" : "other", + ), + subject: "generic-subject", + method: "bearer-access-token", + scopes: new Set(["orchestration:read", "orchestration:operate"]), + }), + ); + }), +); +const makeApp = () => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(TestApi).pipe( + Layer.provide(voiceReviewHttpApiLayer), + Layer.provide(auth), + Layer.provide(voiceReviewResponseHeadersLayer), + Layer.provide(HttpPlatform.layer.pipe(Layer.provide(NodeServices.layer))), + Layer.provide(Etag.layerWeak), + Layer.provide(NodeServices.layer), + ), + { disableLogger: true }, + ); + +it("serves closed unconfigured and session-denied responses with no-store, without broker access", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + vi.stubEnv("T3CODE_VOICE_REVIEW_BROKER_URL", ""); + let app = makeApp(); + try { + expect((await app.handler(new Request("http://local/api/voice-review/drafts"))).status).toBe( + 401, + ); + const response = await app.handler( + new Request("http://local/api/voice-review/drafts", { + headers: { authorization: "Bearer owner" }, + }), + ); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ _tag: "VoiceReviewNotConfiguredError" }); + await app.dispose(); + vi.stubEnv("T3CODE_VOICE_REVIEW_BROKER_URL", "http://127.0.0.1:7000"); + vi.stubEnv("T3CODE_VOICE_REVIEW_REVIEWER_TOKEN_FILE", "/not-read"); + vi.stubEnv("T3CODE_VOICE_REVIEW_SOURCE_ID", "microphone"); + vi.stubEnv("T3CODE_VOICE_REVIEW_ALLOWED_SESSION_IDS", '["owner"]'); + app = makeApp(); + const denied = await app.handler( + new Request("http://local/api/voice-review/drafts", { + headers: { authorization: "Bearer other" }, + }), + ); + expect(denied.status).toBe(403); + expect(await denied.json()).toEqual({ _tag: "VoiceReviewForbiddenError" }); + expect(denied.headers.get("cache-control")).toBe("no-store"); + expect(fetcher).not.toHaveBeenCalled(); + } finally { + await app.dispose(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + } +}); +it("rejects strict mutation and list-window violations before credential or broker access", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + const app = makeApp(); + try { + for (const payload of [ + { expected_revision: true }, + { expected_revision: "1" }, + { expected_revision: 0 }, + { expected_revision: 1, broker_url: "https://evil.test" }, + ]) { + const response = await app.handler( + new Request("http://local/api/voice-review/drafts/capture/pause", { + method: "POST", + headers: { authorization: "Bearer owner", "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + } + expect( + ( + await app.handler( + new Request("http://local/api/voice-review/drafts?limit=201", { + headers: { authorization: "Bearer owner" }, + }), + ) + ).status, + ).toBe(400); + expect(fetcher).not.toHaveBeenCalled(); + } finally { + await app.dispose(); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/server/src/voiceReview/http.ts b/apps/server/src/voiceReview/http.ts new file mode 100644 index 0000000000..dd4cbf1d6b --- /dev/null +++ b/apps/server/src/voiceReview/http.ts @@ -0,0 +1,90 @@ +import { + EnvironmentHttpApi, + EnvironmentAuthenticatedPrincipal, + VoiceReviewError, + VoiceReviewUnavailableError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as ByteSize from "effect/ByteSize"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import { + HttpIncomingMessage, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; +import { makeVoiceReviewBridge, VOICE_REVIEW_MAX_REQUEST_BYTES } from "./bridge.ts"; +import { voiceReviewConfigFromEnv } from "./config.ts"; + +export const voiceReviewResponseHeadersLayer = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const path = new URL(request.originalUrl, "http://environment.invalid").pathname; + if (path !== "/api/voice-review/drafts" && !path?.startsWith("/api/voice-review/drafts/")) + return yield* httpEffect; + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed( + HttpServerResponse.setHeaders(response, { + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }), + ), + ); + return yield* httpEffect.pipe( + Effect.provideService( + HttpIncomingMessage.MaxBodySize, + ByteSize.bytes(VOICE_REVIEW_MAX_REQUEST_BYTES), + ), + ); + }), + { global: true }, +); + +const isReviewError = Schema.is(VoiceReviewError); +export const voiceReviewHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "voiceReview", + (handlers) => { + const bridge = makeVoiceReviewBridge(voiceReviewConfigFromEnv(process.env)); + const call = ( + run: (principal: EnvironmentAuthenticatedPrincipal["Service"]) => Promise, + ) => + Effect.flatMap(EnvironmentAuthenticatedPrincipal, (principal) => + Effect.tryPromise({ + try: () => run(principal), + catch: (error) => (isReviewError(error) ? error : new VoiceReviewUnavailableError({})), + }), + ); + return Effect.succeed( + handlers + .handle("list", ({ query }) => + call((principal) => bridge.list(principal, query.scope ?? "pending", query.limit ?? 50)), + ) + .handle("get", ({ params }) => call((principal) => bridge.get(principal, params.id))) + .handle("pause", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "pause", payload)), + ) + .handle("play", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "play", payload)), + ) + .handle("editBegin", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "edit-begin", payload)), + ) + .handle("editSave", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "edit-save", payload)), + ) + .handle("editCancel", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "edit-cancel", payload)), + ) + .handle("sendNow", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "send-now", payload)), + ) + .handle("delete", ({ params, payload }) => + call((principal) => bridge.mutate(principal, params.id, "delete", payload)), + ), + ); + }, +); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index de63c92447..33fc01e70c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,10 @@ -import { ArrowLeftIcon, BookOpenIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { + ArrowLeftIcon, + BookOpenIcon, + ChartNoAxesColumnIcon, + MicIcon, + SettingsIcon, +} from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; @@ -104,10 +110,12 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { function SidebarUtilityItem({ icon, label, + tooltip = label, onClick, }: { icon: ReactNode; label: string; + tooltip?: string; onClick: () => void; }) { return ( @@ -120,7 +128,7 @@ function SidebarUtilityItem({ } /> - {label} + {tooltip} ); @@ -172,6 +180,11 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { void navigateToMainApp(); }, [closeMobileSidebar, navigateToMainApp]); + const handleVoiceReviewClick = useCallback(() => { + closeMobileSidebar(); + void navigate({ to: "/voice-review" }); + }, [closeMobileSidebar, navigate]); + return ( {isOnUtilityPage ? ( @@ -205,6 +218,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { label="Usage" onClick={handleUsageClick} /> + } + label="Voice review" + tooltip="Review, edit, and pause voice prompts" + onClick={handleVoiceReviewClick} + /> )} diff --git a/apps/web/src/components/sidebar/mainAppLocation.test.ts b/apps/web/src/components/sidebar/mainAppLocation.test.ts index 89b27a9d12..b9965c06c0 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.test.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.test.ts @@ -6,6 +6,7 @@ describe("sidebar utility locations", () => { it("keeps the conversation library out of main-app return history", () => { expect(isSidebarUtilityPage("/conversations")).toBe(true); expect(isSidebarUtilityPage("/pull-requests")).toBe(true); + expect(isSidebarUtilityPage("/voice-review")).toBe(true); expect(isSidebarUtilityPage("/")).toBe(false); }); }); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 19b135989a..6db3ac4d2b 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,7 +1,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect } from "react"; -// Settings, Usage, Pull Requests, and Conversation Library replace the sidebar utility row with a +// Settings, Usage, Voice review, Pull Requests, and Conversation Library replace the sidebar utility row with a // Back button. Everything else is the main app. Legacy `/projects/` links // redirect into settings, so they count too and are never remembered. export function isSidebarUtilityPage(pathname: string) { @@ -10,6 +10,7 @@ export function isSidebarUtilityPage(pathname: string) { pathname.startsWith("/settings/") || pathname.startsWith("/projects/") || pathname === "/usage" || + pathname === "/voice-review" || pathname === "/pull-requests" || pathname === "/conversations" ); diff --git a/apps/web/src/components/voiceReview/VoiceReviewPage.tsx b/apps/web/src/components/voiceReview/VoiceReviewPage.tsx new file mode 100644 index 0000000000..d066201cdd --- /dev/null +++ b/apps/web/src/components/voiceReview/VoiceReviewPage.tsx @@ -0,0 +1,315 @@ +import type { EnvironmentId, VoiceReviewDraft } from "@t3tools/contracts"; +import { MicIcon, PauseIcon, PlayIcon } from "lucide-react"; +import { Fragment, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { isElectron } from "../../env"; +import { WorkspacePageContainer } from "../WorkspacePageContainer"; +import { WorkspacePageHeader } from "../WorkspacePageHeader"; +import { SidebarInset } from "../ui/sidebar"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { useVoiceReview } from "./useVoiceReview"; +import { + remainingSeconds, + VoiceReviewActions, + voiceReviewError, + type VoiceReviewTransport, +} from "./voiceReviewActions"; + +export function VoiceReviewPage() { + const { environments } = useEnvironments(); + const primaryId = usePrimaryEnvironmentId(); + const [requestedId, setRequestedId] = useState(null); + const selected = + environments.find((environment) => environment.environmentId === requestedId) ?? + environments.find((environment) => environment.environmentId === primaryId) ?? + environments[0]; + return ( + + + +

Voice review

+
+
+ +

+ Voice prompts release when their countdown ends. Double-click a prompt to edit it and + pause delivery, or use Edit. Released means handed off for processing; it does not mean + an agent has started. +

+ + {selected ? ( + + ) : ( +

No environment is connected.

+ )} +
+
+
+ ); +} + +function EnvironmentVoiceReview({ environmentId }: { environmentId: EnvironmentId }) { + const { fetchList, transport } = useVoiceReview(environmentId); + const [drafts, setDrafts] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [now, setNow] = useState(() => performance.now()); + const refreshing = useRef(false); + useEffect(() => { + let active = true; + const refresh = async () => { + if (document.visibilityState !== "visible" || refreshing.current) return; + refreshing.current = true; + try { + const pending = await fetchList("pending"); + const recent = await fetchList("recent"); + if (!active) return; + const combined = new Map(pending.drafts.map((draft) => [draft.id, draft])); + recent.drafts.forEach((draft) => { + if (!combined.has(draft.id)) combined.set(draft.id, draft); + }); + setDrafts([...combined.values()]); + setError(null); + } catch (cause) { + if (active) setError(voiceReviewError(cause)); + } finally { + refreshing.current = false; + if (active) setLoading(false); + } + }; + const onVisibility = () => { + if (document.visibilityState === "visible") void refresh(); + }; + void refresh(); + const poll = window.setInterval(() => void refresh(), 3000); + const tick = window.setInterval(() => { + if (document.visibilityState === "visible") setNow(performance.now()); + }, 1000); + document.addEventListener("visibilitychange", onVisibility); + return () => { + active = false; + window.clearInterval(poll); + window.clearInterval(tick); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [fetchList]); + const pending = drafts.filter( + (draft) => !["released", "deleted", "expired"].includes(draft.state), + ); + const recent = drafts.filter((draft) => ["released", "deleted", "expired"].includes(draft.state)); + return ( + <> + {error ?

{error}

: null} + {loading ?

Loading voice prompts…

: null} +
+

Pending

+ {!loading && !error && pending.length === 0 ? ( +

No pending voice prompts.

+ ) : null} + {[...pending, ...recent].map((draft, index) => ( + + {index === pending.length ? ( +

Recent releases and removals

+ ) : null} + +
+ ))} +
+ + ); +} + +function VoiceReviewRow({ + draft, + transport, + now, + unavailable, +}: { + draft: VoiceReviewDraft; + transport: VoiceReviewTransport; + now: number; + unavailable: boolean; +}) { + const [actions] = useState(() => new VoiceReviewActions(draft, transport)); + useSyncExternalStore(actions.subscribe, actions.snapshot); + useEffect(() => { + actions.receive(draft); + }, [actions, draft]); + const act = useCallback( + (action: Parameters[0], send = false) => { + void actions.act(action, send); + }, + [actions], + ); + const current = actions.draft; + const terminal = ["released", "deleted", "expired"].includes(current.state); + const editing = actions.editHandle !== null && current.state === "editing"; + const disabled = actions.busy || actions.uncertain || unavailable; + const seconds = remainingSeconds(current, Math.max(0, now - actions.observedAt)); + const validText = actions.text.trim().length > 0 && actions.text.length <= 100000; + return ( +
+ {!terminal ? ( + + ) : null} +
+
+ {actions.uncertain ? "Uncertain" : current.state} + {current.state === "held" && seconds !== null ? Release in {seconds}s : null} + {current.state === "paused" && seconds !== null ? ( + {seconds}s remaining · paused + ) : null} + + {current.source_id} · revision {current.revision} + +
+ {actions.editHandle !== null ? ( +