From 018be987bbe4ee9f4da6ad686914a417faf7c8c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:11:30 +0200 Subject: [PATCH 01/93] sync: upstream e16b8b059c..0f602b3372 (13 commits) --- PATCH.md | 38 +++ .../src/electron/ElectronShell.test.ts | 30 ++- apps/desktop/src/electron/ElectronShell.ts | 15 +- apps/web/src/components/ChatView.tsx | 1 + .../components/CommandPalette.logic.test.ts | 26 ++ .../src/components/CommandPalette.logic.ts | 3 + apps/web/src/components/Sidebar.logic.test.ts | 29 ++- apps/web/src/components/Sidebar.logic.ts | 16 +- apps/web/src/components/Sidebar.tsx | 8 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../components/chat/ExpandedImageDialog.tsx | 18 +- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../components/chat/ProviderModelPicker.tsx | 6 +- .../components/chat/ZoomableImage.test.tsx | 85 +++++++ .../web/src/components/chat/ZoomableImage.tsx | 239 ++++++++++++++++++ .../components/pullRequest/PullRequestRow.tsx | 4 +- apps/web/src/remoteOpen.test.ts | 12 +- .../ghostty/surface.middle-click.test.ts | 204 +++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 28 +- docs/user/attachments.md | 7 + docs/user/source-control.md | 9 + docs/user/terminal.md | 4 + packages/contracts/src/editor.ts | 25 +- packages/shared/package.json | 4 + packages/shared/src/threadPullRequests.ts | 9 + 25 files changed, 782 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/chat/ZoomableImage.test.tsx create mode 100644 apps/web/src/components/chat/ZoomableImage.tsx create mode 100644 apps/web/src/terminal/ghostty/surface.middle-click.test.ts create mode 100644 packages/shared/src/threadPullRequests.ts diff --git a/PATCH.md b/PATCH.md index bc82fb09f..6f7522099 100644 --- a/PATCH.md +++ b/PATCH.md @@ -357,6 +357,44 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera ownership, ordinary failures do not abort the batch, and failed/unprocessed threads stay selected. Navigation and worktree-cleanup failures are reported separately from a completed deletion, including the fork's archived-thread deletion path. +- The 2026-09-10 sync (`e16b8b059c..0f602b3372`, 13 upstream commits) carries seven + independent changes and retains the following boundaries: + - Linked-PR search (`f0401c6290`) runs on the existing V2 `linkedPullRequest` in web's + sidebar and command palette. The shared `threadPullRequestSearchTerms` exposes the PR + number, repository and URL without host reads. It does not introduce the upstream + multi-link projection, title snapshots or frozen Expo changes. + - Image zoom/pan (`8d8189e67d`) uses the fork's `ExpandedImageDialog` gallery, keeping its + original download handling. Zoom resets on navigation, arrow keys pan while zoomed, + and modal presence blocks type-to-focus. The standalone image component tolerates SSR. + - Composer model labels use available width (`b7b3ef1e6f`, web half); touch devices expose + user-message copy controls (`385cc0a4c6`, assistant controls were already visible). + PR list diff counts move to the title's trailing edge (`addfb1390e`) within the existing + row layout; review/check metadata already lives on the second line. + - Linux/BSD middle-click pastes the terminal's own selection (`d1eeb16247`) through the + existing paste race/bracketed-paste path. VT mouse reporting keeps priority; the fork's + modifier-click links, native copy, selection and split-pane activation remain intact. + Zed remote SSH links (`0f602b3372`) use the shared editor catalog and the fork's Electron + external-link validator. No new runtime capability or migration is needed. + - Duplicate-command expansion (`50f918c57a`) is already covered by V2's + `buildToolCallExpandedBody` / projected-item disclosure; the fork has no + `commandMatchesVisibleLabel` expansion guard. Android feed positioning (`75e4ceb964`) + and glass backing (`383cc40f4d`) remain excluded under the Expo freeze. + - Multiple linked PRs (`afb84898be`) remain deferred for a coordinated V2/Swift port: + upstream introduces host-level link identity, stack-dismissal tombstones, cached snapshots, + multi-PR settlement, automatic linking after creation, and credential-scoped MCP tools. + These must land together on V2's JSON projection and existing MCP capability model. + `050_ProjectionThreadPullRequests` targets V1 tables and collides with a fork-owned number; + it is dropped. The `threadPullRequests` capability, V1 link/unlink commands, RPCs, + provider instructions, client-runtime commands and dependent UI are not advertised/carried. + - GitHub stack navigation/merge/rebase (`de37964db2`) remains deferred with the multi-PR + stack service. A dedicated port must retain reviewed-head checks, branch permissions, + partial-rebase reporting and remote-only operations, then adapt the fork's panel stores + and native client. The `pullRequestStackActions` capability is not carried. + - Restart-persistent PR reads (`33242d0164`) cache the upstream `summary` / `stack` service + methods that the fork does not have (its earlier PR-discovery port is also deferred). + Do not add an unused cache layer or replace the fork's detail-cache semantics by inference. + Carry this with the missing service, including expiry and mutation/in-flight invalidation. + Advancing this sync marker records review of these deferred commits, not feature support. - The 2026-09-09 sync (`223ff4490f..e16b8b059c`, 185 upstream commits) manually carries independent correctness fixes while retaining the boundaries above: - `thread.stop` (`09e8de9c65`) uses web/desktop's existing V2 `interruptThreadTurn` path. diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b..a4ad375bc 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -52,6 +52,33 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -64,9 +91,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfeb..756dfce2e 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote editor links use VS Code’s vscode-remote shape or Zed’s ssh shape. +// Other non-web schemes stay blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -16,13 +16,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; + const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + (url.protocol === "zed:" + ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 80a224bb6..4e36fd1f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -540,6 +540,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ + '[role="dialog"][aria-modal="true"]', '[data-slot="dialog"]', '[data-slot="menu-popup"]', '[data-slot="select-popup"]', diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4f6644ca8..9ebc44d7b 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -169,6 +169,32 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildThreadActionItems", () => { + it("includes the V2 linked PR in thread search", () => { + const [item] = buildThreadActionItems({ + threads: [ + makeThread({ + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "Bl4ckBl1zZ/t3code", + number: 287, + url: "https://github.com/Bl4ckBl1zZ/t3code/pull/287", + }, + }), + ], + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + expect(item?.searchTerms).toEqual( + expect.arrayContaining([ + "#287", + "Bl4ckBl1zZ/t3code#287", + "https://github.com/Bl4ckBl1zZ/t3code/pull/287", + ]), + ); + }); + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index db1709fac..49a6c322e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { type FilesystemBrowseEntry, type KeybindingCommand, @@ -180,6 +181,7 @@ export type BuildThreadActionItemsThread = Pick< > & { updatedAt: string; latestUserMessageAt?: string | null; + linkedPullRequest?: SidebarThreadSummary["linkedPullRequest"]; }; export function buildThreadActionItems(input: { @@ -232,6 +234,7 @@ export function buildThreadActionItems { }); }); -describe("searchSidebarThreadsByTitle", () => { +describe("searchSidebarThreads", () => { + it("finds the V2 linked PR by number, repository or URL without changing order", () => { + const linkedPullRequest = { + projectId: ProjectId.make("project"), + repository: "Bl4ckBl1zZ/t3code", + number: 287, + url: "https://github.com/Bl4ckBl1zZ/t3code/pull/287", + }; + const threads = [ + { title: "First", linkedPullRequest }, + { title: "Unlinked", linkedPullRequest: null }, + { title: "Last", linkedPullRequest }, + ]; + for (const query of [" #287 ", "bl4ckbl1zz/t3code#287", linkedPullRequest.url]) { + expect(searchSidebarThreads(threads, query)).toEqual([threads[0], threads[2]]); + } + expect(searchSidebarThreads(threads, "#999")).toEqual([]); + expect(searchSidebarThreads([{ title: "Older server" }], "#287")).toEqual([]); + }); + const threads = [ { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, { id: "thread-2", title: "Review providers", project: "Workspace" }, @@ -1372,15 +1391,15 @@ describe("searchSidebarThreadsByTitle", () => { ]; it("matches thread titles case-insensitively and preserves their order", () => { - expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]); + expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); }); it("does not match project metadata", () => { - expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]); + expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]); }); it("returns no results for an empty query", () => { - expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]); + expect(searchSidebarThreads(threads, " ")).toEqual([]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 108737392..9c9ab59c6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { isAtomCommandInterrupted, type AtomCommandResult, @@ -1021,17 +1022,20 @@ export { export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** - * Search the already-ordered sidebar thread collection by title only. + * Search the already-ordered sidebar thread collection by title or linked PR. * Keeping the input order means lifecycle ordering (active, snoozed, settled) * remains stable while the user narrows the list. */ -export function searchSidebarThreadsByTitle( - threads: readonly T[], - query: string, -): T[] { +export function searchSidebarThreads< + T extends { readonly title: string } & Parameters[0], +>(threads: readonly T[], query: string): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery)); + return threads.filter((thread) => + [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + term.toLowerCase().includes(normalizedQuery), + ), + ); } type SettledTimestampInput = Pick< diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 01543ec6a..86de43274 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -165,7 +165,7 @@ import { resolveSettledTimestamp, resolveSidebarThreadStatus, resolveThreadLastVisitedAt, - searchSidebarThreadsByTitle, + searchSidebarThreads, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, resolveWorkInboxBadge, @@ -2765,7 +2765,7 @@ export default function Sidebar() { [activeThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults @@ -4053,8 +4053,8 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" - aria-label="Search threads" + placeholder="Search threads or PRs" + aria-label="Search threads or PRs" role="combobox" aria-autocomplete="list" aria-expanded={isSearchingThreads && threadSearchResults.length > 0} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index de7e13576..6fe9501d2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3596,7 +3596,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( void; @@ -48,6 +50,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); + const zoomableImageRef = useRef(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; const navigateImage = useCallback((direction: -1 | 1) => { @@ -62,6 +65,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose(); return; } + if (zoomableImageRef.current?.pan(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (preview.images.length <= 1) return; if (event.key === "ArrowLeft") { event.preventDefault(); @@ -137,11 +145,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ - {item.name}

{item.name} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c2c9df567..29cf0759a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1261,7 +1261,7 @@ function UserTimelineRow({ row }: { row: Extract ) : null} -

+
}> diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index db55edcf8..93bdf93c4 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -34,7 +34,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { keybindings?: ResolvedKeybindingsConfig; modelOptionsByInstance: ReadonlyMap>; activeProviderIconClassName?: string; - compact?: boolean; + isComposerOwned?: boolean; disabled?: boolean; terminalOpen?: boolean; open?: boolean; @@ -150,8 +150,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { variant={props.triggerVariant ?? "ghost"} data-chat-provider-model-picker="true" className={cn( - "min-w-0 justify-between whitespace-nowrap", - props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56", + "min-w-0 shrink justify-between whitespace-nowrap", + !props.isComposerOwned && "max-w-48 sm:max-w-56", props.triggerClassName, )} disabled={props.disabled} diff --git a/apps/web/src/components/chat/ZoomableImage.test.tsx b/apps/web/src/components/chat/ZoomableImage.test.tsx new file mode 100644 index 000000000..093bf033b --- /dev/null +++ b/apps/web/src/components/chat/ZoomableImage.test.tsx @@ -0,0 +1,85 @@ +import { act, createRef } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { ExpandedImageDialog } from "./ExpandedImageDialog"; +import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; + +let renderer: ReactTestRenderer | undefined; +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("zooms, pans with arrow keys, returns to fit and resets on gallery navigation", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const windowEvents = Object.assign(new EventTarget(), { innerWidth: 800, innerHeight: 600 }); + vi.stubGlobal("window", windowEvents); + const viewport = Object.assign(new EventTarget(), { + scrollLeft: 0, + scrollTop: 0, + clientWidth: 400, + clientHeight: 300, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + }); + const preview = { + images: [ + { src: "/one.png", name: "One" }, + { src: "/two.png", name: "Two" }, + ], + index: 0, + }; + await act(async () => { + renderer = create( {}} />, { + createNodeMock: (element) => (element.type === "div" ? viewport : null), + }); + }); + const region = () => renderer!.root.findByProps({ role: "region" }); + const key = async (value: string) => { + const event = Object.assign(new Event("keydown", { cancelable: true }), { key: value }); + await act(async () => { + windowEvents.dispatchEvent(event); + }); + return event; + }; + await act(async () => region().props.onKeyDown({ key: "+", preventDefault() {} })); + expect(renderer!.root.findByProps({ "aria-live": "polite" }).children.join("")).toBe("150% zoom"); + const previousLeft = viewport.scrollLeft; + expect((await key("ArrowRight")).defaultPrevented).toBe(true); + expect(viewport.scrollLeft).toBe(previousLeft + 40); + expect(renderer!.root.findByType("img").props.src).toBe("/one.png"); + await act(async () => region().props.onKeyDown({ key: "0", preventDefault() {} })); + await key("ArrowRight"); + expect(renderer!.root.findByType("img").props.src).toBe("/two.png"); + expect(renderer!.root.findByProps({ "aria-live": "polite" }).children.join("")).toBe("100% zoom"); +}); + +it("clamps zoom and only captures arrow navigation while zoomed", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", Object.assign(new EventTarget(), { innerWidth: 800, innerHeight: 600 })); + const ref = createRef(); + const viewport = Object.assign(new EventTarget(), { + scrollLeft: 0, + scrollTop: 0, + clientWidth: 400, + clientHeight: 300, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + }); + await act(async () => { + renderer = create(, { + createNodeMock: (element) => (element.type === "div" ? viewport : null), + }); + }); + expect(ref.current?.pan("ArrowRight")).toBe(false); + const region = renderer!.root.findByProps({ role: "region" }); + await act(async () => { + for (let i = 0; i < 20; i++) region.props.onKeyDown({ key: "+", preventDefault() {} }); + }); + expect(renderer!.root.findByProps({ "aria-live": "polite" }).children.join("")).toBe("800% zoom"); + expect(ref.current?.pan("Escape")).toBe(false); + await act(async () => { + for (let i = 0; i < 20; i++) region.props.onKeyDown({ key: "-", preventDefault() {} }); + }); + expect(ref.current?.pan("ArrowRight")).toBe(false); +}); diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx new file mode 100644 index 000000000..1ea5ed4f0 --- /dev/null +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -0,0 +1,239 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type Ref, +} from "react"; + +const MAX_ZOOM = 8; + +export interface ZoomableImageHandle { + pan: (key: string) => boolean; +} + +/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */ +export function ZoomableImage({ + src, + name, + onError, + ref, +}: { + src: string; + name: string; + onError?: () => void; + ref?: Ref; +}) { + const viewportRef = useRef(null); + const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); + const [windowSize, setWindowSize] = useState(() => ({ + width: typeof window === "undefined" ? 800 : window.innerWidth, + height: typeof window === "undefined" ? 600 : window.innerHeight, + })); + const [zoom, setZoom] = useState(1); + const zoomRef = useRef(1); + const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + left: number; + top: number; + } | null>(null); + const suppressClickRef = useRef(false); + const [dragging, setDragging] = useState(false); + const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80)); + const fit = Math.min( + 1, + (windowSize.width * 0.92) / (naturalSize.width || 1), + maxHeight / (naturalSize.height || 1), + ); + const width = naturalSize.width * fit * zoom; + const height = naturalSize.height * fit * zoom; + + useImperativeHandle( + ref, + () => ({ + pan(key) { + const viewport = viewportRef.current; + if (!viewport || zoomRef.current <= 1) return false; + switch (key) { + case "ArrowLeft": + viewport.scrollLeft -= 40; + break; + case "ArrowRight": + viewport.scrollLeft += 40; + break; + case "ArrowUp": + viewport.scrollTop -= 40; + break; + case "ArrowDown": + viewport.scrollTop += 40; + break; + default: + return false; + } + return true; + }, + }), + [], + ); + + const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => { + const viewport = viewportRef.current; + const previous = zoomRef.current; + const clamped = Math.min(MAX_ZOOM, Math.max(1, next)); + if (!viewport || previous === clamped) return; + const bounds = viewport.getBoundingClientRect(); + const x = point ? point.x - bounds.left : viewport.clientWidth / 2; + const y = point ? point.y - bounds.top : viewport.clientHeight / 2; + anchorRef.current = { + x: (viewport.scrollLeft + x) / previous, + y: (viewport.scrollTop + y) / previous, + clientX: bounds.left + x, + clientY: bounds.top + y, + }; + zoomRef.current = clamped; + setZoom(clamped); + }, []); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + const anchor = anchorRef.current; + if (!viewport || !anchor) return; + const bounds = viewport.getBoundingClientRect(); + viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left); + viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top); + anchorRef.current = null; + }, [zoom]); + + useEffect(() => { + const resize = () => { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + changeZoom(1); + }; + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [changeZoom]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const wheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = + event.deltaY * + (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); + changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { + x: event.clientX, + y: event.clientY, + }); + }; + viewport.addEventListener("wheel", wheel, { passive: false }); + return () => viewport.removeEventListener("wheel", wheel); + }, [changeZoom]); + + return ( +
+
1 ? (dragging ? "grabbing" : "grab") : "zoom-in", + }} + onClick={(event) => { + // Pointer capture also produces a click after dragging; leave the image zoomed. + if (suppressClickRef.current || event.detail > 1) return; + changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + changeZoom(zoomRef.current * 1.5); + } else if (event.key === "-") { + event.preventDefault(); + changeZoom(zoomRef.current / 1.5); + } else if (event.key === "0") { + event.preventDefault(); + changeZoom(1); + } + }} + onPointerDown={(event) => { + if (dragRef.current) return; + suppressClickRef.current = false; + if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; + const viewport = event.currentTarget; + const bounds = viewport.getBoundingClientRect(); + if ( + event.clientX - bounds.left >= viewport.clientWidth || + event.clientY - bounds.top >= viewport.clientHeight + ) + return; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + left: viewport.scrollLeft, + top: viewport.scrollTop, + }; + viewport.setPointerCapture(event.pointerId); + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { + suppressClickRef.current = true; + } + event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); + event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragging(false); + }} + onLostPointerCapture={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + }} + > + {name} { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} + onError={onError} + /> +
+ + {Math.round(zoom * 100)}% zoom + +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 6ee686a9d..8a17bae57 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -115,7 +115,7 @@ function PullRequestRowImpl({ {entry.title} - + {formatRelativeTimeLabel(entry.updatedAt)} @@ -199,7 +199,7 @@ function PullRequestRowImpl({ diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa..6f7c17d81 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); + it("builds Zed's ssh deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/apps/web/src/terminal/ghostty/surface.middle-click.test.ts b/apps/web/src/terminal/ghostty/surface.middle-click.test.ts new file mode 100644 index 000000000..3ee03cd5b --- /dev/null +++ b/apps/web/src/terminal/ghostty/surface.middle-click.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { GhosttyTerminalCore } from "./core"; +import { GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions } from "./surface"; + +vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ + default: (await import("./vendor/ghostty-vt.wasm?inline")).default, +})); +vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ + default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, +})); + +describe("GhosttyTerminalSurface middle-click paste", () => { + const surfaces = new Set(); + + // Keep the real surface, renderer, and WASM core. Only browser layout and + // scheduling are replaced so tests can count work while the terminal is hidden. + function createHarness() { + vi.useFakeTimers(); + const frames = new Map(); + const resizeCallbacks = new Set<() => void>(); + const paint = vi.fn((_operation: string, _args: ReadonlyArray) => {}); + let frameId = 0; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + + class TerminalTestElement extends EventTarget { + style: Record = {}; + parentElement: TerminalTestElement | null = null; + clientWidth = 168; + clientHeight = 104; + width = 300; + height = 150; + value = ""; + private readonly captures = new Set(); + + setAttribute() {} + append(...children: TerminalTestElement[]) { + for (const child of children) child.parentElement = this; + } + replaceChildren(...children: TerminalTestElement[]) { + this.append(...children); + } + remove() { + this.parentElement = null; + } + getContext() { + return context; + } + focus() { + this.dispatchEvent(new Event("focus")); + } + setPointerCapture(pointerId: number) { + this.captures.add(pointerId); + } + hasPointerCapture(pointerId: number) { + return this.captures.has(pointerId); + } + releasePointerCapture(pointerId: number) { + this.captures.delete(pointerId); + } + getBoundingClientRect() { + return { left: 0, top: 0, right: 168, bottom: 104, width: 168, height: 104 }; + } + } + + const canvas = new TerminalTestElement(); + const mount = new TerminalTestElement(); + const context = { + canvas, + beginPath() {}, + clip() {}, + rect() {}, + resetTransform() {}, + restore() {}, + save() {}, + setTransform() {}, + fillRect: (...args: number[]) => paint("fillRect", args), + strokeRect: (...args: number[]) => paint("strokeRect", args), + fillText: (...args: [string, number, number, number?]) => paint("fillText", args), + measureText: (text: string) => ({ + width: text.length * 8, + actualBoundingBoxAscent: 9, + actualBoundingBoxDescent: 3, + }), + }; + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()), + fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }), + }); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + devicePixelRatio: 1, + requestAnimationFrame: requestFrame, + cancelAnimationFrame: (id: number) => frames.delete(id), + setTimeout, + clearTimeout, + setInterval, + clearInterval, + matchMedia: () => Object.assign(new EventTarget(), { matches: false }), + }), + ); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(private readonly callback: () => void) { + resizeCallbacks.add(callback); + } + observe() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + }, + ); + const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); + const onData = vi.fn<(data: string) => void>(); + + return { + mount, + frames, + paint, + requestFrame, + snapshot, + onData, + get renderedSnapshot() { + const result = snapshot.mock.results.at(-1); + if (result?.type !== "return") throw new Error("No terminal snapshot was rendered"); + return result.value; + }, + flushFrame() { + const queued = [...frames.values()]; + frames.clear(); + for (const callback of queued) callback(0); + }, + resize() { + for (const callback of resizeCallbacks) callback(); + }, + pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) { + canvas.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { + clientX, + clientY: 5, + pointerId: 1, + button, + buttons, + shiftKey, + }), + ); + }, + async create(options: Partial = {}) { + const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { + theme: { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + onData, + onResize() {}, + onSelectionChange() {}, + beforeKey: () => false, + onLinkActivate() {}, + ...options, + }); + surfaces.add(surface); + return surface; + }, + }; + } + + afterEach(() => { + for (const surface of surfaces) surface.dispose(); + surfaces.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("pastes the terminal selection, and only that, on a Linux middle click", async () => { + const harness = createHarness(); + const readText = vi.fn(async () => "clipboard text"); + vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } }); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 4, false, 1); + await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled()); + expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello"); + expect(surface.getSelection()).toBe("hello"); + + // Without a selection there is no primary buffer to paste; the clipboard + // holds what the user copied and must not be substituted. + surface.clearSelection(); + harness.pointer("pointerdown", 5, 4, false, 1); + expect(readText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index df5c1f1cf..6fbc420fd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -439,6 +439,11 @@ export function isTerminalSelectAllShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +/** Linux/BSD middle-click uses the terminal selection, never the clipboard. */ +function isMiddleClickPastePlatform(): boolean { + return /linux|bsd/i.test(navigator.platform); +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -1362,6 +1367,14 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + if (event.button === 1 && isMiddleClickPastePlatform()) { + // Keep mousedown bubbling so the containing split pane is activated. + const selection = this.getSelection(); + if (selection.length > 0) { + void this.pasteFromClipboard(() => Promise.resolve(selection)); + } + return; + } if (event.button !== 0) return; if (isTerminalLinkPointerGesture(event)) { event.preventDefault(); @@ -1574,6 +1587,10 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.button === 1 && isMiddleClickPastePlatform()) { + event.preventDefault(); + return; + } if (event.button !== 0) return; if (!this.selectionMoved && this.selectionMode === "cell") { this.clearSelection(); @@ -1610,10 +1627,17 @@ export class GhosttyTerminalSurface { }; private readonly onMouseDown = (event: MouseEvent) => { - if (event.button === 0) event.preventDefault(); + if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) { + event.preventDefault(); + } this.focus(); }; + // Suppress Chromium’s native PRIMARY paste into the focused hidden textarea. + private readonly onMouseUp = (event: MouseEvent) => { + if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault(); + }; + private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); @@ -1703,6 +1727,7 @@ export class GhosttyTerminalSurface { this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mouseup", this.onMouseUp); this.canvas.addEventListener("contextmenu", this.onContextMenu); this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); @@ -1728,6 +1753,7 @@ export class GhosttyTerminalSurface { this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("contextmenu", this.onContextMenu); this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); diff --git a/docs/user/attachments.md b/docs/user/attachments.md index 30e48c8d4..ef89f5629 100644 --- a/docs/user/attachments.md +++ b/docs/user/attachments.md @@ -36,3 +36,10 @@ Deleting a thread deletes that thread's uploads. After you send a message, each attachment shows the path it was saved to. Click the path to open the file, or use the menu to copy it. Conversations that have no project attached show no path — there is nowhere to save the file, so its contents are sent with the message as before. If T3 Code had a project but could not write to it (a read-only checkout, for example), the attachment is marked **Not saved to the workspace** and its contents are sent with the message instead. Either way the message still goes through. + +## Zooming image previews + +In web and desktop, open an image from the conversation, then click it to zoom in or return +to fit. Scroll to zoom and drag to pan. With the image focused, **+** and **−** change zoom +and **0** returns to fit. Arrow keys pan while zoomed and move between images while fitted. +Downloading still saves the original image. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index ac11e3a06..69ea41133 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -156,3 +156,12 @@ Control settings**. - [GitHub CLI](https://cli.github.com/) - [GitLab CLI](https://gitlab.com/gitlab-org/cli) - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) + +## Finding a thread by its linked pull request + +On web and desktop, sidebar search and the command palette match the linked PR number +(such as **#287**), repository plus number, or URL. This searches links already attached to +threads; it does not query the source-control host. + +Remote **Open in editor** also supports Zed over SSH when the environment advertises an +SSH target and Zed is installed on the client machine. diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 036f123b2..105607424 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -8,3 +8,7 @@ These limits apply when you reconnect and when T3 Code restores saved terminal history. A client can show less scrollback than the server keeps. On Windows and Linux, **Ctrl+Insert** copies the current terminal selection. + +On Linux and BSD, middle-click pastes the selection from that terminal. With no terminal +selection, it does nothing; it does not paste the system clipboard. Applications that +capture mouse input still receive the click themselves. diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index b7a7dd7c7..21b2b16fa 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -13,7 +13,8 @@ type EditorDefinition = { /** * URL scheme for editors that support VS Code's remote deep links * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery. + * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own + * `zed://ssh/` shape. */ readonly remoteScheme?: string; }; @@ -49,7 +50,13 @@ export const EDITORS = [ launchStyle: "goto", remoteScheme: "vscodium", }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteScheme: "zed", + }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, @@ -84,7 +91,7 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type; const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; -/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +/** Editors that can open a remote workspace via SSH deep links. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => remoteSchemeOf(editor) !== undefined ? [editor.id] : [], ); @@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { }; /** - * Builds a `://vscode-remote/ssh-remote+` deep link that - * opens `absolutePath` on `host` in the local editor over SSH. Returns - * undefined for editors without remote deep-link support. + * Builds a `://vscode-remote/ssh-remote+` deep link (Zed + * takes `zed://ssh/`) that opens `absolutePath` on `host` in the + * local editor over SSH. Returns undefined for editors without remote + * deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: { const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + const encodedHost = encodeURIComponent(input.host); + return input.editor === "zed" + ? `${scheme}://ssh/${encodedHost}${encodedPath}` + : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; /** diff --git a/packages/shared/package.json b/packages/shared/package.json index b5ef9935a..4f881507b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./threadPullRequests": { + "types": "./src/threadPullRequests.ts", + "import": "./src/threadPullRequests.ts" + }, "./themePalettes": { "types": "./src/themePalettes.ts", "import": "./src/themePalettes.ts" diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts new file mode 100644 index 000000000..9f011fa15 --- /dev/null +++ b/packages/shared/src/threadPullRequests.ts @@ -0,0 +1,9 @@ +import type { ThreadLinkedPullRequest } from "@t3tools/contracts"; + +/** Search terms from the V2 single-link projection, without another host request. */ +export function threadPullRequestSearchTerms(thread: { + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; +}): string[] { + const link = thread.linkedPullRequest; + return link ? [`#${link.number}`, `${link.repository}#${link.number}`, link.url] : []; +} From d3d0cf4aafaf7da56b3a0f2247bd172de4562557 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:26:35 +0200 Subject: [PATCH 02/93] feat(ios): add linked PR collections, stack actions and image zoom --- PATCH.md | 37 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/environment/ServerEnvironment.ts | 2 + .../src/orchestration-v2/Orchestrator.ts | 25 +- .../orchestration-v2/ProjectionStore.test.ts | 26 ++ .../src/orchestration-v2/ProjectionStore.ts | 6 + .../src/pullRequest/GitHubPullRequestCli.ts | 82 ++++ .../pullRequest/GitHubPullRequestProvider.ts | 8 + .../src/pullRequest/PullRequestProvider.ts | 7 + .../pullRequest/PullRequestService.test.ts | 68 +++ .../src/pullRequest/PullRequestService.ts | 48 +- .../src/pullRequest/gitHubPullRequestJson.ts | 74 ++++ .../pullRequest/githubStackActions.test.ts | 413 +++++++++++++++++ .../src/pullRequest/githubStackActions.ts | 418 ++++++++++++++++++ apps/server/src/ws.ts | 4 + apps/swift-ios/App/NativeFeatureClient.swift | 115 +++-- apps/swift-ios/Core/Models.swift | 6 + .../Core/OrchestrationV2Models.swift | 2 + apps/swift-ios/Core/PullRequestModels.swift | 42 +- apps/swift-ios/Core/T3Client.swift | 22 + .../FeatureLinkedPullRequestSettlement.swift | 19 + .../Features/Chat/MarkdownMediaView.swift | 6 +- .../Chat/PullRequestDetailSheet.swift | 66 ++- .../Chat/PullRequestStackActionSheet.swift | 83 ++++ .../Features/Chat/ThreadDetailsSheet.swift | 6 +- .../Chat/ThreadLinkedPullRequestSheet.swift | 65 +-- .../Features/Chat/ZoomableMessageImage.swift | 65 +++ .../Features/Root/FeatureRootModel.swift | 14 +- .../Features/Shared/FeatureClient.swift | 8 + .../Features/Shared/FeatureModels.swift | 13 + .../Features/Workspace/DailyUXModels.swift | 12 +- .../Features/Workspace/WorkspaceView.swift | 2 +- .../Fixtures/orchestrationV2Projection.json | 20 + .../CoreTests/Fixtures/pullRequestStack.json | 27 ++ .../OrchestrationV2ContractTests.swift | 2 + .../FeatureTests/DailyUXSidebarTests.swift | 19 + .../LinkedPullRequestSettlementTests.swift | 36 ++ .../src/components/CommandPalette.logic.ts | 1 + docs/internals/thread-pull-requests.md | 28 ++ docs/user/chat-formatting.md | 7 + docs/user/linked-pull-requests.md | 21 + packages/client-runtime/src/state/models.ts | 4 + .../src/state/threadSettled.test.ts | 15 + .../client-runtime/src/state/threadSettled.ts | 6 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestrationV2.ts | 9 + packages/contracts/src/pullRequest.ts | 26 ++ packages/contracts/src/rpc.ts | 9 + .../shared/src/threadPullRequests.test.ts | 61 +++ packages/shared/src/threadPullRequests.ts | 67 ++- scripts/generate-swift-contract-fixtures.ts | 45 ++ 51 files changed, 2053 insertions(+), 118 deletions(-) create mode 100644 apps/server/src/pullRequest/githubStackActions.test.ts create mode 100644 apps/server/src/pullRequest/githubStackActions.ts create mode 100644 apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift create mode 100644 apps/swift-ios/Features/Chat/PullRequestStackActionSheet.swift create mode 100644 apps/swift-ios/Features/Chat/ZoomableMessageImage.swift create mode 100644 apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestStack.json create mode 100644 apps/swift-ios/Tests/FeatureTests/LinkedPullRequestSettlementTests.swift create mode 100644 docs/internals/thread-pull-requests.md create mode 100644 docs/user/linked-pull-requests.md create mode 100644 packages/shared/src/threadPullRequests.test.ts diff --git a/PATCH.md b/PATCH.md index 6f7522099..d9f89bbd1 100644 --- a/PATCH.md +++ b/PATCH.md @@ -379,22 +379,27 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera `buildToolCallExpandedBody` / projected-item disclosure; the fork has no `commandMatchesVisibleLabel` expansion guard. Android feed positioning (`75e4ceb964`) and glass backing (`383cc40f4d`) remain excluded under the Expo freeze. - - Multiple linked PRs (`afb84898be`) remain deferred for a coordinated V2/Swift port: - upstream introduces host-level link identity, stack-dismissal tombstones, cached snapshots, - multi-PR settlement, automatic linking after creation, and credential-scoped MCP tools. - These must land together on V2's JSON projection and existing MCP capability model. - `050_ProjectionThreadPullRequests` targets V1 tables and collides with a fork-owned number; - it is dropped. The `threadPullRequests` capability, V1 link/unlink commands, RPCs, - provider instructions, client-runtime commands and dependent UI are not advertised/carried. - - GitHub stack navigation/merge/rebase (`de37964db2`) remains deferred with the multi-PR - stack service. A dedicated port must retain reviewed-head checks, branch permissions, - partial-rebase reporting and remote-only operations, then adapt the fork's panel stores - and native client. The `pullRequestStackActions` capability is not carried. - - Restart-persistent PR reads (`33242d0164`) cache the upstream `summary` / `stack` service - methods that the fork does not have (its earlier PR-discovery port is also deferred). - Do not add an unused cache layer or replace the fork's detail-cache semantics by inference. - Carry this with the missing service, including expiry and mutation/in-flight invalidation. - Advancing this sync marker records review of these deferred commits, not feature support. + - The approved native parity follow-up now supports multiple explicit PR links on V2: + `thread.metadata.update` adds/removes one link atomically, with a 50-link limit and + host/repository/number identity. The JSON projection carries `linkedPullRequests` while + `linkedPullRequest` remains the primary for older clients. Legacy edits preserve other links. + Swift gates collection editing on `threadPullRequestsV2`, searches every link, and requires + every linked PR to read as terminal before settling. Link changes restart its observations. + Web/Expo still render the primary and conservatively avoid automatic settlement for collections. + Automatic discovery/linking after creation, stack-dismissal tombstones, cached snapshots and + credential-scoped MCP link tools remain unported. Upstream's `threadPullRequests` flag and V1 + commands stay excluded; `050_ProjectionThreadPullRequests` is dropped, with no new migration. + - GitHub stack navigation/merge/rebase (`de37964db2`) is now available to Swift through + `pullRequests.stack` and `pullRequestStackActions`. The standalone GitHub action implementation + retains reviewed-head checks, per-branch permissions, partial-rebase reporting and remote-only + operations. Confirmation holds the reviewed stack immutable; mutations invalidate every + reviewed PR's cached reads even after partial failure. Web/Expo stack controls remain unported. + - Restart-persistent PR summary/stack reads (`33242d0164`) remain excluded. Stack reads are + on demand; the earlier V2 background PR-discovery/summary service is still missing. Carry a + durable read cache with that service, including expiry and mutation/in-flight invalidation. + Advancing this sync marker records review of deferred work, not full upstream feature support. + - Swift's existing image galleries now support pinch/pan, double-tap zoom and an accessible + fit action while retaining original-byte export and current/adjacent-page loading. - The 2026-09-09 sync (`223ff4490f..e16b8b059c`, 185 upstream commits) manually carries independent correctness fixes while retaining the boundaries above: - `thread.stop` (`09e8de9c65`) uses web/desktop's existing V2 `interruptThreadTurn` path. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 429d6be6e..a50cb706e 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 432d8df4e..9671adcd1 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -163,6 +163,8 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, + threadPullRequestsV2: true, + pullRequestStackActions: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7e5264645..2915a1e58 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,3 +1,4 @@ +import { updateLinkedPullRequests } from "@t3tools/shared/threadPullRequests"; import { type ChatAttachment, CommandId, @@ -1449,6 +1450,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); const thread = projection.thread; + if (command.type === "thread.metadata.update") { + const edits = [ + command.linkedPullRequest, + command.linkPullRequest, + command.unlinkPullRequest, + ].filter((value) => value !== undefined); + if ( + edits.length > 1 || + updateLinkedPullRequests(thread, command).linkedPullRequests.length > 50 + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Send one pull-request edit at a time; a thread can link at most 50 pull requests.", + }); + } + } if (thread.deletedAt !== null && command.type !== "thread.delete") { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -1730,9 +1749,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { activeOrderKey: command.activeOrderKey }), // Absent leaves the link alone; null unlinks. - ...(command.linkedPullRequest === undefined + ...(command.linkedPullRequest === undefined && + command.linkPullRequest === undefined && + command.unlinkPullRequest === undefined ? {} - : { linkedPullRequest: command.linkedPullRequest }), + : updateLinkedPullRequests(thread, command)), ...(command.workInboxRole === undefined ? {} : { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 806b77dc9..430be7ccc 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -149,6 +149,32 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { payload: thread, }); + const links = [41, 42].map((number) => ({ + projectId, + repository: "owner/repo", + number, + url: `https://github.com/owner/repo/pull/${number}`, + })); + yield* projectionStore.apply({ + id: EventId.make("event:projection-read-state:links"), + type: "thread.metadata-updated", + threadId, + occurredAt: markedUnreadOccurredAt, + payload: { ...thread, linkedPullRequest: links[0]!, linkedPullRequests: links }, + }); + assert.deepEqual( + (yield* projectionStore.getThreadProjection(threadId)).thread.linkedPullRequests, + links, + ); + assert.deepEqual( + (yield* projectionStore.getThreadShell(threadId))?.linkedPullRequests, + links, + ); + assert.deepEqual( + (yield* projectionStore.getShellSnapshot()).threads.find((shell) => shell.id === threadId) + ?.linkedPullRequests, + links, + ); const markedUnread = yield* projectionStore.getThreadProjection(threadId); assert.isNull(markedUnread.thread.lastVisitedAt); assert.deepEqual(markedUnread.thread.updatedAt, createdAt); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2d6e3bc25..0fca043c9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -975,6 +975,9 @@ export function threadShellFromProjection( ? {} : { worktreeStatus: projection.thread.worktreeStatus }), linkedPullRequest: projection.thread.linkedPullRequest ?? null, + ...(projection.thread.linkedPullRequests === undefined + ? {} + : { linkedPullRequests: projection.thread.linkedPullRequests }), lineage: projection.thread.lineage, forkedFrom: projection.thread.forkedFrom, activeProviderThreadId: projection.thread.activeProviderThreadId, @@ -1162,6 +1165,9 @@ function shellFromState(input: { ? {} : { worktreeStatus: input.state.thread.worktreeStatus }), linkedPullRequest: input.state.thread.linkedPullRequest ?? null, + ...(input.state.thread.linkedPullRequests === undefined + ? {} + : { linkedPullRequests: input.state.thread.linkedPullRequests }), lineage: input.state.thread.lineage, forkedFrom: input.state.thread.forkedFrom, activeProviderThreadId: input.state.thread.activeProviderThreadId, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5f8ee8c4e..e28242179 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,9 @@ +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import { + decodePullRequestStacksJson, + type GitHubPullRequestStack, +} from "./gitHubPullRequestJson.ts"; +import type { PullRequestStackHead } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -241,6 +247,7 @@ export class GitHubSubjectScopeError extends Schema.TaggedErrorClass Effect.Effect; + readonly getPullRequestStack: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly includeDetails?: boolean; + }) => Effect.Effect; readonly runPullRequestAction: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -1696,7 +1712,73 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid); }, + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks/${stack.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + ); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubPullRequestNotFoundError: () => Effect.succeed(null), + }), + ); + }, + runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubCli.GitHubCli, github), + ); const [subcommand, ...flags] = actionArgs( input.action, input.mergeMethod, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c..085d60fbf 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -414,6 +414,10 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("setReviewerRequest"))), + getStack: (input) => + cli + .getPullRequestStack({ ...input, includeDetails: true }) + .pipe(Effect.mapError(fail("getStack"))), runAction: (input) => cli .runPullRequestAction({ @@ -422,6 +426,10 @@ export const make = Effect.gen(function* () { host: input.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index eadc5e931..ac25c8e77 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,3 +1,4 @@ +import type { PullRequestStack, PullRequestStackHead } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { @@ -356,10 +357,16 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + readonly getStack?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; /** Only meaningful for `update-branch`; absent takes the host's own default. */ diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 8f96bbca4..e4610409f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3420,3 +3420,71 @@ it.effect("names the signed-in account in the detail, and says nothing where the assert.strictEqual(unnamed.viewer, undefined); }), ); + +it.effect("routes stack reads and preserves reviewed heads through action authorization", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const heads = [ + { number: 1, headSha: "abc" }, + { number: 2, headSha: "def" }, + ]; + const stack = { + id: "stack-1", + number: 1, + url: "https://github.com/acme/web/stack/1", + base: "main", + layers: [], + }; + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getStack: (input) => { + assert.equal(input.number, 2); + return Effect.succeed(stack); + }, + runAction: (input) => { + calls++; + assert.equal(input.stackNumber, 1); + assert.deepEqual(input.expectedStackHeads, heads); + return Effect.void; + }, + }), + ], + }); + assert.deepEqual(yield* service.stack(reference), stack); + yield* service.runAction({ + ...reference, + stackNumber: 1, + expectedStackHeads: heads, + action: "merge", + mergeMethod: "merge", + }); + assert.equal(calls, 1); + }), +); + +it.effect("returns no stack and refuses stack mutations on unsupported hosts", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + runAction: () => { + calls++; + return Effect.void; + }, + }), + ], + }); + assert.isNull(yield* service.stack(reference)); + const error = yield* Effect.flip( + service.runAction({ ...reference, stackNumber: 1, action: "merge" }), + ); + assert.equal(error._tag, "PullRequestOperationError"); + assert.equal(calls, 0); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index dbb4d7a66..7d278f582 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,4 @@ +import type { PullRequestStack } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -135,6 +136,9 @@ export class PullRequestService extends Context.Service< readonly diffFileContents: ( input: PullRequestDiffFileContentsInput, ) => Effect.Effect; + readonly stack: ( + input: PullRequestRef, + ) => Effect.Effect; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; @@ -441,6 +445,7 @@ function withRateLimitBackoff( ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), + ...(api.getStack === undefined ? {} : { getStack: wrap("getStack", api.getStack) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -1296,9 +1301,33 @@ export const make = Effect.gen(function* () { }), ); + const stack: PullRequestService["Service"]["stack"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.getStack + ? project.api + .getStack({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("stack"))) + : Effect.succeed(null), + ), + ); + const runAction: PullRequestService["Service"]["runAction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { + if (input.stackNumber !== undefined && !project.api.getStack) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "This host does not support stack actions.", + }), + ); + } // The surface hides what a host cannot do, and this refuses it as well: a request that // reached here anyway must not be handed to a provider that never claimed the action. if (!project.api.capabilities.actions.includes(input.action)) { @@ -1367,6 +1396,10 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) @@ -2101,7 +2134,20 @@ export const make = Effect.gen(function* () { threadComments, diff, diffFileContents, - runAction: invalidatedByMutation(runAction), + stack, + runAction: (input) => + invalidatedByMutation(runAction)(input).pipe( + Effect.ensuring( + input.stackNumber === undefined + ? Effect.void + : Effect.sync(() => { + // A rebase can fail after updating earlier layers. Invalidate every reviewed layer. + for (const head of input.expectedStackHeads ?? []) + bumpRefEpoch({ ...input, number: head.number }); + listingsEpoch = ++epochCounter; + }), + ), + ), update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), updateComment: invalidatedByMutation(updateComment), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea11..e367b0a8c 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2239,3 +2239,77 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ +const RawStackPullRequestSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + draft: Schema.optional(Schema.Boolean), + number: Schema.Int, + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), + state: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may + * still move. Only what a stack is made of is required — where it lives, what it stands on, and + * its pull requests — and `base` is accepted both as the ref object the preview sends today and + * as the bare branch name it started out as. + */ +const RawStackSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), + number: Schema.Int, + node_id: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), + pull_requests: Schema.Array(RawStackPullRequestSchema), +}); + +const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); + +export interface GitHubPullRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +export interface GitHubPullRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + /** Bottom to top, which is the order GitHub lists them in. */ + readonly layers: ReadonlyArray; +} + +/** + * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in + * at most one stack, so the array is GitHub's way of saying "none" rather than a page. + */ +export function decodePullRequestStacksJson( + raw: string, +): Result.Result { + const decoded = decodeStacks(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const stack = decoded.success[0]; + if (stack === undefined) return Result.succeed(null); + return Result.succeed({ + id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), + number: stack.number, + // The page a person opens where the preview reports one; the API URL is what it always has. + url: trimmed(stack.html_url) ?? stack.url, + base: typeof stack.base === "string" ? stack.base : stack.base.ref, + layers: stack.pull_requests.map((pullRequest) => ({ + ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), + ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), + ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts new file mode 100644 index 000000000..4ed254514 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -0,0 +1,413 @@ +import { expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; + +const runGitHubStackAction = ( + execute: GitHubCli.GitHubCli["Service"]["execute"], + input: Parameters[0], +) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); + +const stack = [ + { + number: 50, + url: "https://api.github.com/repos/acme/web/stacks/50", + base: { ref: "main" }, + pull_requests: [ + { + number: 1, + title: "Base", + head: { ref: "base", sha: "aaa" }, + state: "closed", + merged_at: "2026-01-01T00:00:00Z", + }, + { + number: 2, + title: "Middle", + head: { ref: "middle", sha: "bbb" }, + state: "open", + draft: false, + }, + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }, +]; +const input = { + cwd: "/repo", + repository: "acme/web", + host: "github.com", + number: 3, + stackNumber: 50, + expectedStackHeads: [ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ], + action: "merge" as const, +}; +const access = { + data: { + repository: { + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + }, + }, +}; + +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ + data: { + processed: processed.map((headRefOid) => ({ headRefOid })), + repository: { + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, + }, + }, +}); +const rebased = { + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, +}; +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; + +function fake(responses: readonly unknown[]) { + const calls: ReadonlyArray[] = []; + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => + Effect.sync(() => { + calls.push(request.args); + const value = responses[calls.length - 1]; + if (value === undefined) throw new Error("Unexpected GitHub request"); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdout: JSON.stringify(value), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; + }); + return { execute, calls }; +} + +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }]); + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=ccc"); + expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls[1]).toContain("merge_method=squash"); + }), +); + +it.effect("merges through the selected layer without including later draft layers", () => + Effect.gen(function* () { + const fiveLayers = [ + { + ...stack[0], + pull_requests: Array.from({ length: 5 }, (_, index) => ({ + number: index + 1, + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, + state: "open", + draft: index >= 3, + })), + }, + ]; + const api = fake([fiveLayers, { status: "merged", details: {} }]); + yield* runGitHubStackAction(api.execute, { + ...input, + number: 3, + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), + }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=sha-3"); + expect(api.calls[1]).toContain("merge_action=default"); + }), +); + +it.effect("rejects stale reviewed heads below a selected middle layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 2, + expectedStackHeads: [{ number: 2, headSha: "old-head" }], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("does not merge from an already merged layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 1, + expectedStackHeads: [], + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackUnsupportedError" }, + }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("polls an accepted merge and reports a later rule rejection", () => + Effect.gen(function* () { + const api = fake([ + stack, + { status: "pending", details: { uuid: "operation" } }, + { status: "failed", details: { message: "Required checks have not passed" } }, + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergeRejectedError" }, + }); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); + }), +); + +it.effect("retains stack identity and a rejection response without a message", () => + Effect.gen(function* () { + const rejection = { status: "failed", details: {} }; + const api = fake([stack, rejection]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "GitHubStackMergeRejectedError", + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + cause: rejection, + }, + }); + }), +); + +it.effect("refuses a changed stack before performing any mutation", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + expectedStackHeads: [ + { number: 2, headSha: "old" }, + { number: 3, headSha: "ccc" }, + ], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("rebases unmerged layers bottom to top without local git commands", () => + Effect.gen(function* () { + const api = fake([stack, access, ...rebaseResponses]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + const mutations = api.calls.filter((args) => + args.some((arg) => arg.startsWith("query=mutation")), + ); + expect(mutations).toHaveLength(2); + expect(mutations[0]).toContain("id=PR_2"); + expect(mutations[0]).toContain("sha=bbb"); + expect(mutations[1]).toContain("id=PR_3"); + expect(mutations[1]).toContain("sha=ccc"); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("does not update later layers after a rebase failure", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb")]); + const execute: typeof api.execute = (request) => + !request.args.some((arg) => arg.startsWith("query=mutation")) + ? api.execute(request) + : Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/repo", + cause: new Error("denied"), + }), + ); + const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( + Effect.result, + ); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, + }); + }), +); + +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackPermissionError" }, + }); + expect(api.calls).toHaveLength(2); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("allows a fork that explicitly permits maintainer updates", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, + }, + }, + }, + ...rebaseResponses, + ]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.at(-1)).toContain("id=PR_3"); + }), +); + +it.effect("bounds polling and reports a still-running merge without claiming success", () => + Effect.gen(function* () { + const api = fake([ + stack, + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("6 minutes"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergePendingError" }, + }); + expect(api.calls.length).toBeLessThan(40); + }), +); + +it.effect("rejects a push after preflight without rebasing the new revision", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "new-head")]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, + }); + expect(api.calls).toHaveLength(3); + }), +); + +it.effect("skips current layers without submitting a rebase mutation", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( + false, + ); + }), +); + +it.effect("keeps earlier progress and stops after a later layer fails", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "ccc", 1, ["rebased-sha"]), + { data: { updatePullRequestBranch: null } }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, + }); + }), +); + +it.effect("reports partial progress when a later head changes during the rebase", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "concurrent-head", 1, ["rebased-sha"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Earlier updates remain on GitHub"); + } + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(1); + }), +); + +it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb", rebasedParent ? 1 : 0), + ...(rebasedParent ? [rebased] : []), + branch(3, "ccc", 1, ["concurrent-parent-head"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, + }); + expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( + true, + ); + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(rebasedParent ? 1 : 0); + }), +); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts new file mode 100644 index 000000000..d13de0187 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -0,0 +1,418 @@ +import type { + PullRequestAction, + PullRequestMergeMethod, + PullRequestStackHead, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; + +const stackErrorIdentity = { + repository: Schema.String, + number: Schema.Int, + stackNumber: Schema.Int, +}; + +export class GitHubStackChangedError extends Schema.TaggedErrorClass()( + "GitHubStackChangedError", + { ...stackErrorIdentity, completed: Schema.Int }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return this.completed > 0 + ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` + : "The stack changed. Refresh it before trying again."; + } +} + +export class GitHubStackUnsupportedError extends Schema.TaggedErrorClass()( + "GitHubStackUnsupportedError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "This operation is not supported for this stack."; + } +} + +export class GitHubStackResponseInvalidError extends Schema.TaggedErrorClass()( + "GitHubStackResponseInvalidError", + { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub returned an unreadable stack operation response."; + } +} + +export class GitHubStackMergeRejectedError extends Schema.TaggedErrorClass()( + "GitHubStackMergeRejectedError", + { ...stackErrorIdentity, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; + } +} + +export class GitHubStackMergePendingError extends Schema.TaggedErrorClass()( + "GitHubStackMergePendingError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "The merge is still running on GitHub. Check its status there before submitting another request."; + } +} + +export class GitHubStackPermissionError extends Schema.TaggedErrorClass()( + "GitHubStackPermissionError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; + } +} + +export class GitHubStackRebaseFailedError extends Schema.TaggedErrorClass()( + "GitHubStackRebaseFailedError", + { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; + } +} + +export type GitHubStackActionError = + | GitHubStackChangedError + | GitHubStackUnsupportedError + | GitHubStackResponseInvalidError + | GitHubStackMergeRejectedError + | GitHubStackMergePendingError + | GitHubStackPermissionError + | GitHubStackRebaseFailedError; + +const MergeResponse = Schema.Struct({ + status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), + details: Schema.Struct({ + uuid: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +}); + +const decodeBranchAccess = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + headRepository: Schema.NullOr( + Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), + ), + maintainerCanModify: Schema.Boolean, + }), + ), + ), + ), + }), + }), + ), +); + +const decodeRebaseBranch = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + processed: Schema.optional( + Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), + }), + }), + }), + }), + ), +); +const decodeRebaseResponse = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + updatePullRequestBranch: Schema.Struct({ + pullRequest: Schema.Struct({ headRefOid: Schema.String }), + }), + }), + }), + ), +); + +const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); + +/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ +export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { + cwd: string; + repository: string; + host: string; + number: number; + stackNumber: number; + expectedStackHeads?: ReadonlyArray; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; +}) { + const github = yield* GitHubCli.GitHubCli; + const identity = { + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + }; + if (input.action !== "merge" && input.action !== "update-branch") + return yield* new GitHubStackUnsupportedError({ ...identity }); + const endpoint = `repos/${input.repository}`; + const read = yield* github.execute({ + cwd: input.cwd, + args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], + }); + const decoded = decodePullRequestStacksJson(read.stdout); + if (Result.isFailure(decoded)) + return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); + const stack = decoded.success; + const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; + const target = stack?.layers[targetIndex]; + if ( + stack?.number !== input.stackNumber || + target === undefined || + (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + const affectedLayers = + input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; + const open = affectedLayers.filter((layer) => layer.state !== "merged"); + if (input.action === "merge" && target.state !== "open") + return yield* new GitHubStackUnsupportedError({ ...identity }); + if ( + !input.expectedStackHeads || + input.expectedStackHeads.length !== open.length || + new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || + open.some( + (layer) => + !layer.headSha || + !input.expectedStackHeads?.some( + (expected) => expected.number === layer.number && expected.headSha === layer.headSha, + ), + ) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + if (open.length === 0 || open.some((layer) => layer.state !== "open")) + return yield* new GitHubStackUnsupportedError({ ...identity }); + if (input.action === "update-branch") { + const [owner, name] = input.repository.split("/"); + const permissions = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-f", + `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open + .map( + (layer) => + `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, + ) + .join(" ")}}}`, + ], + }); + const access = yield* decodeBranchAccess(permissions.stdout).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent + // will make it stale. Check branch write access separately before touching any layer. + if ( + open.some((layer) => { + const pr = access.data.repository?.[`pr${layer.number}`]; + return ( + !pr?.headRepository || + (!pr.maintainerCanModify && + !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) + ); + }) + ) + return yield* new GitHubStackPermissionError({ ...identity }); + const processed: Array<{ id: string; number: number; headSha: string }> = []; + for (const [index, layer] of open.entries()) { + yield* Effect.gen(function* () { + const read = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${layer.number}`, + "-f", + `sha=${layer.headSha}`, + "-f", + `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ + processed.length === 0 + ? "" + : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` + } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, + ], + }); + const { + data: { + processed: observed, + repository: { pullRequest: pr }, + }, + } = yield* decodeRebaseBranch(read.stdout); + // A push to an earlier layer must not silently become the next layer's new base. + const changed = processed.find( + (head, index) => observed?.[index]?.headRefOid !== head.headSha, + ); + if (changed !== undefined) + return yield* new GitHubStackChangedError({ + ...identity, + number: changed.number, + completed: index, + }); + if (pr.headRefOid !== layer.headSha) + return yield* new GitHubStackChangedError({ + ...identity, + number: layer.number, + completed: index, + }); + if (pr.baseRef.compare.behindBy === 0) { + processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); + return; + } + // Pass the reviewed revision to GitHub, including when a push races this read. + const updated = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `id=${pr.id}`, + "-f", + `sha=${layer.headSha}`, + "-f", + "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", + ], + }); + const response = yield* decodeRebaseResponse(updated.stdout); + processed.push({ + id: pr.id, + number: layer.number, + headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, + }); + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackChangedError" + ? cause + : new GitHubStackRebaseFailedError({ + ...identity, + number: layer.number, + completed: index, + cause, + }), + ), + ); + } + return; + } + if (open.some((layer) => layer.isDraft)) + return yield* new GitHubStackUnsupportedError({ ...identity }); + const decode = (raw: string) => + decodeMergeResponse(raw).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + const request = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--method", + "PUT", + `${endpoint}/pulls/${input.number}/merge-async`, + "-f", + `merge_method=${input.mergeMethod ?? "merge"}`, + "-f", + "merge_action=default", + "-f", + `sha=${target.headSha}`, + ], + }); + let result = yield* decode(request.stdout); + const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; + for ( + let attempt = 0; + result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; + attempt++ + ) { + const uuid = result.details.uuid; + if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); + yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); + const poll = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, + ], + }); + result = yield* decode(poll.stdout); + } + if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); + if (result.status === "failed") + return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7d3a0aa68..6d99cc652 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2069,6 +2069,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsStack]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 5a37e6fd6..79de8b53a 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -1259,12 +1259,41 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, try? await refresh(client: route.client) } + func addThreadPullRequest(threadID: String, number: Int) async throws -> FeatureLinkedPullRequest? { + try await changeThreadLinkedPullRequest(threadID: threadID, number: number, adding: true) + } + + func removeThreadPullRequest(threadID: String, link: FeatureLinkedPullRequest) async throws { + let route = try threadRoute(for: threadID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.threadPullRequestsV2 == true else { + throw FeatureCapabilityUnavailable("Multiple pull requests") + } + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let wire = (thread.linkedPullRequests ?? thread.linkedPullRequest.map { [$0] } ?? []).first(where: { $0.number == link.number && $0.url == link.url }) else { + throw NativeFeatureClientError.workspaceNotFound + } + _ = try await route.client.dispatch(OrchestrationCommands.updateMetadata(threadID: route.wireID, fields: ["unlinkPullRequest": try JSONValue.encode(wire)])) + try? await refresh(client: route.client) + } + + @discardableResult + func setThreadLinkedPullRequest(threadID: String, number: Int?) async throws -> FeatureLinkedPullRequest? { + try await changeThreadLinkedPullRequest(threadID: threadID, number: number, adding: false) + } + @discardableResult - func setThreadLinkedPullRequest( + private func changeThreadLinkedPullRequest( threadID: String, - number: Int? + number: Int?, + adding: Bool ) async throws -> FeatureLinkedPullRequest? { let route = try threadRoute(for: threadID) + if adding { + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.threadPullRequestsV2 == true else { + throw FeatureCapabilityUnavailable("Multiple pull requests") + } + } guard let number else { _ = try await route.client.setLinkedPullRequest( threadID: route.wireID, @@ -1290,15 +1319,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, repository: repository, number: number ) - _ = try await route.client.setLinkedPullRequest( - threadID: route.wireID, - pullRequest: OrchestrationV2ThreadLinkedPullRequest( - projectId: project.id, - repository: repository, - number: detail.number, - url: detail.url - ) - ) + let link = OrchestrationV2ThreadLinkedPullRequest(projectId: project.id, repository: repository, number: detail.number, url: detail.url) + _ = try await route.client.dispatch(OrchestrationCommands.updateMetadata(threadID: route.wireID, + fields: [adding ? "linkPullRequest" : "linkedPullRequest": try JSONValue.encode(link)])) try? await refresh(client: route.client) return FeatureLinkedPullRequest( projectID: FeatureScopedID.project( @@ -1983,6 +2006,29 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, return detail } + func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? { + let route = try threadRoute(for: threadID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { return nil } + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + return try await route.client.pullRequestStack(projectID: project.id, repository: repository, number: number) + } + + func runPullRequestStackAction(threadID: String, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws { + let route = try threadRoute(for: threadID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { + throw FeatureCapabilityUnavailable("Stack actions") + } + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + try await route.client.runPullRequestStackAction(projectID: project.id, repository: repository, number: number, + stack: stack, action: action, mergeMethod: mergeMethod) + } + func pullRequestOverview(threadID: String, number: Int) async throws -> FeaturePullRequestOverview { @@ -2049,19 +2095,18 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, // A linked pull request is the thread's own answer and outranks whatever // its worktree's branch happens to point at: the same branch can back // several requests, and a thread whose worktree is gone still has one. - var linkedByThreadID: [String: LinkedChangeRequestSubscription] = [:] + var linkedByThreadID: [String: [LinkedChangeRequestSubscription]] = [:] for threadID in threadIDs { guard let route = try? threadRoute(for: threadID), let shell = shellsByEnvironmentID[route.environmentID], let thread = shell.threads.first(where: { $0.id == route.wireID }) else { continue } - if let linked = thread.linkedPullRequest { - linkedByThreadID[threadID] = LinkedChangeRequestSubscription( - environmentID: route.environmentID, - projectWireID: linked.projectId, - repository: linked.repository, - number: linked.number - ) + let links = thread.linkedPullRequests ?? thread.linkedPullRequest.map { [$0] } ?? [] + if !links.isEmpty { + linkedByThreadID[threadID] = links.map { linked in + LinkedChangeRequestSubscription(environmentID: route.environmentID, + projectWireID: linked.projectId, repository: linked.repository, number: linked.number) + } continue } guard let context = try? workspaceContext(route: route), @@ -2144,28 +2189,20 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, /// within half a minute instead of at the next app launch. private func pollLinkedChangeRequest( threadID: String, - subscription: LinkedChangeRequestSubscription, + subscription: [LinkedChangeRequestSubscription], accumulator: ChangeRequestAccumulator, into continuation: AsyncStream<[String: FeaturePullRequest]>.Continuation ) async { while !Task.isCancelled { - guard let client = environmentClients[subscription.environmentID] else { return } - let detail = try? await client.pullRequestDetail( - projectID: subscription.projectWireID, - repository: subscription.repository, - number: subscription.number - ) - if Task.isCancelled { return } - // A failed read leaves the previous answer in place. The host is - // reached through the `gh` CLI, so a flaky read is ordinary; blanking - // the badge on one would make a merged row bounce back to Active. - if let detail, - let merged = accumulator.applyLinked( - threadID: threadID, - pullRequest: NativeWorkspaceMapper.pullRequest(detail) - ) { - continuation.yield(merged) + guard let first = subscription.first, let client = environmentClients[first.environmentID] else { return } + var reads: [FeaturePullRequest?] = [] + for link in subscription { + let detail = try? await client.pullRequestDetail(projectID: link.projectWireID, repository: link.repository, number: link.number) + if Task.isCancelled { return } + reads.append(detail.map(NativeWorkspaceMapper.pullRequest)) } + let summary = FeatureLinkedPullRequestSettlement.aggregate(reads) ?? FeaturePullRequest(number: first.number, title: "Pull requests unavailable", state: "unknown") + if let merged = accumulator.applyLinked(threadID: threadID, pullRequest: summary) { continuation.yield(merged) } try? await Task.sleep(for: .seconds(30)) } } @@ -4595,6 +4632,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, thread.linkedPullRequest, environment: environment ), + linkedPullRequests: thread.linkedPullRequests.map { links in links.compactMap { mapLinkedPullRequest($0, environment: environment) } }, + supportsMultiplePullRequests: environment.descriptor?.capabilities.threadPullRequestsV2, + supportsPullRequestStackActions: environment.descriptor?.capabilities.pullRequestStackActions, supportsPullRequestLinking: environment.descriptor?.capabilities .threadPullRequestLinking, attentionAt: latestRun?.status == "failed" @@ -4724,6 +4764,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, thread.linkedPullRequest, environment: environment ), + linkedPullRequests: thread.linkedPullRequests.map { links in links.compactMap { mapLinkedPullRequest($0, environment: environment) } }, + supportsMultiplePullRequests: environment.descriptor?.capabilities.threadPullRequestsV2, + supportsPullRequestStackActions: environment.descriptor?.capabilities.pullRequestStackActions, supportsPullRequestLinking: environment.descriptor?.capabilities .threadPullRequestLinking, // A failed run is the only thing that earns an attention marker; a diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index bebbbfbcf..0bc2d3815 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -50,6 +50,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { /// Absent on older servers, so the link action stays hidden rather than /// sending a command the server will reject. public let threadPullRequestLinking: Bool? + public let threadPullRequestsV2: Bool? + public let pullRequestStackActions: Bool? public let pullRequests: Bool? public let serverSelfUpdate: String? public let serverSelfUpdateProgress: Bool? @@ -62,6 +64,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadPinning, threadActiveOrderV2, threadQuestionActionsV2 case threadTitleRegeneration case threadPullRequestLinking + case threadPullRequestsV2 + case pullRequestStackActions case pullRequests case serverSelfUpdate case serverSelfUpdateProgress @@ -81,6 +85,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { Bool.self, forKey: .threadTitleRegeneration ) + threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) + pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) threadPullRequestLinking = try container.decodeIfPresent( Bool.self, forKey: .threadPullRequestLinking diff --git a/apps/swift-ios/Core/OrchestrationV2Models.swift b/apps/swift-ios/Core/OrchestrationV2Models.swift index 4e73e22ad..5a66ce322 100644 --- a/apps/swift-ios/Core/OrchestrationV2Models.swift +++ b/apps/swift-ios/Core/OrchestrationV2Models.swift @@ -880,6 +880,7 @@ public struct OrchestrationV2AppThread: Codable, Equatable, Sendable, Identifiab public let worktreePath: String? /// See `OrchestrationV2ThreadShell.linkedPullRequest`. public let linkedPullRequest: OrchestrationV2ThreadLinkedPullRequest? + public var linkedPullRequests: [OrchestrationV2ThreadLinkedPullRequest]? = nil public let activeProviderThreadId: String? public let historyOrigin: String? public let lineage: OrchestrationV2AppThreadLineage @@ -1299,6 +1300,7 @@ public struct OrchestrationV2ThreadShell: Codable, Equatable, Sendable, Identifi /// Absent on servers that predate pull-request linking, and on threads with /// nothing linked. Nil means "resolve the pull request from the branch". public var linkedPullRequest: OrchestrationV2ThreadLinkedPullRequest? + public var linkedPullRequests: [OrchestrationV2ThreadLinkedPullRequest]? = nil public var lineage: OrchestrationV2AppThreadLineage public var forkedFrom: OrchestrationV2ForkSource? public var activeProviderThreadId: String? diff --git a/apps/swift-ios/Core/PullRequestModels.swift b/apps/swift-ios/Core/PullRequestModels.swift index f6ecf86c9..e3e7eafb9 100644 --- a/apps/swift-ios/Core/PullRequestModels.swift +++ b/apps/swift-ios/Core/PullRequestModels.swift @@ -2,9 +2,8 @@ import Foundation // Pull-request detail and activity, as `packages/contracts/src/pullRequest.ts` // reports them over the `pullRequests.detail` and `pullRequests.activity` WS -// RPCs. Only the fields the read-only sheet renders are modelled; the -// capability, permission and merge-method blocks the actions UI would need are -// left undeclared, which `JSONDecoder` simply skips. +// RPCs. The detail sheet also decodes host capabilities and viewer permissions +// to gate reviewed stack actions. Unused response fields are skipped. // // Dates stay ISO strings, matching how the other Core models carry // `IsoDateTime`. @@ -106,6 +105,8 @@ public enum PullRequestMergeability: String, Codable, Sendable { } public struct PullRequestDetail: Codable, Equatable, Sendable { + public var capabilities: NativePullRequestCapabilities? = nil + public var viewerPermissions: NativePullRequestViewerPermissions? = nil public let projectId: String public let projectTitle: String public let repository: String @@ -146,3 +147,38 @@ public struct PullRequestActivity: Codable, Equatable, Sendable { public let reviewThreads: [PullRequestReviewThread] public let commits: [PullRequestCommit] } + +public struct PullRequestStack: Codable, Equatable, Sendable { + public let id: String + public let number: Int + public let url: String + public let base: String + public let layers: [Layer] + + public struct Layer: Codable, Equatable, Sendable, Identifiable { + public var id: Int { number } + public let number: Int + public let title: String? + public let isDraft: Bool? + public let headSha: String? + public let headBranch: String + public let state: PullRequestState + } + + /// Only the reviewed open layers travel; the server revalidates each revision before writing. + public func affectedLayers(number: Int, action: String) -> [Layer] { + guard let index = layers.firstIndex(where: { $0.number == number }) else { return [] } + return (action == "merge" ? Array(layers.prefix(index + 1)) : layers).filter { $0.state != .merged } + } +} + +public struct NativePullRequestCapabilities: Codable, Equatable, Sendable { + public let actions: [String] + public let mergeMethods: [String] + public let updateMethods: [String]? +} + +public struct NativePullRequestViewerPermissions: Codable, Equatable, Sendable { + public let actions: [String] + public let updateMethods: [String]? +} diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index d964d89e5..a58be442c 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -742,6 +742,28 @@ public actor T3Client { ) } + public func pullRequestStack(projectID: String, repository: String, number: Int) async throws -> PullRequestStack? { + try await rpc.request("pullRequests.stack", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + ]), as: Optional.self) + } + + public func runPullRequestStackAction(projectID: String, repository: String, number: Int, + stack: PullRequestStack, action: String, mergeMethod: String?) async throws { + let heads = stack.affectedLayers(number: number, action: action) + guard !heads.isEmpty, heads.allSatisfy({ $0.headSha != nil }) else { + throw RPCError.remote("Refresh the stack before performing this action.") + } + var fields: [String: JSONValue] = [ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "stackNumber": .number(Double(stack.number)), "action": .string(action), + "expectedStackHeads": .array(heads.map { .object(["number": .number(Double($0.number)), "headSha": .string($0.headSha!)]) }), + ] + if let mergeMethod { fields["mergeMethod"] = .string(mergeMethod) } + if action == "update-branch" { fields["updateMethod"] = .string("rebase") } + let _: JSONValue = try await rpc.request("pullRequests.runAction", payload: .object(fields), as: JSONValue.self) + } + public func pullRequestActivity( projectID: String, repository: String, diff --git a/apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift b/apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift new file mode 100644 index 000000000..330eda4bf --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A primary badge may only become terminal after every linked request has answered as terminal. +enum FeatureLinkedPullRequestSettlement { + static func aggregate(_ reads: [FeaturePullRequest?]) -> FeaturePullRequest? { + let known = reads.compactMap { $0 } + guard var result = known.first(where: { $0.state == "open" }) ?? known.first else { return nil } + if result.state == "open" { return result } + guard known.count == reads.count, + known.allSatisfy({ $0.state == "closed" || $0.state == "merged" }) else { + result.state = "unknown" + return result + } + // If merge settlement is disabled, a mixed closed/merged collection must stay active. + result.state = known.contains(where: { $0.state == "merged" }) ? "merged" : "closed" + result.updatedAt = known.compactMap(\.updatedAt).max() + return result + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownMediaView.swift b/apps/swift-ios/Features/Chat/MarkdownMediaView.swift index ff0eb79ca..ab2f6536a 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMediaView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMediaView.swift @@ -340,9 +340,7 @@ struct FeatureImagePreviewSheet: View { AsyncImage(url: url) { phase in switch phase { case let .success(image): - image - .resizable() - .scaledToFit() + ZoomableMessageImage(image: image) case .failure: ContentUnavailableView( "Image unavailable", @@ -403,7 +401,7 @@ struct MarkdownGallerySheet: View { } else if let url = urls[page] { AsyncImage(url: url) { phase in switch phase { - case let .success(image): image.resizable().scaledToFit() + case let .success(image): ZoomableMessageImage(image: image, isCurrentPage: page == index) case .failure: ContentUnavailableView("Image unavailable", systemImage: "photo") default: ProgressView() } diff --git a/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift b/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift index 129ffcc50..b4f6d4513 100644 --- a/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift +++ b/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift @@ -1,17 +1,18 @@ import SwiftUI -// A read-only, native view of one change request: the summary the host's page -// leads with, and the conversation-plus-commits chronology under it. Opened -// from the thread details sheet's Version Control section; anything beyond -// reading — reviews, merges, comments — stays in the browser, one tap away. -// -// Every rule lives in PullRequestDetailSections.swift; this file is the view. +// Native PR details and reviewed, remote-only GitHub stack actions. struct PullRequestDetailSheet: View { let client: any FeatureClient let threadID: String let number: Int + @State private var selectedNumber: Int? + @State private var stack: PullRequestStack? + @State private var stackError: String? + @State private var pendingStackAction: NativeStackAction? + private var displayedNumber: Int { selectedNumber ?? number } + @State private var overview: FeaturePullRequestOverview? @State private var loadError: String? @State private var tab: PullRequestDetailTab = .summary @@ -29,7 +30,7 @@ struct PullRequestDetailSheet: View { } } .background(T3Colors.background) - .navigationTitle("Pull Request #\(number)") + .navigationTitle("Pull Request #\(displayedNumber)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -43,15 +44,35 @@ struct PullRequestDetailSheet: View { } } } - .task { await load() } + .task(id: displayedNumber) { await load() } + .sheet(item: $pendingStackAction, onDismiss: { Task { await load() } }) { request in + PullRequestStackActionSheet(request: request, client: client, threadID: threadID) { + pendingStackAction = nil + } + } .accessibilityIdentifier("pull-request-detail-sheet") } private func load() async { + let requestedNumber = displayedNumber loadError = nil + overview = nil + stack = nil + stackError = nil do { - overview = try await client.pullRequestOverview(threadID: threadID, number: number) + let result = try await client.pullRequestOverview(threadID: threadID, number: requestedNumber) + guard !Task.isCancelled, displayedNumber == requestedNumber else { return } + overview = result + do { + let loadedStack = try await client.pullRequestStack(threadID: threadID, number: requestedNumber) + guard !Task.isCancelled, displayedNumber == requestedNumber else { return } + stack = loadedStack + } catch { + guard !Task.isCancelled, displayedNumber == requestedNumber else { return } + stackError = error.localizedDescription + } } catch { + guard !Task.isCancelled, displayedNumber == requestedNumber else { return } loadError = error.localizedDescription } } @@ -77,6 +98,11 @@ struct PullRequestDetailSheet: View { ScrollView { VStack(alignment: .leading, spacing: 16) { header(overview.detail) + if let stack { stackSection(stack, detail: overview.detail) } + if let stackError { + Text("Could not load stack: \(stackError)").font(T3Typography.supporting).foregroundStyle(T3Colors.warning) + Button("Retry stack") { Task { await load() } } + } Picker("Section", selection: $tab) { ForEach(PullRequestDetailTab.allCases, id: \.self) { tab in @@ -99,6 +125,28 @@ struct PullRequestDetailSheet: View { .scrollIndicators(.hidden) } + private func stackSection(_ stack: PullRequestStack, detail: PullRequestDetail) -> some View { + ThreadDetailsSection(title: "Stack · \(stack.layers.count) layers", footer: "Layers run from base to top. Actions update GitHub without changing your checkout.") { + ForEach(stack.layers) { layer in + ThreadDetailsRow(systemImage: layer.number == displayedNumber ? "checkmark.circle.fill" : "arrow.triangle.pull", + title: "#\(layer.number) \(layer.title ?? layer.headBranch)", subtitle: layer.state.rawValue.capitalized, + showsChevron: layer.number != displayedNumber, action: { selectedNumber = layer.number }) + } + if detail.state == .open, let capabilities = detail.capabilities, let viewer = detail.viewerPermissions { + if capabilities.actions.contains("merge"), viewer.actions.contains("merge") { + ThreadDetailsRow(systemImage: "arrow.triangle.merge", title: "Review merge through #\(displayedNumber)…", + action: { pendingStackAction = NativeStackAction(stack: stack, number: displayedNumber, action: "merge", mergeMethods: capabilities.mergeMethods) }) + } + if stack.layers.last?.number == displayedNumber, + capabilities.actions.contains("update-branch"), viewer.actions.contains("update-branch"), + capabilities.updateMethods?.contains("rebase") == true, viewer.updateMethods?.contains("rebase") == true { + ThreadDetailsRow(systemImage: "arrow.triangle.branch", title: "Review stack rebase…", + action: { pendingStackAction = NativeStackAction(stack: stack, number: displayedNumber, action: "update-branch", mergeMethods: []) }) + } + } + } + } + private func header(_ detail: PullRequestDetail) -> some View { VStack(alignment: .leading, spacing: 8) { Text("#\(detail.number) \(detail.title)") diff --git a/apps/swift-ios/Features/Chat/PullRequestStackActionSheet.swift b/apps/swift-ios/Features/Chat/PullRequestStackActionSheet.swift new file mode 100644 index 000000000..39c5762bb --- /dev/null +++ b/apps/swift-ios/Features/Chat/PullRequestStackActionSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct NativeStackAction: Identifiable { + let id = UUID() + let stack: PullRequestStack + let number: Int + let action: String + let mergeMethods: [String] + var layers: [PullRequestStack.Layer] { stack.affectedLayers(number: number, action: action) } +} + +/// Holds the exact stack the reader reviewed; a refresh must never alter an armed operation. +struct PullRequestStackActionSheet: View { + let request: NativeStackAction + let client: any FeatureClient + let threadID: String + let onFinished: () -> Void + @State private var method = "" + @State private var isBusy = false + @State private var errorMessage: String? + + private var isMerge: Bool { request.action == "merge" } + private var ready: Bool { + !request.layers.isEmpty && request.layers.allSatisfy { $0.state == .open && $0.headSha != nil } + && (!isMerge || request.mergeMethods.contains(method)) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(isMerge ? "Merge through #\(request.number)" : "Rebase stack") + .font(T3Typography.threadHeading2) + Text("These layers will be updated on GitHub. Your local checkout stays unchanged.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + ThreadDetailsSection(title: "Affected layers") { + ForEach(request.layers) { layer in + ThreadDetailsRow(systemImage: "arrow.triangle.pull", title: "#\(layer.number) \(layer.title ?? layer.headBranch)", + subtitle: String((layer.headSha ?? "Revision unavailable").prefix(12)), showsChevron: false) + } + } + if isMerge { + Picker("Merge strategy", selection: $method) { + ForEach(request.mergeMethods, id: \.self) { Text($0.capitalized).tag($0) } + } + } + if let errorMessage { + Text(errorMessage).foregroundStyle(T3Colors.danger) + Text("Earlier completed updates remain on GitHub. Close this sheet to refresh before another attempt.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + if !ready { + Text("Refresh the stack to load every open layer’s revision before continuing.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.warning) + } + SettingsActionButton(title: isMerge ? "Merge reviewed layers" : "Rebase reviewed layers", + systemImage: "arrow.triangle.merge", tone: .primary, isBusy: isBusy, + isDisabled: !ready || errorMessage != nil, action: perform) + }.padding(16) + } + .background(T3Colors.background) + .navigationTitle("Confirm stack action") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close", action: onFinished).disabled(isBusy) } } + .t3NavigationChrome() + } + .interactiveDismissDisabled(isBusy) + .onAppear { method = request.mergeMethods.first ?? "" } + } + + private func perform() { + guard ready, !isBusy, errorMessage == nil else { return } + isBusy = true + Task { @MainActor in + do { + try await client.runPullRequestStackAction(threadID: threadID, number: request.number, + stack: request.stack, action: request.action, mergeMethod: isMerge ? method : nil) + onFinished() + } catch { errorMessage = error.localizedDescription } + isBusy = false + } + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift b/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift index ba384123e..bf4003226 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift @@ -496,10 +496,8 @@ struct ThreadDetailsSheet: View { ThreadDetailsDivider() ThreadDetailsRow( systemImage: "link", - title: "Linked pull request", - subtitle: ThreadDetailsGit.linkedPullRequestSubtitle( - thread.linkedPullRequest - ), + title: thread.supportsMultiplePullRequests == true ? "Linked pull requests" : "Linked pull request", + subtitle: thread.allLinkedPullRequests.isEmpty ? "None" : thread.allLinkedPullRequests.map { "#\($0.number)" }.joined(separator: ", "), action: { isEditingLinkedPullRequest = true } ) } diff --git a/apps/swift-ios/Features/Chat/ThreadLinkedPullRequestSheet.swift b/apps/swift-ios/Features/Chat/ThreadLinkedPullRequestSheet.swift index 06becbb20..36341cff7 100644 --- a/apps/swift-ios/Features/Chat/ThreadLinkedPullRequestSheet.swift +++ b/apps/swift-ios/Features/Chat/ThreadLinkedPullRequestSheet.swift @@ -24,11 +24,13 @@ struct ThreadLinkedPullRequestSheet: View { @FocusState private var isFieldFocused: Bool private var linked: FeatureLinkedPullRequest? { thread.linkedPullRequest } + private var links: [FeatureLinkedPullRequest] { thread.allLinkedPullRequests } + @State private var selectedLink: FeatureLinkedPullRequest? /// Hidden when the branch's request is already the linked one: "Link #12" /// under a row that says #12 is linked reads as a bug. private var linkableBranchPullRequest: ThreadDetailsPullRequest? { - guard let branchPullRequest, branchPullRequest.number != linked?.number else { return nil } + guard let branchPullRequest, !links.contains(where: { $0.number == branchPullRequest.number }) else { return nil } return branchPullRequest } @@ -37,30 +39,16 @@ struct ThreadLinkedPullRequestSheet: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { - if let linked { - ThreadDetailsSection( - title: "Linked", - footer: """ - This thread follows pull request #\(linked.number) instead of whatever \ - its branch points at. - """ - ) { - ThreadDetailsRow( - systemImage: "arrow.triangle.pull", - title: "#\(linked.number)", - subtitle: linked.repository, - showsChevron: false - ) - ThreadDetailsDivider() - ThreadDetailsRow( - systemImage: "link.badge.plus", - iconTint: T3Colors.danger, - title: "Unlink", - subtitle: "Go back to following the branch", - isDisabled: isBusy, - showsChevron: false, - action: { commit(number: nil) } - ) + if !links.isEmpty { + ThreadDetailsSection(title: "Linked pull requests", footer: "The task stays active while any linked pull request is open.") { + ForEach(links, id: \.self) { link in + ThreadDetailsRow(systemImage: "arrow.triangle.pull", title: "#\(link.number)", subtitle: link.repository, + action: { selectedLink = link }) + ThreadDetailsRow(systemImage: "link.badge.plus", iconTint: T3Colors.danger, + title: "Unlink #\(link.number)", isDisabled: isBusy, showsChevron: false, + action: { unlink(link) }) + if link != links.last { ThreadDetailsDivider() } + } } } @@ -78,7 +66,7 @@ struct ThreadLinkedPullRequestSheet: View { } ThreadDetailsSection( - title: linked == nil ? "Link a pull request" : "Link a different one", + title: thread.supportsMultiplePullRequests == true ? "Add a pull request" : (linked == nil ? "Link a pull request" : "Link a different one"), footer: """ Enter a number or paste a pull request URL. It has to belong to this \ thread's project. @@ -119,7 +107,10 @@ struct ThreadLinkedPullRequestSheet: View { .scrollDismissesKeyboard(.interactively) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(T3Colors.background) - .navigationTitle("Pull request") + .navigationTitle("Pull requests") + .navigationDestination(item: $selectedLink) { link in + PullRequestDetailSheet(client: client, threadID: thread.id, number: link.number) + } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -129,6 +120,17 @@ struct ThreadLinkedPullRequestSheet: View { } } + private func unlink(_ link: FeatureLinkedPullRequest) { + guard !isBusy else { return } + if thread.supportsMultiplePullRequests != true { commit(number: nil); return } + isBusy = true; errorMessage = nil + Task { @MainActor in + do { try await client.removeThreadPullRequest(threadID: thread.id, link: link); onFinished() } + catch { errorMessage = error.localizedDescription } + isBusy = false + } + } + /// `nil` unlinks. Either way the sheet closes on success and stays open on /// failure, because the failure is about the value still in the field. private func commit(number: Int?) { @@ -137,10 +139,11 @@ struct ThreadLinkedPullRequestSheet: View { errorMessage = nil Task { @MainActor in do { - _ = try await client.setThreadLinkedPullRequest( - threadID: thread.id, - number: number - ) + if thread.supportsMultiplePullRequests == true, let number { + _ = try await client.addThreadPullRequest(threadID: thread.id, number: number) + } else { + _ = try await client.setThreadLinkedPullRequest(threadID: thread.id, number: number) + } onFinished() } catch { errorMessage = error.localizedDescription diff --git a/apps/swift-ios/Features/Chat/ZoomableMessageImage.swift b/apps/swift-ios/Features/Chat/ZoomableMessageImage.swift new file mode 100644 index 000000000..3cacc6b6f --- /dev/null +++ b/apps/swift-ios/Features/Chat/ZoomableMessageImage.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// Keeps zoom inside the current image; fitted pages leave swipes to the gallery. +struct ZoomableMessageImage: View { + let image: Image + var isCurrentPage = true + @State private var scale: CGFloat = 1 + @State private var settledScale: CGFloat = 1 + @State private var offset: CGSize = .zero + @State private var settledOffset: CGSize = .zero + + var body: some View { + GeometryReader { geometry in + image.resizable().scaledToFit() + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(scale).offset(offset) + .contentShape(Rectangle()) + .gesture(MagnifyGesture().onChanged { value in + scale = min(8, max(1, settledScale * value.magnification)) + offset = bounded(offset, in: geometry.size) + }.onEnded { _ in + settledScale = scale + settledOffset = offset + }) + .highPriorityGesture(DragGesture().onChanged { value in + offset = bounded(CGSize(width: settledOffset.width + value.translation.width, + height: settledOffset.height + value.translation.height), + in: geometry.size) + }.onEnded { _ in settledOffset = offset }, including: scale > 1 ? .all : .none) + .onTapGesture(count: 2) { + if scale > 1 { reset() } else { scale = 2; settledScale = 2 } + } + .accessibilityLabel("Image") + .accessibilityValue("\(Int(scale * 100)) percent zoom") + .accessibilityAdjustableAction { direction in + scale = min(8, max(1, scale + (direction == .increment ? 1 : -1))) + settledScale = scale + offset = bounded(offset, in: geometry.size) + settledOffset = offset + } + .accessibilityAction(named: "Fit image", reset) + } + .clipped() + .overlay(alignment: .topTrailing) { + Button("Fit image", systemImage: "arrow.down.right.and.arrow.up.left") { reset() } + .font(T3Typography.supportingStrong) + .padding(10) + .background(T3Colors.surface, in: Capsule()) + .padding(8) + .opacity(scale > 1 ? 1 : 0) + .allowsHitTesting(scale > 1) + .accessibilityHidden(scale <= 1) + } + .onChange(of: isCurrentPage) { reset() } + } + + private func bounded(_ value: CGSize, in size: CGSize) -> CGSize { + CGSize(width: min(size.width * (scale - 1) / 2, max(-size.width * (scale - 1) / 2, value.width)), + height: min(size.height * (scale - 1) / 2, max(-size.height * (scale - 1) / 2, value.height))) + } + + private func reset() { + scale = 1; settledScale = 1; offset = .zero; settledOffset = .zero + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index c3560828e..11b45ce42 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -65,6 +65,7 @@ public final class FeatureRootModel { private var outboxRetryAttempt = 0 private var outboxGeneration: UInt64 = 0 private var changeRequestThreadIDs: [String] = [] + private var changeRequestLinks: [String: [FeatureLinkedPullRequest]] = [:] private var changeRequestTask: Task? public init( @@ -96,12 +97,15 @@ public final class FeatureRootModel { /// showing. Safe to call whenever that list is rebuilt: an unchanged set of /// threads keeps the existing subscriptions rather than restarting them. public func observeChangeRequests(threadIDs: [String]) { - guard threadIDs != changeRequestThreadIDs else { return } + let observed = Set(threadIDs) + let links = Dictionary(uniqueKeysWithValues: snapshot.threads.filter { observed.contains($0.id) }.map { ($0.id, $0.allLinkedPullRequests) }) + guard threadIDs != changeRequestThreadIDs || links != changeRequestLinks else { return } + let previousLinks = changeRequestLinks + changeRequestLinks = links changeRequestThreadIDs = threadIDs changeRequestTask?.cancel() - let observed = Set(threadIDs) - changeRequestsByThreadID = changeRequestsByThreadID.filter { observed.contains($0.key) } + changeRequestsByThreadID = changeRequestsByThreadID.filter { observed.contains($0.key) && previousLinks[$0.key] == links[$0.key] } guard !threadIDs.isEmpty else { changeRequestTask = nil return @@ -119,7 +123,7 @@ public final class FeatureRootModel { ) { // A cancelled stream can still hold one last emission; applying // it would overwrite the replacement stream's fresher state. - if Task.isCancelled || self.changeRequestThreadIDs != threadIDs { return } + if Task.isCancelled || self.changeRequestThreadIDs != threadIDs || self.changeRequestLinks != links { return } self.changeRequestsByThreadID = pullRequests } } @@ -680,6 +684,7 @@ public final class FeatureRootModel { } threadCollectionRevision &+= 1 homePresentationRevision &+= 1 + observeChangeRequests(threadIDs: changeRequestThreadIDs) } private func removeThread(id: String) { @@ -722,6 +727,7 @@ public final class FeatureRootModel { threadCollectionRevision &+= 1 } snapshot = value + observeChangeRequests(threadIDs: changeRequestThreadIDs) if value.connection.state == .connected || value.environments.contains(where: { $0.connectionState == .connected }) { scheduleOutboxDrain() diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index b359957f5..56d99e929 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -72,6 +72,10 @@ public protocol FeatureClient: AnyObject { threadID: String, number: Int? ) async throws -> FeatureLinkedPullRequest? + func addThreadPullRequest(threadID: String, number: Int) async throws -> FeatureLinkedPullRequest? + func removeThreadPullRequest(threadID: String, link: FeatureLinkedPullRequest) async throws + func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? + func runPullRequestStackAction(threadID: String, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws /// Persists the model and its options (effort, context window) on the @@ -256,6 +260,10 @@ public extension FeatureClient { ) async throws -> FeatureLinkedPullRequest? { throw FeatureCapabilityUnavailable("Pull request linking") } + func addThreadPullRequest(threadID: String, number: Int) async throws -> FeatureLinkedPullRequest? { throw FeatureCapabilityUnavailable("Multiple pull requests") } + func removeThreadPullRequest(threadID: String, link: FeatureLinkedPullRequest) async throws { throw FeatureCapabilityUnavailable("Multiple pull requests") } + func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? { nil } + func runPullRequestStackAction(threadID: String, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws { throw FeatureCapabilityUnavailable("Stack actions") } func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws {} func setModelSelection(id: String, selection: FeatureSelection) async throws {} diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index fe9a2e908..6297e5478 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -270,6 +270,13 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl /// branch-derived one: the row shows it, and it is what the merge settle /// rule watches. public var linkedPullRequest: FeatureLinkedPullRequest? + public var linkedPullRequests: [FeatureLinkedPullRequest]? = nil + public var supportsMultiplePullRequests: Bool? = nil + public var supportsPullRequestStackActions: Bool? = nil + + public var allLinkedPullRequests: [FeatureLinkedPullRequest] { + linkedPullRequests ?? linkedPullRequest.map { [$0] } ?? [] + } /// Whether this thread's server persists a pull-request link. Resolved from /// the environment's capabilities at map time, so the action is hidden /// rather than offered and refused. @@ -325,6 +332,9 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl isRegeneratingTitle: Bool = false, supportsTitleRegeneration: Bool? = nil, linkedPullRequest: FeatureLinkedPullRequest? = nil, + linkedPullRequests: [FeatureLinkedPullRequest]? = nil, + supportsMultiplePullRequests: Bool? = nil, + supportsPullRequestStackActions: Bool? = nil, supportsPullRequestLinking: Bool? = nil, attentionAt: Date? = nil, workingStartedAt: Date? = nil, @@ -372,6 +382,9 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.isRegeneratingTitle = isRegeneratingTitle self.supportsTitleRegeneration = supportsTitleRegeneration self.linkedPullRequest = linkedPullRequest + self.linkedPullRequests = linkedPullRequests + self.supportsMultiplePullRequests = supportsMultiplePullRequests + self.supportsPullRequestStackActions = supportsPullRequestStackActions self.supportsPullRequestLinking = supportsPullRequestLinking self.attentionAt = attentionAt self.workingStartedAt = workingStartedAt diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 2b4388f5c..066a6cea3 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -344,12 +344,13 @@ struct DailyUXSidebarIndex { } return candidates.filter { thread in let project = projectByID[thread.projectID] - return [ + return ([ thread.title, thread.preview ?? "", project?.name ?? "", project?.path ?? "", - ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + ] + thread.allLinkedPullRequests.flatMap { ["#\($0.number)", "\($0.repository)#\($0.number)", $0.url] }) + .contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } } } } @@ -402,6 +403,7 @@ enum DailyUXSidebarRefresh { // ordinary inactivity clock. !thread.changeRequestAutoSettles(changeRequest), changeRequest?.state != "open", + !thread.hasUnresolvedLinkedPullRequests(changeRequest), let autoSettleAfterDays = thread.autoSettleAfterDays, let lastActivityAt = thread.lastActivityAt else { return nil @@ -602,6 +604,11 @@ extension FeatureThread { return max(createdAt, latestUserActivityAt) } + /// A missing aggregate must not let the inactivity timer hide unresolved linked work. + func hasUnresolvedLinkedPullRequests(_ changeRequest: FeaturePullRequest?) -> Bool { + allLinkedPullRequests.count > 1 && !["open", "closed", "merged"].contains(changeRequest?.state ?? "unknown") + } + /// Swift port of `changeRequestAutoSettles` in /// `packages/client-runtime/src/state/threadSettled.ts`. /// @@ -641,6 +648,7 @@ extension FeatureThread { if keepsActive { return false } + if hasUnresolvedLinkedPullRequests(changeRequest) { return false } if changeRequestAutoSettles(changeRequest) { return true } diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 01003412f..09cb8c07a 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -719,7 +719,7 @@ public struct WorkspaceView: View { Image(systemName: "magnifyingglass") .font(.system(size: 14, weight: .medium)) .foregroundStyle(T3Colors.textTertiary) - TextField("Search tasks and projects", text: $searchText) + TextField("Search tasks, projects and PRs", text: $searchText) .font(.subheadline) .foregroundStyle(T3Colors.textPrimary) .focused($isSearchFocused) diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json b/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json index 8281a9662..b751807c3 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json @@ -14,6 +14,26 @@ "interactionMode": "default", "branch": null, "worktreePath": null, + "linkedPullRequest": { + "projectId": "project-v2", + "repository": "example/repo", + "number": 41, + "url": "https://github.com/example/repo/pull/41" + }, + "linkedPullRequests": [ + { + "projectId": "project-v2", + "repository": "example/repo", + "number": 41, + "url": "https://github.com/example/repo/pull/41" + }, + { + "projectId": "project-v2", + "repository": "example/repo", + "number": 42, + "url": "https://github.com/example/repo/pull/42" + } + ], "activeProviderThreadId": "provider-thread-1", "lineage": { "parentThreadId": null, diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestStack.json b/apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestStack.json new file mode 100644 index 000000000..a437ac8ac --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestStack.json @@ -0,0 +1,27 @@ +{ + "id": "stack-1", + "number": 1, + "url": "https://github.com/o/r/stack/1", + "base": "main", + "layers": [ + { + "number": 1, + "headBranch": "one", + "state": "merged" + }, + { + "number": 2, + "title": "Second layer", + "isDraft": false, + "headSha": "abc", + "headBranch": "two", + "state": "open" + }, + { + "number": 3, + "headSha": "def", + "headBranch": "three", + "state": "open" + } + ] +} diff --git a/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift b/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift index 249ba26e6..b46dfca4b 100644 --- a/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift +++ b/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift @@ -44,6 +44,8 @@ final class OrchestrationV2ContractTests: XCTestCase { func testNativeParityFieldsDecodeFromServerContract() throws { let projection = try projection() XCTAssertEqual(projection.thread.activeOrderKey, "n") + XCTAssertEqual(projection.thread.linkedPullRequests?.map(\.number), [41, 42]) + XCTAssertEqual(projection.thread.linkedPullRequest?.number, 41) XCTAssertEqual(projection.runtimeRequests.first?.responseMode, "message") let dismiss = OrchestrationCommands.respondToUserInput(threadID: "t", requestID: "q", answers: [:], dismiss: true) XCTAssertEqual(dismiss["dismiss"], .bool(true)) diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index bd0693fc2..4d950797c 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -4,6 +4,25 @@ import Testing @Suite("Sidebar v2") struct DailyUXSidebarTests { + @Test func unavailableLinkedRequestsBlockInactivitySettlement() { + var item = thread(id: "linked", created: -500, updated: -500) + item.linkedPullRequests = [41, 42].map { FeatureLinkedPullRequest(projectID: item.projectID, repository: "example/repo", number: $0, url: "https://github.com/example/repo/pull/\($0)") } + let later = now.addingTimeInterval(10 * 24 * 60 * 60) + #expect(!item.isEffectivelySettled(at: later)) + #expect(!item.isEffectivelySettled(at: later, changeRequest: FeaturePullRequest(number: 41, title: "Unavailable", state: "unknown"))) + item.isSettled = true + #expect(item.isEffectivelySettled(at: later)) + } + + @Test func searchesEveryLinkedPullRequest() { + var item = thread(id: "linked", created: -100, updated: -50) + item.linkedPullRequests = [41, 42].map { FeatureLinkedPullRequest(projectID: item.projectID, repository: "example/repo", number: $0, url: "https://github.com/example/repo/pull/\($0)") } + for query in ["#41", "#42", "example/repo#42", "https://github.com/example/repo/pull/42"] { + #expect(DailyUXSidebarIndex.matchingThreads([item], snapshot: FeatureSnapshot(threads: [item]), query: query).map(\.id) == ["linked"]) + } + #expect(DailyUXSidebarIndex.matchingThreads([item], snapshot: FeatureSnapshot(threads: [item]), query: "#43").isEmpty) + } + private let now = Date(timeIntervalSince1970: 2_000_000) @Test func manualOrderKeepsNewAndReopenedThreadsFirst() { diff --git a/apps/swift-ios/Tests/FeatureTests/LinkedPullRequestSettlementTests.swift b/apps/swift-ios/Tests/FeatureTests/LinkedPullRequestSettlementTests.swift new file mode 100644 index 000000000..4bf51cc00 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/LinkedPullRequestSettlementTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Linked pull request settlement") +struct LinkedPullRequestSettlementTests { + private func pr(_ number: Int, _ state: String) -> FeaturePullRequest { + FeaturePullRequest(number: number, title: "PR", state: state, updatedAt: Date(timeIntervalSince1970: Double(number))) + } + + @Test func anOpenLinkKeepsTheCollectionOpen() { + #expect(FeatureLinkedPullRequestSettlement.aggregate([pr(1, "merged"), pr(2, "open")])?.state == "open") + } + + @Test func failedReadsCannotSettleACollection() { + #expect(FeatureLinkedPullRequestSettlement.aggregate([pr(1, "merged"), nil])?.state == "unknown") + #expect(FeatureLinkedPullRequestSettlement.aggregate([nil, nil]) == nil) + #expect(FeatureLinkedPullRequestSettlement.aggregate([pr(1, "closed"), pr(2, "unknown")])?.state == "unknown") + } + + @Test func allTerminalLinksUseTheLatestTimestampAndRespectMergePreference() { + let result = FeatureLinkedPullRequestSettlement.aggregate([pr(1, "merged"), pr(2, "closed")]) + #expect(result?.state == "merged") + #expect(result?.updatedAt == Date(timeIntervalSince1970: 2)) + #expect(FeatureLinkedPullRequestSettlement.aggregate([pr(1, "closed"), pr(2, "closed")])?.state == "closed") + } + + @Test func stackActionsUseExactlyTheReviewedAffectedLayers() throws { + let fixture = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("CoreTests/Fixtures/pullRequestStack.json") + let stack = try JSONDecoder().decode(PullRequestStack.self, from: Data(contentsOf: fixture)) + #expect(stack.affectedLayers(number: 2, action: "merge").map(\.number) == [2]) + #expect(stack.affectedLayers(number: 3, action: "update-branch").map(\.number) == [2, 3]) + #expect(stack.affectedLayers(number: 99, action: "merge").isEmpty) + } +} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49a6c322e..d75785002 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -182,6 +182,7 @@ export type BuildThreadActionItemsThread = Pick< updatedAt: string; latestUserMessageAt?: string | null; linkedPullRequest?: SidebarThreadSummary["linkedPullRequest"]; + linkedPullRequests?: SidebarThreadSummary["linkedPullRequests"]; }; export function buildThreadActionItems(input: { diff --git a/docs/internals/thread-pull-requests.md b/docs/internals/thread-pull-requests.md new file mode 100644 index 000000000..b0fbfca9d --- /dev/null +++ b/docs/internals/thread-pull-requests.md @@ -0,0 +1,28 @@ +# V2 thread pull requests + +The V2 JSON thread projection optionally carries `linkedPullRequests`. An absent +collection falls back to `linkedPullRequest`; an empty collection is explicit. +The primary field mirrors the first collection entry for older clients. + +`thread.metadata.update` accepts one of `linkPullRequest`, `unlinkPullRequest`, +or the legacy `linkedPullRequest` edit. The orchestrator applies the edit to the +latest projection under its existing dispatch serialization. Links deduplicate +by URL host, repository and number, with at most 50 entries. A legacy replacement +or removal changes only its primary entry, retaining additional links. + +Swift gates collection editing on `threadPullRequestsV2` and polls every link for +visible tasks. Its aggregate remains nonterminal when any request is open or a +read fails. Changing the collection restarts subscriptions and drops the prior +aggregate seed. Web/Expo continue showing the primary; their shared settlement +helper declines primary-only automatic settlement for a collection. + +`pullRequests.stack` reads a GitHub stack on demand. Other providers return null. +`pullRequestStackActions` gates the native UI. `pullRequests.runAction` accepts +`stackNumber` and `expectedStackHeads`; the GitHub boundary re-reads membership, +checks revisions and branch permissions, and performs remote-only mutations. +All reviewed references are invalidated even on partial failure. Native action +confirmation holds an immutable reviewed stack and refreshes after dismissal. + +This does not implement upstream's automatic PR discovery/linking, stack link +tombstones, MCP linking tools, or restart-persistent summary cache. It adds no +SQLite migration or V1 thread-runtime dependency. diff --git a/docs/user/chat-formatting.md b/docs/user/chat-formatting.md index 098188a89..c9bf94703 100644 --- a/docs/user/chat-formatting.md +++ b/docs/user/chat-formatting.md @@ -30,3 +30,10 @@ open local files. A preview is separate from a saved file, and showing one does not mean the assistant has tested the saved result. These instructions guide future answers; they do not rewrite earlier messages. + +## Inspecting images on iOS + +Open an image to view it full screen. Pinch to zoom, drag to pan while zoomed, +or double-tap to toggle zoom. Fit image restores the full image. At its fitted +size, swipe between images in the message's gallery. VoiceOver offers zoom +adjustments and a Fit image action. Saving or sharing keeps the original image. diff --git a/docs/user/linked-pull-requests.md b/docs/user/linked-pull-requests.md new file mode 100644 index 000000000..a40c4117f --- /dev/null +++ b/docs/user/linked-pull-requests.md @@ -0,0 +1,21 @@ +# Linked pull requests on iOS + +Open a task's Details, then Version Control → Linked pull requests. Add a PR number +or paste its URL to link a request from the task's repository. Add more requests +in the same sheet, tap one to read it, or unlink a request without closing it on +the host. Search tasks by PR number (`#42`), repository, or PR URL. + +A task with multiple links stays active while any linked request is open or its +status is unavailable. Automatic settlement still respects your settle-on-merge +preference. Older servers offer one linked request instead. + +For a GitHub PR that belongs to a stack, its detail screen lists the layers from +base to top. Tap a layer to read it. If your account has permission, review a +merge through the selected layer or a rebase from the top layer. The confirmation +lists the affected revisions before you submit. These operations update GitHub; +they do not switch or rewrite your local checkout. + +If a stack changed since you reviewed it, refresh before retrying. A rebase can +stop after updating earlier layers; those completed updates remain on GitHub. +If GitHub reports that a merge is still running, check its status before submitting +another request. Stack controls require a server and host that support them. diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 9caed8567..61c8819ba 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -80,6 +80,7 @@ export interface EnvironmentThreadShell { readonly worktreePath: string | null; /** Pull request a user pinned to this thread; null when nothing is linked. */ readonly linkedPullRequest: ThreadLinkedPullRequest | null; + readonly linkedPullRequests?: readonly ThreadLinkedPullRequest[] | undefined; readonly lineage: OrchestrationV2ThreadShell["lineage"]; readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; @@ -231,6 +232,9 @@ export function presentThreadShell( branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, + ...(thread.linkedPullRequests === undefined + ? {} + : { linkedPullRequests: thread.linkedPullRequests }), lineage: thread.lineage, forkedFrom: thread.forkedFrom, activeProviderThreadId: thread.activeProviderThreadId, diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 7ee7ba1ca..b06b82b3f 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -17,6 +17,11 @@ const FRESH = "2026-04-09T00:00:00.000Z"; const STALE = "2026-04-06T23:59:59.999Z"; describe("changeRequestAutoSettles", () => { + it("does not settle a collection from a legacy primary-only status read", () => { + expect( + changeRequestAutoSettles({ state: "merged" }, { thread: { linkedPullRequests: [{}, {}] } }), + ).toBe(false); + }); it.each([ ["open", true, false], ["merged", true, true], @@ -193,6 +198,16 @@ describe("threadLastActivityAt", () => { }); describe("effectiveSettled", () => { + it("blocks inactivity settlement for collections but honors explicit settlement", () => { + const shell = { ...makeShell({ activityAt: STALE }), linkedPullRequests: [{}, {}] }; + const options = { + now: NOW, + autoSettleAfterDays: 1, + changeRequest: { state: "merged" as const }, + }; + expect(effectiveSettled(shell, options)).toBe(false); + expect(effectiveSettled({ ...shell, settledOverride: "settled" }, options)).toBe(true); + }); const overrideCases = [null, "settled", "active"] as const; const changeRequestStates = [undefined, "open", "merged"] as const; const inactivityCases = [ diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 06c230486..2cfccaca5 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -25,6 +25,7 @@ interface QueuedThreadShell { } interface SettlementThreadShell extends QueuedThreadShell { + readonly linkedPullRequests?: readonly unknown[] | undefined; readonly settledOverride: "settled" | "active" | null; readonly settledAt: string | null; readonly hasPendingApprovals: boolean; @@ -45,6 +46,7 @@ export interface ChangeRequestSettleSource { /** What the settle rules need to know about the thread's own timeline. */ export interface ThreadActivitySource { + readonly linkedPullRequests?: readonly unknown[] | undefined; readonly createdAt?: string | null; readonly latestUserMessageAt?: string | null; readonly latestRun?: SettlementRunLike | null; @@ -90,6 +92,8 @@ export function changeRequestAutoSettles( } = {}, ): boolean { if (changeRequest == null) return false; + // Legacy clients only read the primary PR; they cannot settle an entire collection from it. + if ((options.thread?.linkedPullRequests?.length ?? 0) > 1) return false; const terminal = changeRequest.state === "closed" || (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); @@ -382,6 +386,8 @@ export function effectiveSettled( // "active" is the explicit keep-active pin: it suppresses auto-settle // until real activity clears it server-side. if (shell.settledOverride === "active") return false; + // Primary-only status cannot establish that every linked request has finished. + if ((shell.linkedPullRequests?.length ?? 0) > 1) return false; if ( changeRequestAutoSettles(options.changeRequest, { autoSettleOnMerge: options.autoSettleOnMerge, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index abb8e9be8..abc153b8d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -90,6 +90,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), /** Server persists a pull request reference on thread.meta.update. */ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), + /** V2 atomic metadata link/unlink operations and a linkedPullRequests collection. */ + threadPullRequestsV2: Schema.optionalKey(Schema.Boolean), + pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 709e40c78..a89a207f2 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -326,6 +326,9 @@ export const OrchestrationV2AppThread = Schema.Struct({ worktreePath: Schema.NullOr(TrimmedNonEmptyString), worktreeStatus: Schema.optional(OrchestrationV2ThreadWorktreeStatus), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + linkedPullRequests: Schema.optional( + Schema.Array(ThreadLinkedPullRequest).check(Schema.isMaxLength(50)), + ), activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), lineage: OrchestrationV2AppThreadLineage, @@ -1638,6 +1641,9 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ worktreePath: Schema.NullOr(TrimmedNonEmptyString), worktreeStatus: Schema.optional(OrchestrationV2ThreadWorktreeStatus), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + linkedPullRequests: Schema.optional( + Schema.Array(ThreadLinkedPullRequest).check(Schema.isMaxLength(50)), + ), lineage: OrchestrationV2AppThreadLineage, forkedFrom: Schema.NullOr(OrchestrationV2AppThread.fields.forkedFrom), activeProviderThreadId: Schema.NullOr(ProviderThreadId), @@ -2453,6 +2459,9 @@ export const OrchestrationV2Command = Schema.Union([ expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Absent leaves the link alone; null unlinks. */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + /** Atomic collection edits; older clients keep using the single-link field. */ + linkPullRequest: Schema.optional(ThreadLinkedPullRequest), + unlinkPullRequest: Schema.optional(ThreadLinkedPullRequest), pinned: Schema.optional(Schema.Boolean), /** Fractional key placing this thread within the pinned run. Sent alone to reorder, or alongside `pinned: true` to place a fresh pin. */ diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1f30803aa..7c2648508 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -801,7 +801,33 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +export const PullRequestStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + base: TrimmedNonEmptyString, + layers: Schema.Array( + Schema.Struct({ + number: PositiveInt, + title: Schema.optional(Schema.String), + isDraft: Schema.optional(Schema.Boolean), + headSha: Schema.optional(TrimmedNonEmptyString), + headBranch: TrimmedNonEmptyString, + state: PullRequestState, + }), + ), +}); +export type PullRequestStack = typeof PullRequestStack.Type; + +export const PullRequestStackHead = Schema.Struct({ + number: PositiveInt, + headSha: TrimmedNonEmptyString, +}); +export type PullRequestStackHead = typeof PullRequestStackHead.Type; + export const PullRequestActionInput = Schema.Struct({ + stackNumber: Schema.optional(PositiveInt), + expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)), ...PullRequestRef.fields, action: PullRequestAction, /** diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e02bbacfc..b1cfe3f8e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,4 @@ +import { PullRequestStack } from "./pullRequest.ts"; import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; @@ -385,6 +386,7 @@ export const WS_METHODS = { pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", pullRequestsDetail: "pullRequests.detail", + pullRequestsStack: "pullRequests.stack", pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", pullRequestsDiffFileContents: "pullRequests.diffFileContents", @@ -614,6 +616,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt error: PullRequestRpcError, }); +export const WsPullRequestsStackRpc = Rpc.make(WS_METHODS.pullRequestsStack, { + payload: PullRequestRef, + success: Schema.NullOr(PullRequestStack), + error: PullRequestRpcError, +}); + export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, @@ -1340,6 +1348,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsDetailRpc, + WsPullRequestsStackRpc, WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, WsPullRequestsDiffFileContentsRpc, diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts new file mode 100644 index 000000000..8102106ad --- /dev/null +++ b/packages/shared/src/threadPullRequests.test.ts @@ -0,0 +1,61 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + linkedPullRequestsOf, + threadPullRequestSearchTerms, + updateLinkedPullRequests, +} from "./threadPullRequests.ts"; +const link = (number: number, host = "github.com") => ({ + projectId: ProjectId.make("p"), + repository: "owner/repo", + number, + url: `https://${host}/owner/repo/pull/${number}`, +}); +describe("V2 linked pull requests", () => { + it("upgrades legacy state and composes successive additions without losing links", () => { + const first = updateLinkedPullRequests( + { linkedPullRequest: link(1) }, + { linkPullRequest: link(2) }, + ); + const second = updateLinkedPullRequests(first, { linkPullRequest: link(3) }); + expect(second.linkedPullRequests.map((x) => x.number)).toEqual([1, 2, 3]); + expect(second.linkedPullRequest).toEqual(link(1)); + }); + it("deduplicates repository identity but keeps hosts distinct", () => { + const initial = updateLinkedPullRequests({}, { linkPullRequest: link(1) }); + const duplicate = updateLinkedPullRequests(initial, { + linkPullRequest: { + ...link(1), + projectId: ProjectId.make("another"), + repository: "OWNER/REPO", + }, + }); + expect(duplicate.linkedPullRequests).toHaveLength(1); + expect( + updateLinkedPullRequests(duplicate, { linkPullRequest: link(1, "github.enterprise") }) + .linkedPullRequests, + ).toHaveLength(2); + }); + it("legacy replacement and unlink preserve additional links", () => { + const initial = { linkedPullRequest: link(1), linkedPullRequests: [link(1), link(2)] }; + expect( + updateLinkedPullRequests(initial, { linkedPullRequest: link(3) }).linkedPullRequests.map( + (x) => x.number, + ), + ).toEqual([3, 2]); + expect( + updateLinkedPullRequests(initial, { linkedPullRequest: null }).linkedPullRequest, + ).toEqual(link(2)); + expect( + updateLinkedPullRequests(initial, { unlinkPullRequest: link(2) }).linkedPullRequests, + ).toEqual([link(1)]); + }); + it("respects an empty collection and searches all links", () => { + expect(linkedPullRequestsOf({ linkedPullRequest: link(1), linkedPullRequests: [] })).toEqual( + [], + ); + expect(threadPullRequestSearchTerms({ linkedPullRequests: [link(1), link(2)] })).toContain( + "owner/repo#2", + ); + }); +}); diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 9f011fa15..4a69c6bdf 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -1,9 +1,66 @@ import type { ThreadLinkedPullRequest } from "@t3tools/contracts"; -/** Search terms from the V2 single-link projection, without another host request. */ -export function threadPullRequestSearchTerms(thread: { +type ThreadLinks = { readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; -}): string[] { - const link = thread.linkedPullRequest; - return link ? [`#${link.number}`, `${link.repository}#${link.number}`, link.url] : []; + readonly linkedPullRequests?: readonly ThreadLinkedPullRequest[] | undefined; +}; + +/** Missing collection denotes an older projection; an empty collection explicitly clears it. */ +export function linkedPullRequestsOf(thread: ThreadLinks): readonly ThreadLinkedPullRequest[] { + return thread.linkedPullRequests ?? (thread.linkedPullRequest ? [thread.linkedPullRequest] : []); +} + +export function linkedPullRequestKey(link: ThreadLinkedPullRequest): string { + let host: string; + try { + host = new URL(link.url).host.toLowerCase(); + } catch { + host = link.projectId; + } + return `${host}/${link.repository.toLowerCase()}#${link.number}`; +} + +/** Updates are applied to the latest durable projection, so concurrent additions compose. */ +export function updateLinkedPullRequests( + thread: ThreadLinks, + command: { + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + readonly linkPullRequest?: ThreadLinkedPullRequest | undefined; + readonly unlinkPullRequest?: ThreadLinkedPullRequest | undefined; + }, +) { + let links = [...linkedPullRequestsOf(thread)]; + if (command.linkedPullRequest !== undefined) { + const previous = thread.linkedPullRequest; + if (previous) + links = links.filter((link) => linkedPullRequestKey(link) !== linkedPullRequestKey(previous)); + if (command.linkedPullRequest) { + links = [ + command.linkedPullRequest, + ...links.filter( + (link) => linkedPullRequestKey(link) !== linkedPullRequestKey(command.linkedPullRequest!), + ), + ]; + } + } + if (command.unlinkPullRequest) { + const key = linkedPullRequestKey(command.unlinkPullRequest); + links = links.filter((link) => linkedPullRequestKey(link) !== key); + } + if (command.linkPullRequest) { + const key = linkedPullRequestKey(command.linkPullRequest); + const existing = links.findIndex((link) => linkedPullRequestKey(link) === key); + if (existing < 0) links.push(command.linkPullRequest); + else links[existing] = command.linkPullRequest; + } + return { linkedPullRequest: links[0] ?? null, linkedPullRequests: links }; +} + +/** Searches every explicit link without another host request. */ +export function threadPullRequestSearchTerms(thread: ThreadLinks): string[] { + return linkedPullRequestsOf(thread).flatMap((link) => [ + `#${link.number}`, + `${link.repository}#${link.number}`, + link.url, + ]); } diff --git a/scripts/generate-swift-contract-fixtures.ts b/scripts/generate-swift-contract-fixtures.ts index ba0dc40e9..c210fc7fc 100644 --- a/scripts/generate-swift-contract-fixtures.ts +++ b/scripts/generate-swift-contract-fixtures.ts @@ -16,6 +16,7 @@ */ import { ServerProviderUsageLimits, + PullRequestStack, CheckpointId, CheckpointScopeId, ContextHandoffId, @@ -286,6 +287,18 @@ const projection = { interactionMode: "default" as const, branch: null, worktreePath: null, + linkedPullRequests: [41, 42].map((number) => ({ + projectId, + repository: "example/repo", + number, + url: `https://github.com/example/repo/pull/${number}`, + })), + linkedPullRequest: { + projectId, + repository: "example/repo", + number: 41, + url: "https://github.com/example/repo/pull/41", + }, activeProviderThreadId: providerThreadId, activeOrderKey: "n", lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, @@ -400,3 +413,35 @@ if (process.argv.includes("--check")) { } else { NodeFS.writeFileSync(limitsPath, limitsSerialized); } + +const stackPath = NodePath.join(NodePath.dirname(outputPath), "pullRequestStack.json"); +const stackSerialized = `${JSON.stringify( + Schema.encodeSync(PullRequestStack)({ + id: "stack-1", + number: 1, + url: "https://github.com/o/r/stack/1", + base: "main", + layers: [ + { number: 1, headBranch: "one", state: "merged" }, + { + number: 2, + headBranch: "two", + title: "Second layer", + isDraft: false, + state: "open", + headSha: "abc", + }, + { number: 3, headBranch: "three", state: "open", headSha: "def" }, + ], + }), + null, + 2, +)}\n`; +if (process.argv.includes("--check")) { + if (!NodeFS.existsSync(stackPath) || NodeFS.readFileSync(stackPath, "utf8") !== stackSerialized) { + console.error("[swift-fixtures] pullRequestStack.json is stale; regenerate fixtures."); + process.exit(1); + } +} else { + NodeFS.writeFileSync(stackPath, stackSerialized); +} From b4231a1e1e0dca55f6ace29dd8e7f583d1b98e9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:45:32 +0200 Subject: [PATCH 03/93] feat: port upstream diff and navigation controls across clients --- PATCH.md | 17 +- .../settings/DesktopClientSettings.test.ts | 1 + apps/desktop/src/updates/DesktopUpdates.ts | 13 +- apps/desktop/src/updates/releaseNotes.test.ts | 157 ++++++++-- apps/desktop/src/updates/releaseNotes.ts | 59 +++- apps/desktop/src/updates/updateMachine.ts | 8 + apps/swift-ios/DesignSystem/T3Theme.swift | 8 + .../swift-ios/DesignSystem/T3ThemeStore.swift | 1 + .../Chat/CheckpointRestoreSheet.swift | 4 +- .../Features/Chat/InlineUnifiedDiff.swift | 8 +- .../Features/Chat/ThreadWorkLog.swift | 4 +- .../Features/Review/FeatureReviewView.swift | 20 +- .../Features/Root/FeatureRootView.swift | 3 + .../Features/Settings/SettingsView.swift | 1 + .../Settings/ThreadAppearanceSection.swift | 12 +- .../Features/Shared/FeatureModels.swift | 11 + .../ThreadAppearanceSettingsTests.swift | 9 + apps/web/src/components/ChatView.tsx | 1 + .../src/components/ComposerPromptEditor.tsx | 80 +++++ .../src/components/DiffFilePathCopyButton.tsx | 41 +++ apps/web/src/components/DiffPanel.tsx | 228 +++++++++----- apps/web/src/components/GitActionsControl.tsx | 12 +- apps/web/src/components/Sidebar.logic.test.ts | 67 ++++ apps/web/src/components/Sidebar.logic.ts | 38 +++ apps/web/src/components/Sidebar.tsx | 237 ++++++++++---- apps/web/src/components/chat/ChatComposer.tsx | 89 ++++++ .../web/src/components/chat/DiffStatLabel.tsx | 4 +- .../chat/composerPromptHistory.test.ts | 214 +++++++++++++ .../components/chat/composerPromptHistory.ts | 212 +++++++++++++ .../components/desktopUpdate.logic.test.ts | 4 +- .../web/src/components/desktopUpdate.logic.ts | 7 +- .../components/desktopUpdate.toast.test.tsx | 4 +- .../src/components/desktopUpdate.toast.tsx | 21 +- .../components/diffs/AnnotatableCodeView.tsx | 12 +- .../web/src/components/diffs/DiffFileTree.tsx | 209 +++++++++++++ .../diffs/diffFileTree.logic.test.ts | 69 ++++ .../components/diffs/diffFileTree.logic.ts | 93 ++++++ .../components/diffs/useCodeViewFileReveal.ts | 28 ++ .../pullRequest/pullRequestPresentation.tsx | 6 +- .../components/settings/SettingsPanels.tsx | 51 +++ .../src/components/settings/settingsSearch.ts | 11 +- .../components/sidebar/SidebarUpdatePill.tsx | 294 ++++++++++-------- .../sidebar/SidebarUpdateReleaseNotes.tsx | 123 ++++++++ .../src/components/ui/anchoredCopyToast.ts | 33 ++ apps/web/src/components/ui/combobox.tsx | 24 +- apps/web/src/index.css | 26 ++ apps/web/src/lib/diffRendering.ts | 24 +- apps/web/src/pierre-tree-theme.ts | 22 ++ apps/web/src/proposedPlan.ts | 4 +- apps/web/src/routes/__root.tsx | 4 + docs/user/chat-formatting.md | 15 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/settings.test.ts | 17 + packages/contracts/src/settings.ts | 6 + 54 files changed, 2286 insertions(+), 384 deletions(-) create mode 100644 apps/web/src/components/DiffFilePathCopyButton.tsx create mode 100644 apps/web/src/components/chat/composerPromptHistory.test.ts create mode 100644 apps/web/src/components/chat/composerPromptHistory.ts create mode 100644 apps/web/src/components/diffs/DiffFileTree.tsx create mode 100644 apps/web/src/components/diffs/diffFileTree.logic.test.ts create mode 100644 apps/web/src/components/diffs/diffFileTree.logic.ts create mode 100644 apps/web/src/components/diffs/useCodeViewFileReveal.ts create mode 100644 apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx create mode 100644 apps/web/src/components/ui/anchoredCopyToast.ts create mode 100644 apps/web/src/pierre-tree-theme.ts diff --git a/PATCH.md b/PATCH.md index d9f89bbd1..49bae35fb 100644 --- a/PATCH.md +++ b/PATCH.md @@ -72,9 +72,8 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera `5e63aea2df`) or its `ProviderInstanceCard` `mode: "list" | "editor"` restructure. The fork keeps `EnvironmentProviderSettings` inline in `SettingsPanels.tsx` with the card's own expand/collapse. - Does not carry upstream's pinned-block drag-to-reorder in the web sidebar (the - `optimisticPinnedOrder` / `handlePinnedDragEnd` block) or its searchable project-filter combobox - (`48c176b3cf`, `filterSidebarProjectScopeItems`): both are written against upstream's sidebar - shell, and the fork keeps its client-local whole-list manual order. Upstream's toggleable unpin + `optimisticPinnedOrder` / `handlePinnedDragEnd` block) because the fork keeps its client-local whole-list manual order. The searchable project-filter + combobox is now ported, including keyboard project settings and query reset on close. Upstream's toggleable unpin confirmation (`22c311ddec`) _is_ carried — the setting, `requestThreadUnpinConfirmation`, and `useThreadActions` come across unchanged, and the fork's `Sidebar.tsx` `toggleThreadPin` gates its `thread.metadata.update` on it rather than upstream's `confirmAndUnpinThread`, which routes @@ -594,3 +593,15 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera exposes a public TestFlight invitation after Apple's initial Beta App Review. The fork does not consume the upstream Expo project's OTA updates; TestFlight distributes signed updates to opted-in testers. + +- Carries upstream diff navigation and appearance controls: a changed-file tree and path copy + buttons in web review, plus red/green or blue/orange diff roles on web/desktop and native Swift. + Native stores the preference in FeatureSettings and applies it through T3ThemeStore; semantic + success/error colors remain independent of the diff palette. +- Web prompt recall is adapted to the fork's unified attachment queue and V2 message projection. + Up/down only recall when the composer has no attached context and its caret is at the visual + edge; generated plan/attachment prompts and appended context are excluded. Native already + implements its own prompt recall. +- Desktop release notes use the upstream focusable popover and bounded newest-first excerpts. + Omission counts cross IPC as optional fields for older consumers. Links point at the fork's + release feed, and the fork's automatic download/install state machine remains intact. diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ce11a5212..e0feb94bd 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + diffColorScheme: "red-green", alwaysExpandActivity: false, autoOpenPlanSidebar: false, appearanceContrast: 100, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 2ed4104b1..21548e456 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -722,9 +722,18 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; - const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); + const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( + info.releaseNotes, + info.version, + ); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), + reduceDesktopUpdateStateOnUpdateAvailable( + state, + info.version, + checkedAt, + releaseNotes, + omittedReleaseCount, + ), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); yield* logUpdaterInfo("update available", { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 78ea56e75..3ba2444dc 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -3,38 +3,137 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; describe("normalizeDesktopUpdateReleaseNotes", () => { - it("splits a plain string note into items under the fallback version", () => { - const notes = normalizeDesktopUpdateReleaseNotes( - "## What's changed\n- First fix\n- Second fix", + it("shows the newest changes and counts all real changes", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + "- feat: first change", + "- fix: second change", + "- fix: third change", + "- fix: fourth change", + "- fix: fifth change", + "- fix: sixth change", + "- fix: seventh change", + "- fix: eighth change", + "- fix(web): keep long task drawers usable on small screens by @human in #8313", + "- fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", + "## New Contributors", + "- @human made their first contribution in #8435", + "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", + ].join("\n"), + "0.0.36-nightly.20260828.1213", + ); + + expect(result).toEqual({ + releaseNotes: [ + { + version: "0.0.36-nightly.20260828.1213", + items: [ + "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", + "fix(web): keep long task drawers usable on small screens by @human in #8313", + "fix: eighth change", + "fix: seventh change", + "fix: sixth change", + "fix: fifth change", + "fix: fourth change", + "fix: third change", + ], + totalItems: 10, + }, + ], + omittedReleaseCount: 0, + }); + }); + + it("excludes a GitHub HTML contributor section", () => { + const result = normalizeDesktopUpdateReleaseNotes( + "

What's Changed

  • Older fix
  • Newer fix
" + + "

New Contributors

  • @human made their first contribution
" + + "

Full Changelog

", "1.2.3", ); - expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + + expect(result).toEqual({ + releaseNotes: [{ version: "1.2.3", items: ["Newer fix", "Older fix"], totalItems: 2 }], + omittedReleaseCount: 0, + }); + }); + + it("does not count Markdown or HTML section headings as changes", () => { + const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.4", note: ["### Features", ...changes].join("\n- ") }, + { + version: "1.2.3", + note: `

Fixes

    ${changes.map((change) => `
  • ${change}
  • `).join("")}
`, + }, + ], + "1.2.4", + ); + + expect(result.releaseNotes).toEqual([ + { version: "1.2.4", items: changes.toReversed(), totalItems: 8 }, + { version: "1.2.3", items: changes.toReversed(), totalItems: 8 }, + ]); }); - it("keeps per-version groups and drops empty ones", () => { - const notes = normalizeDesktopUpdateReleaseNotes( + it("keeps per-version order and drops empty groups", () => { + const result = normalizeDesktopUpdateReleaseNotes( [ - { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.3", note: "- Newer release" }, { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, - { version: "1.2.1", note: "- Older change" }, + { version: "1.2.1", note: "- Older release" }, ], "1.2.3", ); - expect(notes).toEqual([ - { version: "1.2.3", items: ["Newer change"] }, - { version: "1.2.1", items: ["Older change"] }, + + expect(result).toEqual({ + releaseNotes: [ + { version: "1.2.3", items: ["Newer release"], totalItems: 1 }, + { version: "1.2.1", items: ["Older release"], totalItems: 1 }, + ], + omittedReleaseCount: 0, + }); + }); + + it("counts valid groups before applying the six-release limit", () => { + const releaseNotes = [ + { version: "1.3.9", note: "- Change 9" }, + { version: "1.3.8", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.3.7", note: "- Change 7" }, + { version: "1.3.6", note: "- Change 6" }, + { version: "1.3.5", note: "- Change 5" }, + { version: "1.3.4", note: "- Change 4" }, + { version: "1.3.3", note: "- Change 3" }, + { version: "1.3.2", note: "- Change 2" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "1.3.9", + "1.3.7", + "1.3.6", + "1.3.5", + "1.3.4", + "1.3.3", ]); + expect(result.omittedReleaseCount).toBe(1); }); it("decodes valid HTML entities", () => { - const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); - expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(result).toEqual({ + releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); - it("ignores malformed entries instead of throwing", () => { - const notes = normalizeDesktopUpdateReleaseNotes( + it("ignores malformed and empty entries instead of throwing", () => { + const result = normalizeDesktopUpdateReleaseNotes( [ { version: "1.2.3", note: "- Valid change" }, + { version: "1.2.2", note: "" }, { version: 42, note: "- Bad version type" }, { version: "1.2.1", note: { html: "

object note

" } }, "not an object", @@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ], "1.2.3", ); - expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + + expect(result).toEqual({ + releaseNotes: [{ version: "1.2.3", items: ["Valid change"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); - it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { - const boilerplate = Array.from({ length: 7 }, (_, index) => ({ - version: `1.3.${9 - index}`, - note: "Full changelog: https://example.com/compare/x...y", - })); - const notes = normalizeDesktopUpdateReleaseNotes( - [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], - "1.3.9", - ); - expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + it("returns an empty result for an invalid payload", () => { + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + releaseNotes: [], + omittedReleaseCount: 0, + }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); - expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(result).toEqual({ + releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69857c92b..3b2f32e64 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -59,6 +59,7 @@ function stripMarkup(input: string): string { input .replace(//gi, "\n") .replace(/]*>/gi, "\n- ") + .replace(/]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") .replace(/<[^>]*>/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") @@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string { return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; } -function isIgnoredReleaseNoteLine(line: string): boolean { - const normalized = line +function normalizeReleaseNoteLine(line: string): string { + return line .toLowerCase() .replace(/[*_`#]/g, "") .trim(); +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = normalizeReleaseNoteLine(line); return ( normalized === "" || normalized === "what's changed" || normalized === "whats changed" || - normalized === "full changelog" || - normalized === "new contributors" || normalized.startsWith("compare: ") || normalized.includes("/compare/") ); } -function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { - if (!note) return []; +interface ExtractedReleaseNoteItems { + readonly items: ReadonlyArray; + readonly totalItems: number; +} + +function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { + if (!note) return { items: [], totalItems: 0 }; const items: string[] = []; + let totalItems = 0; for (const rawLine of stripMarkup(note).split("\n")) { const item = rawLine .trim() .replace(/^[-*]\s+/, "") .replace(/^\d+[.)]\s+/, "") .replace(/\s+/g, " "); + const normalized = normalizeReleaseNoteLine(item); + if (normalized === "new contributors" || normalized === "full changelog") break; + if (/^#{1,6}\s+/.test(item)) continue; if (isIgnoredReleaseNoteLine(item)) continue; + totalItems += 1; items.push(truncateReleaseNoteItem(item)); - if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift(); } - return items; + return { items: items.toReversed(), totalItems }; +} + +interface NormalizedDesktopUpdateReleaseNotes { + readonly releaseNotes: ReadonlyArray; + readonly omittedReleaseCount: number; } export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, -): ReadonlyArray { +): NormalizedDesktopUpdateReleaseNotes { const rawNotes = typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] @@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes( ? releaseNotes.filter(isElectronReleaseNoteInfo) : []; - return rawNotes - .map((entry) => ({ - version: entry.version, - items: extractReleaseNoteItems(entry.note), - })) - .filter((entry) => entry.items.length > 0) - .slice(0, MAX_RELEASE_NOTE_GROUPS); + const normalizedNotes = rawNotes.flatMap((entry) => { + const { items, totalItems } = extractReleaseNoteItems(entry.note); + if (totalItems === 0) return []; + return [ + { + version: entry.version, + items, + totalItems, + }, + ]; + }); + + return { + releaseNotes: normalizedNotes.slice(0, MAX_RELEASE_NOTE_GROUPS), + omittedReleaseCount: Math.max(0, normalizedNotes.length - MAX_RELEASE_NOTE_GROUPS), + }; } diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 97d192be6..4514ac9d5 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -33,6 +33,7 @@ export function createInitialDesktopUpdateState( availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt: null, message: null, @@ -52,6 +53,7 @@ export function reduceDesktopUpdateStateOnCheckStart( autoInstallPending: false, checkedAt, releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], + omittedReleaseCount: hasDownloadedUpdate ? (state.omittedReleaseCount ?? 0) : 0, message: null, downloadPercent: hasDownloadedUpdate ? 100 : null, errorContext: null, @@ -92,6 +94,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( version: string, checkedAt: string, releaseNotes: ReadonlyArray = [], + omittedReleaseCount = 0, ): DesktopUpdateState { const isDownloadedVersion = state.downloadedVersion === version; const nextReleaseNotes = @@ -103,6 +106,10 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( availableVersion: version, downloadedVersion: isDownloadedVersion ? version : null, releaseNotes: nextReleaseNotes, + omittedReleaseCount: + isDownloadedVersion && releaseNotes.length === 0 + ? (state.omittedReleaseCount ?? 0) + : omittedReleaseCount, downloadPercent: isDownloadedVersion ? 100 : null, checkedAt, message: null, @@ -135,6 +142,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt, message: null, diff --git a/apps/swift-ios/DesignSystem/T3Theme.swift b/apps/swift-ios/DesignSystem/T3Theme.swift index 162292383..4ebd80d91 100644 --- a/apps/swift-ios/DesignSystem/T3Theme.swift +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -38,6 +38,14 @@ enum T3Colors { static var primaryActionForeground: Color { Color(uiColor: palette.primaryActionForeground) } static var accent: Color { Color(uiColor: palette.accent) } static var danger: Color { Color(uiColor: palette.danger) } + static var diffAddition: Color { + T3ThemeStore.shared.diffColorScheme == .blueOrange + ? color(light: rgb(0x2563EB), dark: rgb(0x60A5FA)) : success + } + static var diffDeletion: Color { + T3ThemeStore.shared.diffColorScheme == .blueOrange + ? color(light: rgb(0xEA580C), dark: rgb(0xFB923C)) : danger + } // Fixed roles. These have no palette counterpart on the Expo client either // — it renders them from constant Tailwind classes — so a palette that diff --git a/apps/swift-ios/DesignSystem/T3ThemeStore.swift b/apps/swift-ios/DesignSystem/T3ThemeStore.swift index 3870d6715..524ac4bc9 100644 --- a/apps/swift-ios/DesignSystem/T3ThemeStore.swift +++ b/apps/swift-ios/DesignSystem/T3ThemeStore.swift @@ -147,6 +147,7 @@ struct T3ResolvedColors { @Observable final class T3ThemeStore { static let shared = T3ThemeStore() + var diffColorScheme: FeatureDiffColorScheme = .redGreen private(set) var lightPaletteID: String = T3Palette.defaultID private(set) var darkPaletteID: String = T3Palette.defaultID diff --git a/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift b/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift index 1dd195226..3ad61a680 100644 --- a/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift +++ b/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift @@ -193,12 +193,12 @@ struct CheckpointRestoreSheet: View { if file.additions > 0 { Text("+\(file.additions)") .font(ChatTimelineStyle.smallMono) - .foregroundStyle(T3Colors.success) + .foregroundStyle(T3Colors.diffAddition) } if file.deletions > 0 { Text("−\(file.deletions)") .font(ChatTimelineStyle.smallMono) - .foregroundStyle(T3Colors.danger) + .foregroundStyle(T3Colors.diffDeletion) } } .padding(.leading, 30) diff --git a/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift b/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift index f135ac877..f63614d78 100644 --- a/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift +++ b/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift @@ -195,16 +195,16 @@ struct InlineUnifiedDiff: View { private func markerColor(_ kind: UnifiedDiffRow.Kind) -> Color { switch kind { - case .addition: T3Colors.success - case .deletion: T3Colors.danger + case .addition: T3Colors.diffAddition + case .deletion: T3Colors.diffDeletion case .context, .hunk: T3Colors.textTertiary } } private func background(_ kind: UnifiedDiffRow.Kind) -> Color { switch kind { - case .addition: T3Colors.success.opacity(0.12) - case .deletion: T3Colors.danger.opacity(0.12) + case .addition: T3Colors.diffAddition.opacity(0.12) + case .deletion: T3Colors.diffDeletion.opacity(0.12) case .context, .hunk: .clear } } diff --git a/apps/swift-ios/Features/Chat/ThreadWorkLog.swift b/apps/swift-ios/Features/Chat/ThreadWorkLog.swift index af355a02e..362fce32c 100644 --- a/apps/swift-ios/Features/Chat/ThreadWorkLog.swift +++ b/apps/swift-ios/Features/Chat/ThreadWorkLog.swift @@ -754,10 +754,10 @@ struct WorkRowDiffStat: View { if additions > 0 || deletions > 0 { HStack(spacing: 4) { if additions > 0 { - Text(verbatim: "+\(additions)").foregroundStyle(T3Colors.success) + Text(verbatim: "+\(additions)").foregroundStyle(T3Colors.diffAddition) } if deletions > 0 { - Text(verbatim: "−\(deletions)").foregroundStyle(T3Colors.danger) + Text(verbatim: "−\(deletions)").foregroundStyle(T3Colors.diffDeletion) } } .font(ChatTimelineStyle.smallMono) diff --git a/apps/swift-ios/Features/Review/FeatureReviewView.swift b/apps/swift-ios/Features/Review/FeatureReviewView.swift index cd502d119..5d355c160 100644 --- a/apps/swift-ios/Features/Review/FeatureReviewView.swift +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -316,8 +316,8 @@ private struct FeatureReviewFileRow: View { private var changeColor: Color { switch file.change { - case .added: .green - case .deleted: .red + case .added: T3Colors.diffAddition + case .deleted: T3Colors.diffDeletion case .renamed: .blue case .modified, .binary: .orange } @@ -331,10 +331,10 @@ struct FeatureDiffStatsLabel: View { var body: some View { HStack(spacing: 5) { if additions > 0 { - Text("+\(additions)").foregroundStyle(.green) + Text("+\(additions)").foregroundStyle(T3Colors.diffAddition) } if deletions > 0 { - Text("−\(deletions)").foregroundStyle(.red) + Text("−\(deletions)").foregroundStyle(T3Colors.diffDeletion) } } .font(T3Typography.tool.monospacedDigit().weight(.medium)) @@ -641,8 +641,8 @@ private struct FeatureDiffLineRow: View { private var prefixColor: Color { switch line.kind { - case .addition: .green - case .deletion: .red + case .addition: T3Colors.diffAddition + case .deletion: T3Colors.diffDeletion case .context, .hunk: .secondary } } @@ -667,16 +667,16 @@ private struct FeatureDiffLineRow: View { private var changedSpanBackground: Color { switch line.kind { - case .addition: Color.green.opacity(0.28) - case .deletion: Color.red.opacity(0.28) + case .addition: T3Colors.diffAddition.opacity(0.28) + case .deletion: T3Colors.diffDeletion.opacity(0.28) case .context, .hunk: Color.clear } } private var background: Color { switch line.kind { - case .addition: Color.green.opacity(0.11) - case .deletion: Color.red.opacity(0.11) + case .addition: T3Colors.diffAddition.opacity(0.11) + case .deletion: T3Colors.diffDeletion.opacity(0.11) case .hunk: Color.blue.opacity(0.08) case .context: Color.clear } diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift index 95a7b8ffd..8b0ca60de 100644 --- a/apps/swift-ios/Features/Root/FeatureRootView.swift +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -49,6 +49,9 @@ public struct FeatureRootView: View { // T3Colors reader picks it up; this is the one place settings feed it. // `apply` no-ops unless the selection actually moved, so the frequent // non-theme settings updates cost nothing. + .onChange(of: model.snapshot.settings.diffColorScheme, initial: true) { _, value in + T3ThemeStore.shared.diffColorScheme = value + } .onChange(of: themeSelection, initial: true) { _, selection in T3ThemeStore.shared.apply( lightPaletteID: selection.light, diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index 5b8c20e87..5d9aef13f 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -47,6 +47,7 @@ public struct SettingsView: View { environmentThemes: activeEnvironmentThemes ) ThreadAppearanceSection( + diffColorScheme: $settings.diffColorScheme, alwaysExpandActivity: $settings.alwaysExpandActivity, showSkillsInSlashMenu: $settings.showSkillsInSlashMenu ) diff --git a/apps/swift-ios/Features/Settings/ThreadAppearanceSection.swift b/apps/swift-ios/Features/Settings/ThreadAppearanceSection.swift index f7c2392c3..baa0d3e79 100644 --- a/apps/swift-ios/Features/Settings/ThreadAppearanceSection.swift +++ b/apps/swift-ios/Features/Settings/ThreadAppearanceSection.swift @@ -7,9 +7,10 @@ import SwiftUI /// away once the turn finishes. "Show skills in slash menu" is the web client's /// companion composer setting. /// -/// A section rather than a screen because it is two toggles — drop it into the +/// A section rather than a screen — drop it into the /// appearance area of the settings sheet alongside the theme picker. struct ThreadAppearanceSection: View { + @Binding var diffColorScheme: FeatureDiffColorScheme @Binding var alwaysExpandActivity: Bool @Binding var showSkillsInSlashMenu: Bool @@ -22,6 +23,15 @@ struct ThreadAppearanceSection: View { var body: some View { SettingsSection(title: "Threads", footer: Self.footer) { VStack(spacing: 0) { + HStack { + Label("Diff colors", systemImage: "plus.forwardslash.minus") + Spacer() + Picker("Diff colors", selection: $diffColorScheme) { + Text("Red & green").tag(FeatureDiffColorScheme.redGreen) + Text("Blue & orange").tag(FeatureDiffColorScheme.blueOrange) + } + }.font(T3Typography.threadBody).padding(16) + SettingsRowDivider() SettingsToggleRow( title: "Activity detail", systemImage: "list.bullet.indent", diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 6297e5478..8fe12dcd1 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -1115,7 +1115,13 @@ public enum FeatureAppearance: String, CaseIterable, Sendable, Codable { case dark } +public enum FeatureDiffColorScheme: String, Sendable, Codable { + case redGreen = "red-green" + case blueOrange = "blue-orange" +} + public struct FeatureSettings: Sendable, Equatable, Codable { + public var diffColorScheme: FeatureDiffColorScheme public var appearance: FeatureAppearance public var hapticsEnabled: Bool public var notificationsEnabled: Bool @@ -1140,6 +1146,7 @@ public struct FeatureSettings: Sendable, Equatable, Codable { public var defaultSelection: FeatureSelection? public init( + diffColorScheme: FeatureDiffColorScheme = .redGreen, appearance: FeatureAppearance = .system, hapticsEnabled: Bool = true, notificationsEnabled: Bool = true, @@ -1151,6 +1158,7 @@ public struct FeatureSettings: Sendable, Equatable, Codable { darkThemeID: String = T3ThemeDefaults.paletteID, defaultSelection: FeatureSelection? = nil ) { + self.diffColorScheme = diffColorScheme self.appearance = appearance self.hapticsEnabled = hapticsEnabled self.notificationsEnabled = notificationsEnabled @@ -1164,6 +1172,7 @@ public struct FeatureSettings: Sendable, Equatable, Codable { } private enum CodingKeys: String, CodingKey { + case diffColorScheme case appearance case hapticsEnabled case notificationsEnabled @@ -1178,6 +1187,7 @@ public struct FeatureSettings: Sendable, Equatable, Codable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + diffColorScheme = (try? container.decodeIfPresent(FeatureDiffColorScheme.self, forKey: .diffColorScheme)) ?? .redGreen appearance = try container.decodeIfPresent( FeatureAppearance.self, forKey: .appearance @@ -1224,6 +1234,7 @@ public struct FeatureSettings: Sendable, Equatable, Codable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(diffColorScheme, forKey: .diffColorScheme) try container.encode(appearance, forKey: .appearance) try container.encode(hapticsEnabled, forKey: .hapticsEnabled) try container.encode(notificationsEnabled, forKey: .notificationsEnabled) diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadAppearanceSettingsTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadAppearanceSettingsTests.swift index be1141847..e4bbd4552 100644 --- a/apps/swift-ios/Tests/FeatureTests/ThreadAppearanceSettingsTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ThreadAppearanceSettingsTests.swift @@ -7,6 +7,13 @@ import XCTest /// stored settings blob is shared with builds that predate the key and with /// builds that will add more, so both directions have to survive a round trip. final class ThreadAppearanceSettingsTests: XCTestCase { + func testUnknownDiffPalettePreservesOtherSettings() throws { + let data = Data(#"{"appearance":"dark","diffColorScheme":"future"}"#.utf8) + let settings = try JSONDecoder().decode(FeatureSettings.self, from: data) + XCTAssertEqual(settings.appearance, .dark) + XCTAssertEqual(settings.diffColorScheme, .redGreen) + } + func testActivityDetailIsOffByDefault() { XCTAssertFalse(FeatureSettings().alwaysExpandActivity) } @@ -31,6 +38,7 @@ final class ThreadAppearanceSettingsTests: XCTestCase { """.utf8 ) let settings = try JSONDecoder().decode(FeatureSettings.self, from: legacy) + XCTAssertEqual(settings.diffColorScheme, .redGreen) XCTAssertFalse(settings.alwaysExpandActivity) XCTAssertTrue(settings.showSkillsInSlashMenu) XCTAssertEqual(settings.appearance, .dark) @@ -58,6 +66,7 @@ final class ThreadAppearanceSettingsTests: XCTestCase { settings.alwaysExpandActivity = true settings.showSkillsInSlashMenu = false settings.appearance = .light + settings.diffColorScheme = .blueOrange let data = try JSONEncoder().encode(settings) let decoded = try JSONDecoder().decode(FeatureSettings.self, from: data) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4e36fd1f7..39dfacde2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7690,6 +7690,7 @@ function ChatViewContent(props: ChatViewProps) {
boolean; focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; @@ -907,6 +908,55 @@ interface ComposerPromptEditorProps { editorRef: React.RefObject; } +function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { + const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); + const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; + if (collapsedRect) return collapsedRect; + + const container = range.startContainer; + if (container.nodeType === Node.TEXT_NODE) { + const textNode = container as Text; + if (textNode.data.length === 0) return null; + const probeStart = Math.max( + 0, + Math.min( + edge === "start" ? range.startOffset : range.startOffset - 1, + textNode.data.length - 1, + ), + ); + const probeRange = document.createRange(); + probeRange.setStart(textNode, probeStart); + probeRange.setEnd(textNode, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + const boundingRect = probeRange.getBoundingClientRect(); + return boundingRect.height > 0 ? boundingRect : null; + } + + if (!(container instanceof HTMLElement)) return null; + // The caret sits between the paragraph's children, which is where Lexical + // puts it next to an inline chip. Measure the neighbouring child. + const neighbour = + container.childNodes[Math.max(0, range.startOffset - 1)] ?? + container.childNodes[range.startOffset]; + if (neighbour instanceof HTMLElement) { + const neighbourRect = neighbour.getBoundingClientRect(); + if (neighbourRect.height > 0) return neighbourRect; + } else if (neighbour instanceof Text && neighbour.data.length > 0) { + // Probe the character on the caret's side. A soft-wrapped text node's + // first rect is its first visual line, which may not be the caret's. + const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; + const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; + const probeRange = document.createRange(); + probeRange.setStart(neighbour, probeStart); + probeRange.setEnd(neighbour, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + } + const containerRect = container.getBoundingClientRect(); + return containerRect.height > 0 ? containerRect : null; +} + function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -1699,6 +1749,36 @@ function ComposerPromptEditorInner({ ); }, readSnapshot, + isCaretOnVisualEdge: (edge) => { + const snapshot = readSnapshot(); + if (snapshot.value.length === 0) return true; + const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); + const afterCaret = snapshot.value.slice(snapshot.expandedCursor); + if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { + return false; + } + const rootElement = editor.getRootElement(); + const selection = window.getSelection(); + if ( + !rootElement || + !selection || + !selection.isCollapsed || + selection.rangeCount === 0 || + !selection.anchorNode || + !rootElement.contains(selection.anchorNode) + ) { + return false; + } + const caretRect = caretLineRect(selection.getRangeAt(0), edge); + if (!caretRect) return false; + const edgeElement = + edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; + const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); + const threshold = caretRect.height / 2; + return edge === "start" + ? caretRect.top - edgeRect.top < threshold + : edgeRect.bottom - caretRect.bottom < threshold; + }, }), [focusAt, readSnapshot], ); diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx new file mode 100644 index 000000000..49b00ed89 --- /dev/null +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -0,0 +1,41 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useRef } from "react"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "./ui/anchoredCopyToast"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + }); + + return ( + + copyToClipboard(filePath, undefined)} + /> + } + > + {isCopied ? : } + + +

{isCopied ? "Copied" : "Copy path"}

+
+
+ ); +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a23632cfd..bbed3fb33 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,3 +1,7 @@ +import { DiffFileTree } from "./diffs/DiffFileTree"; +import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; +import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { ListTreeIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -112,7 +116,8 @@ export default function DiffPanel({ fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const [codeViewRevision, setCodeViewRevision] = useState(0); - const codeViewRef = useRef(null); + const [codeView, setCodeView] = useState(null); + const [fileTreeOpen, setFileTreeOpen] = useState(false); const lastCompletedTurnRefreshRef = useRef<{ readonly threadKey: string | null; readonly runId: RunId | null; @@ -432,8 +437,8 @@ export default function DiffPanel({ useEffect(() => { if (!selectedDiffFileKey) return; - codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); - }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); + codeView?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); + }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); const openDiffFile = useCallback( (filePath: string) => { @@ -478,6 +483,22 @@ export default function DiffPanel({ [collapseScopeKey], ); + const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]); + const requestFileReveal = useCodeViewFileReveal(codeView, collapseScopeKey); + const revealDiffFile = useCallback( + (path: string) => { + const file = codeViewFiles.find((candidate) => candidate.filePath === path); + if (!file) return; + setCollapsedDiffFiles((current) => { + const keys = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + keys.delete(file.fileKey); + return { scopeKey: collapseScopeKey, fileKeys: keys }; + }); + requestFileReveal(file.fileKey); + }, + [codeViewFiles, collapseScopeKey, requestFileReveal], + ); + const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); setCollapsedDiffFiles((current) => { @@ -716,6 +737,24 @@ export default function DiffPanel({ )}
+ {codeViewFiles.length > 0 && ( + + + } + > + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + )} {codeViewFiles.length > 0 && ( ) ) : renderablePatch.kind === "files" ? ( -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // Header controls keep their own actions. In particular, the chevron must - // not also trigger the row handler or the two toggles cancel each other. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { +
+
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // Header controls keep their own actions. In particular, the chevron must + // not also trigger the row handler or the two toggles cancel each other. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + } + const title = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const filePath = title?.textContent?.trim(); + // The filename remains the explicit "open in editor" affordance. + if (filePath) { + openDiffFile(filePath); return; } - } - const title = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-title"), - ); - const filePath = title?.textContent?.trim(); - // The filename remains the explicit "open in editor" affordance. - if (filePath) { - openDiffFile(filePath); - return; - } - const header = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), - ); - const headerFilePath = header?.querySelector("[data-title]")?.textContent?.trim(); - if (!headerFilePath) return; - const file = codeViewFiles.find( - (candidate) => candidate.filePath === headerFilePath, - ); - if (file) toggleDiffFileCollapsed(file.fileKey); - }} - > - { - const filePath = resolveFileDiffPath(fileDiff); - return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - - )} - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), ); + const headerFilePath = header + ?.querySelector("[data-title]") + ?.textContent?.trim(); + if (!headerFilePath) return; + const file = codeViewFiles.find( + (candidate) => candidate.filePath === headerFilePath, + ); + if (file) toggleDiffFileCollapsed(file.fileKey); }} - options={{ - diffStyle: diffRenderMode === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - themeType: resolvedTheme as DiffThemeType, - stickyHeaders: true, - ...(loadDiffFiles ? { loadDiffFiles } : {}), - }} - /> + > + { + const filePath = resolveFileDiffPath(fileDiff); + return ( + + { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + ); + }} + options={{ + diffStyle: diffRenderMode === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme as DiffThemeType, + stickyHeaders: true, + ...(loadDiffFiles ? { loadDiffFiles } : {}), + }} + /> +
+ {fileTreeOpen && ( + + )}
) : (
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index abd8e9dad..13cd3758e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1864,8 +1864,8 @@ export default function GitActionsControl({ Changes - +{changeStat?.insertions ?? 0} - -{changeStat?.deletions ?? 0} + +{changeStat?.insertions ?? 0} + -{changeStat?.deletions ?? 0} ) : null} @@ -1973,9 +1973,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1986,11 +1986,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e68d3cfec..3ad95366a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,3 +1,7 @@ +import { + filterSidebarProjectScopeItems, + reduceSidebarProjectScopeMenuState, +} from "./Sidebar.logic"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { @@ -2481,3 +2485,66 @@ describe("resolveWorkInboxBadge", () => { ); }); }); + +describe("filterSidebarProjectScopeItems", () => { + const items = [ + { value: "all", label: "All projects" }, + { value: "alpha", label: "Alpha workspace" }, + { value: "beta", label: "Beta tools" }, + ] as const; + const filter = (activeScopeKey: string | null, query: string) => + filterSidebarProjectScopeItems({ + items, + activeScopeKey, + query, + matches: (item, candidate) => + item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), + }); + + it("omits the reset row when the sidebar is already unscoped", () => { + expect(filter(null, "")).toEqual(items.slice(1)); + }); + + it("shows the reset row first while a project scope is active", () => { + expect(filter("alpha", "")).toEqual(items); + }); + + it("hides the reset row while filtering an active scope", () => { + expect(filter("alpha", "all")).toEqual([]); + }); + + it("returns matching projects in source order and supports no-match results", () => { + expect(filter(null, "WORK")).toEqual([items[1]]); + expect(filter(null, "missing")).toEqual([]); + }); +}); + +describe("reduceSidebarProjectScopeMenuState", () => { + const queriedOpenState = { open: true, query: "alpha" }; + + it("clears the query when the combobox closes through onOpenChange", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "open-changed", + open: false, + }), + ).toEqual({ open: false, query: "" }); + }); + + it("clears the query when project settings closes the combobox", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "project-settings-opened", + }), + ).toEqual({ open: false, query: "" }); + }); + + it("keeps the popup open while the query changes", () => { + expect( + reduceSidebarProjectScopeMenuState( + { open: true, query: "" }, + { type: "query-changed", query: "beta" }, + ), + ).toEqual({ open: true, query: "beta" }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9c9ab59c6..54d989829 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1435,3 +1435,41 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +export function filterSidebarProjectScopeItems(input: { + items: readonly TItem[]; + activeScopeKey: string | null; + query: string; + matches: (item: TItem, query: string) => boolean; +}): readonly TItem[] { + const projectItems = input.items.filter((item) => item.value !== "all"); + const query = input.query.trim(); + if (query.length > 0) { + return projectItems.filter((item) => input.matches(item, query)); + } + return input.activeScopeKey === null ? projectItems : input.items; +} + +export interface SidebarProjectScopeMenuState { + readonly open: boolean; + readonly query: string; +} + +export type SidebarProjectScopeMenuAction = + | { readonly type: "query-changed"; readonly query: string } + | { readonly type: "open-changed"; readonly open: boolean } + | { readonly type: "project-settings-opened" }; + +export function reduceSidebarProjectScopeMenuState( + state: SidebarProjectScopeMenuState, + action: SidebarProjectScopeMenuAction, +): SidebarProjectScopeMenuState { + switch (action.type) { + case "query-changed": + return { ...state, query: action.query }; + case "open-changed": + return { open: action.open, query: "" }; + case "project-settings-opened": + return { open: false, query: "" }; + } +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 86de43274..73bdba9de 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,18 @@ +import { useReducer, type SyntheticEvent } from "react"; +import { + filterSidebarProjectScopeItems, + reduceSidebarProjectScopeMenuState, +} from "./Sidebar.logic"; +import { + Combobox, + ComboboxTrigger, + ComboboxPopup, + ComboboxSearchInput, + ComboboxEmpty, + ComboboxList, + ComboboxItem, + useComboboxFilter, +} from "./ui/combobox"; import { autoAnimate } from "@formkit/auto-animate"; import { DndContext, @@ -1901,10 +1916,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {isWork || prLine ? null : prBadge} {!isHermes && diff ? ( - - +{diff.insertions} - {" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} {rowIconCluster} @@ -2232,7 +2245,12 @@ export default function Sidebar() { ); }, }); - const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); + const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( + reduceSidebarProjectScopeMenuState, + { open: false, query: "" }, + ); + const suppressNextScopeChangeRef = useRef(false); + const highlightedProjectScopeKeyRef = useRef(null); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -2481,6 +2499,45 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + const projectScopeItems = useMemo( + () => [ + { value: "all", label: "All projects" }, + ...projectGroups.map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), + ], + [projectGroups], + ); + const projectGroupByScopeKey = useMemo( + () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), + [projectGroups], + ); + const selectedProjectScopeItem = useMemo( + () => + projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ?? + projectScopeItems[0]!, + [projectScopeItems, projectScopeKey], + ); + const projectScopeFilter = useComboboxFilter(); + // Filtering derives from the same React state that controls the input, so + // the visible query and the visible list can never desync — the peer wiring + // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope + // reset, not a searchable entry: it only shows while a project scope is + // active (there is something to reset) and the query is empty, so it can't + // outrank a project match under autoHighlight and no-hit queries reach the + // empty state. + const filteredProjectScopeItems = useMemo( + () => + filterSidebarProjectScopeItems({ + items: projectScopeItems, + activeScopeKey: projectScopeKey, + query: projectScopeMenuState.query, + matches: (item, query) => + projectScopeFilter.contains(item, query, (candidate) => candidate.label), + }), + [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], + ); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -2537,10 +2594,11 @@ export default function Sidebar() { }, [clearSelection, projectScopeKey, workEnvironmentScopeId]); const handleProjectSettings = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + (event: SyntheticEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); - setProjectScopeMenuOpen(false); + suppressNextScopeChangeRef.current = true; + dispatchProjectScopeMenu({ type: "project-settings-opened" }); if (isMobile) { setOpenMobile(false); } @@ -4161,8 +4219,31 @@ export default function Sidebar() {
{workspace === "code" && projectGroups.length > 0 ? (
- - item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuState.open} + onOpenChange={(open) => { + if (open) suppressNextScopeChangeRef.current = false; + dispatchProjectScopeMenu({ type: "open-changed", open }); + }} + onItemHighlighted={(item) => { + highlightedProjectScopeKeyRef.current = item?.value ?? null; + }} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (suppressNextScopeChangeRef.current) { + suppressNextScopeChangeRef.current = false; + return; + } + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > + {scopedProjectGroup ? ( - + + + ) : ( )} @@ -4184,57 +4267,87 @@ export default function Sidebar() { {scopedProjectGroup?.displayName ?? "All projects"} - - - - setProjectScopeKey(value === "all" ? null : (value as string)) + + + { + if ( + event.defaultPrevented || + event.nativeEvent.isComposing || + event.ctrlKey || + event.altKey || + event.metaKey || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + // Combobox items use virtual focus: keyboard events + // stay on this input, not on the highlighted option. + const scopeKey = highlightedProjectScopeKeyRef.current; + const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + if (project) handleProjectSettings(event, project); + }} + onChange={(event) => + dispatchProjectScopeMenu({ + type: "query-changed", + query: event.target.value, + }) } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; + /> + No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; return ( - { + if (project) handleProjectSettings(event, project); + }} > - - {project.displayName} - - + {project ? ( + + ) : ( + + )} + {item.label} + {project ? ( + + ) : null} + ); - })} - - - + }} + + + (null); + const promptHistoryMessagesRef = useRef(promptHistoryMessages); + promptHistoryMessagesRef.current = promptHistoryMessages; + + // The composer persists across threads. A recall from thread A must not + // be treated as active in thread B, where the text-match fallback could + // otherwise turn B's own draft into a browsing position. + const promptHistoryTargetKey = + typeof composerDraftTarget === "string" + ? composerDraftTarget + : `${composerDraftTarget.environmentId}:${composerDraftTarget.threadId}`; + useEffect(() => { + promptHistoryPositionRef.current = null; + }, [promptHistoryTargetKey]); + + const replacePromptFromHistory = useCallback( + (nextPrompt: string) => { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }, + [composerDraftTarget, promptRef, setComposerDraftPrompt], + ); + + const navigatePromptHistory = useCallback( + (direction: "backward" | "forward", event: KeyboardEvent): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (isComposerApprovalState || pendingUserInputs.length > 0) return false; + // A composer holding an image, file, picked element, preview + // annotation, or review comment is not empty. Recalling text into it + // would send the old prompt with the new context, which is never what + // ArrowUp meant. + if ( + composerImagesRef.current.length > 0 || + composerTerminalContexts.length > 0 || + composerElementContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + // A typed draft with no active recall can never step, so skip the + // layout read and the entry build for that common case. + if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + position: promptHistoryPositionRef.current, + currentPrompt: promptRef.current, + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position; + replacePromptFromHistory(step.prompt); + return true; + }, + [ + composerElementContextsRef, + composerTerminalContexts.length, + composerImagesRef, + composerPreviewAnnotations.length, + composerReviewComments.length, + isComposerApprovalState, + pendingUserInputs.length, + promptRef, + replacePromptFromHistory, + ], + ); + const onComposerCommandKey = ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, @@ -2289,6 +2375,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } + if (!menuIsActive && (key === "ArrowUp" || key === "ArrowDown")) { + return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); + } // Mod+Enter means two things depending on where the composer is: on a // draft it starts the thread in the background, and on a live thread it // steers the running turn. A draft has no run to steer, so the two never diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 6787a8df1..a4110afe8 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -40,10 +40,10 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { className, )} > -
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 29cf0759a..335169e88 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { type EnvironmentId, type MessageId, @@ -159,6 +160,7 @@ import { // --------------------------------------------------------------------------- interface TimelineRowSharedState { + onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; timestampFormat: TimestampFormat; routeThreadKey: string; threadRef: ScopedThreadRef | null; @@ -214,6 +216,7 @@ const EMPTY_TIMELINE_RUNS: ReadonlyArray = []; // --------------------------------------------------------------------------- interface MessagesTimelineProps { + onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; @@ -268,6 +271,7 @@ interface MessagesTimelineProps { // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ + onUseArtifactTemplate, isWorking, activeTurnInProgress, activeTurnStartedAt, @@ -534,6 +538,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenWorkspaceFile, onCopyWorkspacePath, + onUseArtifactTemplate, onOpenTurnDiff, onOpenThread, onForkFromRun, @@ -558,6 +563,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenWorkspaceFile, onCopyWorkspacePath, + onUseArtifactTemplate, onOpenTurnDiff, onOpenThread, onForkFromRun, @@ -1417,6 +1423,7 @@ function AssistantTimelineRow({ row }: { row: Extract
+ {detail ? ( + { + void refreshFromHost(); + onActed?.(); + }} + /> + ) : null} {detail ? ( <> {/* Checking a pull request out is the reason to open one here at all, so it is a diff --git a/apps/web/src/components/pullRequest/PullRequestStackControl.tsx b/apps/web/src/components/pullRequest/PullRequestStackControl.tsx new file mode 100644 index 000000000..a71124fda --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackControl.tsx @@ -0,0 +1,270 @@ +import type { + EnvironmentId, + PullRequestDetail, + PullRequestRef, + PullRequestMergeMethod, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { LayersIcon, RefreshCwIcon } from "lucide-react"; +import { useState } from "react"; +import { useServerConfigs } from "~/state/entities"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { Button } from "../ui/button"; +import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, +} from "../ui/dialog"; +import { PullRequestStackHeader } from "./PullRequestStackHeader"; +import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; +import { reviewStackAction } from "./pullRequestStackReview"; +import { toastManager } from "../ui/toast"; + +export function PullRequestStackControl({ + environmentId, + reference, + detail, + threadRef, + disabled, + onActed, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + detail: PullRequestDetail; + threadRef?: ScopedThreadRef; + disabled: boolean; + onActed: () => void; +}) { + const configs = useServerConfigs(); + const supported = + detail.provider === "github" && + configs.get(environmentId)?.environment.capabilities.pullRequestStackActions === true; + const [open, setOpen] = useState(false); + const [review, setReview] = useState< + | (NonNullable> & { + mergeMethods: readonly PullRequestMergeMethod[]; + }) + | null + >(null); + const query = useEnvironmentQuery( + supported && (open || review !== null) + ? pullRequestEnvironment.stack({ environmentId, input: reference }) + : null, + ); + const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const openLink = useOpenPrLink(threadRef); + const [method, setMethod] = useState("merge"); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + if (!supported) return null; + const stack = query.data; + const fresh = stack !== null && !query.isPending && !query.error; + const mergeMethods = detail.capabilities.mergeMethods.filter( + (method) => detail.mergeCapabilities[method], + ); + const canMerge = + !disabled && + fresh && + detail.viewerPermissions.actions.includes("merge") && + mergeMethods.length > 0; + const canRebase = + !disabled && + fresh && + detail.viewerPermissions.stackRebase === true && + detail.capabilities.updateMethods?.includes("rebase") === true; + + const arm = (action: "merge" | "update-branch") => { + if (!stack || !(action === "merge" ? canMerge : canRebase)) return; + const candidate = reviewStackAction( + stack, + action === "merge" ? reference.number : stack.layers.at(-1)!.number, + action, + ); + if (!candidate) return; + setReview({ ...candidate, mergeMethods: [...mergeMethods] }); + setMethod(mergeMethods[0] ?? "merge"); + setError(null); + }; + const close = () => { + if (!pending) { + setReview(null); + query.refresh(); + onActed(); + } + }; + const perform = async () => { + if ( + !review || + disabled || + pending || + error || + (review.action === "merge" && !review.mergeMethods.includes(method)) + ) + return; + setPending(true); + const result = await runAction({ + environmentId, + input: { + ...reference, + number: review.number, + action: review.action, + stackNumber: review.stackNumber, + expectedStackHeads: review.heads, + ...(review.action === "merge" + ? { mergeMethod: method } + : { updateMethod: "rebase" as const }), + }, + }); + setPending(false); + query.refresh(); + onActed(); + if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result))); + else { + setReview(null); + toastManager.add({ + type: "success", + title: review.action === "merge" ? "Stack merge completed" : "Stack rebased", + }); + } + }; + return ( + <> + + }> + + Stack + + + {stack ? ( + <> + +
+ {stack.layers.toReversed().map((layer) => { + const url = new URL(detail.url); + url.pathname = url.pathname.replace(/\/pull\/\d+.*/, `/pull/${layer.number}`); + url.search = ""; + url.hash = ""; + return ( + openLink(event, url.toString())} + /> + } + aria-current={layer.number === reference.number ? "true" : undefined} + > + + + ); + })} +
+ + arm("merge")} + > + Merge through #{reference.number} + + arm("update-branch")} + > + Rebase stack + + + ) : ( +

+ {query.error ?? + (query.isPending + ? "Loading stack…" + : "This pull request does not belong to a GitHub stack.")} +

+ )} + + + Refresh stack + +
+
+ { + if (!value) close(); + }} + > + + + + {review?.action === "merge" ? `Merge through #${review.number}` : "Rebase stack"} + + + Review the affected revisions. This updates remote branches on GitHub. Earlier + completed updates remain if a later layer fails. + + + +
    + {review?.layers.map((layer) => ( +
  • + + {layer.headSha?.slice(0, 8)} +
  • + ))} +
+ {review?.action === "merge" ? ( + + ) : null} + {error ? ( +

+ {error} Close to refresh before retrying. +

+ ) : null} +
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx b/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx new file mode 100644 index 000000000..a97c4ab77 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx @@ -0,0 +1,26 @@ +import { MenuGroupLabel } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function PullRequestStackHeader({ + number, + notice, + stale = false, +}: { + number: number; + notice?: string | null | undefined; + stale?: boolean; +}) { + return ( + + Stack #{number} + {notice ? ( + + }> + {stale ? "May be stale" : "Refreshing…"} + + {notice} + + ) : null} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx b/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx new file mode 100644 index 000000000..64d5737b0 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx @@ -0,0 +1,28 @@ +import type { PullRequestStack } from "@t3tools/contracts"; +import { cn } from "~/lib/utils"; +import { resolvePullRequestState } from "./pullRequestPresentation"; + +export function PullRequestStackLayerContent({ + layer, + compact = false, +}: { + layer: PullRequestStack["layers"][number]; + compact?: boolean; +}) { + const state = resolvePullRequestState({ + state: layer.state, + isDraft: layer.isDraft ?? false, + }); + return ( + <> + + + {layer.title || layer.headBranch} + + #{layer.number} · {compact ? null : `${layer.headBranch} · `} + {state.label} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsControl.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsControl.tsx new file mode 100644 index 000000000..a3b6cb571 --- /dev/null +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsControl.tsx @@ -0,0 +1,138 @@ +import type { ScopedThreadRef, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import { linkedPullRequestsOf, linkedPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { GitPullRequestIcon, LinkIcon, PlusIcon, UnlinkIcon } from "lucide-react"; +import { useState } from "react"; +import { useThreadShell, useServerConfigs } from "~/state/entities"; +import { linkedPullRequestDetailAtom } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { toastManager } from "../ui/toast"; +import { Button } from "../ui/button"; +import { Popover, PopoverTrigger, PopoverPopup } from "../ui/popover"; +import { openLinkPullRequestDialog } from "./LinkPullRequestDialog"; +import { PullRequestDiffStat, PullRequestStateGlyph } from "./pullRequestPresentation"; + +function LinkRow({ + threadRef, + link, +}: { + threadRef: ScopedThreadRef; + link: ThreadLinkedPullRequest; +}) { + const query = useEnvironmentQuery( + linkedPullRequestDetailAtom({ environmentId: threadRef.environmentId, input: link }), + ); + const open = useOpenPrLink(threadRef); + const linking = usePullRequestLinking(threadRef.environmentId); + const [pending, setPending] = useState(false); + const detail = query.data; + return ( +
+ {detail ? ( + + ) : ( + + )} + open(event, link.url)} + > + + #{link.number} {detail?.title ?? link.repository} + + + {query.error + ? "Host state unavailable" + : detail + ? `${link.repository} · ${detail.headBranch} → ${detail.baseBranch}` + : "Loading host state…"} + + + {detail ? ( + + ) : null} + +
+ ); +} + +/** Queries host summaries only while the collection is visible. */ +export function ThreadPullRequestsControl({ threadRef }: { threadRef: ScopedThreadRef }) { + const thread = useThreadShell(threadRef); + const configs = useServerConfigs(); + const [open, setOpen] = useState(false); + if ( + !thread || + configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequestsV2 !== true + ) + return null; + const links = linkedPullRequestsOf(thread); + return ( + + + } + > + + {links.length} + + +
+

Linked pull requests

+ +
+ {open ? ( +
+ {links.length ? ( + links.map((link) => ( + + )) + ) : ( +

+ Link pull requests to keep this thread's reviews together. +

+ )} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestStackReview.test.ts b/apps/web/src/components/pullRequest/pullRequestStackReview.test.ts new file mode 100644 index 000000000..a6995b884 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackReview.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { PullRequestStack } from "@t3tools/contracts"; +import { reviewStackAction } from "./pullRequestStackReview"; +const stack: PullRequestStack = { + id: "stack", + number: 8, + url: "https://github.com/a/b/stack/8", + base: "main", + layers: [ + { number: 1, headBranch: "one", headSha: "aaa", state: "merged" }, + { number: 2, headBranch: "two", headSha: "bbb", state: "open" }, + { number: 3, headBranch: "three", headSha: "ccc", state: "open" }, + ], +}; +describe("reviewed stack operations", () => { + it("merges only the selected prefix's unmerged revisions", () => { + expect(reviewStackAction(stack, 2, "merge")?.heads).toEqual([{ number: 2, headSha: "bbb" }]); + }); + it("rebases every unmerged revision from the top", () => { + expect(reviewStackAction(stack, 2, "update-branch")).toBeNull(); + expect(reviewStackAction(stack, 3, "update-branch")?.heads).toEqual([ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ]); + }); + it("refuses closed, draft, unknown and missing revisions", () => { + expect(reviewStackAction(stack, 9, "merge")).toBeNull(); + expect(reviewStackAction(stack, 1, "merge")).toBeNull(); + for (const change of [ + { state: "closed" as const }, + { isDraft: true }, + { headSha: undefined }, + ]) { + const changed = { + ...stack, + layers: stack.layers.map((layer) => (layer.number === 2 ? { ...layer, ...change } : layer)), + }; + expect(reviewStackAction(changed, 3, "merge")).toBeNull(); + } + }); + it("does not include unrelated later layers in the reviewed prefix", () => { + const changed = { + ...stack, + layers: stack.layers.map((layer) => + layer.number === 3 ? { ...layer, state: "closed" as const } : layer, + ), + }; + expect(reviewStackAction(changed, 2, "merge")?.heads).toEqual([{ number: 2, headSha: "bbb" }]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestStackReview.ts b/apps/web/src/components/pullRequest/pullRequestStackReview.ts new file mode 100644 index 000000000..4b84df809 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackReview.ts @@ -0,0 +1,26 @@ +import type { PullRequestStack, PullRequestStackHead } from "@t3tools/contracts"; + +/** Capture only the affected unmerged revisions, matching the server's atomic review check. */ +export function reviewStackAction( + stack: PullRequestStack, + number: number, + action: "merge" | "update-branch", +) { + const index = stack.layers.findIndex((layer) => layer.number === number); + if (index < 0 || (action === "update-branch" && index !== stack.layers.length - 1)) return null; + const layers = (action === "merge" ? stack.layers.slice(0, index + 1) : stack.layers).filter( + (layer) => layer.state !== "merged", + ); + if ( + !layers.length || + layers.some( + (layer) => layer.state !== "open" || !layer.headSha || (action === "merge" && layer.isDraft), + ) + ) + return null; + const heads: PullRequestStackHead[] = layers.map((layer) => ({ + number: layer.number, + headSha: layer.headSha!, + })); + return { stackNumber: stack.number, number, action, layers, heads }; +} diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts new file mode 100644 index 000000000..7bb280922 --- /dev/null +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -0,0 +1,72 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { findProjectForChangeRequest, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { useProjects, useServerConfigs } from "~/state/entities"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** Resolve against this environment's known remotes, then persist a host-confirmed V2 link. */ +export function usePullRequestLinking(environmentId?: EnvironmentId) { + const configs = useServerConfigs(); + const projects = useProjects().filter((project) => project.environmentId === environmentId); + const capabilities = environmentId + ? configs.get(environmentId)?.environment.capabilities + : undefined; + const mode = + capabilities?.threadPullRequestsV2 === true && capabilities.pullRequests === true + ? "collection" + : "unsupported"; + const read = useAtomCommand(pullRequestEnvironment.readDetail, { reportFailure: false }); + const update = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); + const resolve = (url: string) => { + const parsed = parseChangeRequestUrl(url); + return parsed ? findProjectForChangeRequest(projects, parsed) : undefined; + }; + return { + mode, + unlink: async (threadRef: ScopedThreadRef, link: ThreadLinkedPullRequest) => { + if (mode !== "collection" || !environmentId || threadRef.environmentId !== environmentId) + throw new Error("This environment cannot unlink pull requests."); + const result = await update({ + environmentId, + input: { threadId: threadRef.threadId, unlinkPullRequest: link }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }, + canLink: (url: string) => mode === "collection" && resolve(url) !== undefined, + changeLink: async (threadRef: ScopedThreadRef, url: string, adding: boolean) => { + const parsed = parseChangeRequestUrl(url); + const project = resolve(url); + if ( + mode !== "collection" || + !project || + !parsed || + threadRef.environmentId !== environmentId + ) { + throw new Error("This environment cannot link that pull request."); + } + const reference = { + projectId: project.id, + repository: project.repositoryIdentity?.displayName ?? parsed.repository, + number: parsed.number, + }; + // Unlinking must still work when the host is offline or the PR was deleted. + let canonicalUrl = url; + if (adding) { + const result = await read({ environmentId, input: reference }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + canonicalUrl = result.value.url; + } + const link = { ...reference, url: canonicalUrl }; + const result = await update({ + environmentId, + input: { + threadId: threadRef.threadId, + ...(adding ? { linkPullRequest: link } : { unlinkPullRequest: link }), + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }, + }; +} diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 810956c52..6cadcc8c8 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,3 +1,4 @@ +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import type { EnvironmentId, LocalApi, @@ -228,8 +229,8 @@ export function findProjectForChangeRequest( (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); return ( repository !== null && - repository.toLowerCase() === link.repository.toLowerCase() && - pullRequestHostOf(identity, kind) === link.host.toLowerCase() + canonicalRepositoryKey(`${pullRequestHostOf(identity, kind)}/${repository}`.toLowerCase()) === + canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()) ); }); } diff --git a/docs/user/chat-formatting.md b/docs/user/chat-formatting.md index 824eb58da..96c2dae80 100644 --- a/docs/user/chat-formatting.md +++ b/docs/user/chat-formatting.md @@ -52,3 +52,7 @@ and files, and the copy control beside a file heading to copy its path. The sidebar project filter supports searching project names. Open a project's settings from its gear button or with Shift+F10 while the project is highlighted. Desktop nightly release notes open on hover or keyboard focus; Tab reaches their full-release links. + +Codex file citations open the referenced file and line. Artifact-template results appear +as cards on web, desktop, and iOS. Choose **Use template** to add a prompt to the +composer, then edit and send it when ready. diff --git a/docs/user/linked-pull-requests.md b/docs/user/linked-pull-requests.md index a40c4117f..f2fe26172 100644 --- a/docs/user/linked-pull-requests.md +++ b/docs/user/linked-pull-requests.md @@ -1,4 +1,4 @@ -# Linked pull requests on iOS +# Linked pull requests Open a task's Details, then Version Control → Linked pull requests. Add a PR number or paste its URL to link a request from the task's repository. Add more requests @@ -19,3 +19,13 @@ If a stack changed since you reviewed it, refresh before retrying. A rebase can stop after updating earlier layers; those completed updates remain on GitHub. If GitHub reports that a merge is still running, check its status before submitting another request. Stack controls require a server and host that support them. + +On web and desktop, the link button beside the thread title lists all linked requests. +Choose **Link** there or **Link pull request** in the command palette. Bare numbers +use the current project's repository; a full URL can link another repository with a +readable project in the same environment. Unlinking removes the association without +changing the request on its host. + +In GitHub PR details, open **Stack** to browse layers, refresh their state, or review +a merge/rebase. The confirmation captures the revisions you reviewed. A failure +requires closing and refreshing before another attempt. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index e2ae09318..5b825607f 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -123,6 +123,8 @@ export interface UpdateThreadMetadataInput extends ThreadCommandInput { readonly clearTimeline?: true; /** Absent leaves the link alone; null unlinks. */ readonly linkedPullRequest?: ThreadLinkedPullRequest | null; + readonly linkPullRequest?: ThreadLinkedPullRequest; + readonly unlinkPullRequest?: ThreadLinkedPullRequest; } export interface SetThreadRuntimeModeInput extends ThreadCommandInput { @@ -525,7 +527,9 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM input.pinned !== undefined || input.workInboxRole !== undefined || input.clearTimeline !== undefined || - input.linkedPullRequest !== undefined + input.linkedPullRequest !== undefined || + input.linkPullRequest !== undefined || + input.unlinkPullRequest !== undefined ) { result = yield* dispatch({ type: "thread.metadata.update", @@ -541,6 +545,10 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM ...(input.linkedPullRequest === undefined ? {} : { linkedPullRequest: input.linkedPullRequest }), + ...(input.linkPullRequest === undefined ? {} : { linkPullRequest: input.linkPullRequest }), + ...(input.unlinkPullRequest === undefined + ? {} + : { unlinkPullRequest: input.unlinkPullRequest }), }); } if (input.modelSelection !== undefined) { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index b62206008..93e5902f3 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -98,6 +98,17 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 15_000, }), activity, + stack: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:stack", + tag: WS_METHODS.pullRequestsStack, + staleTimeMs: 15_000, + }), + readDetail: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:read-detail", + tag: WS_METHODS.pullRequestsDetail, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", tag: WS_METHODS.pullRequestsThreadComments, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 7c2648508..fa2516fa2 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -406,6 +406,8 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; * offering one they may not use ends in the host's own refusal — which at least says why. */ export const PullRequestViewerPermissions = Schema.Struct({ + /** Stack rebases need write access even when the selected branch is not behind its base. */ + stackRebase: Schema.optionalKey(Schema.Boolean), /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ actions: Schema.Array(PullRequestAction), /** This viewer may write a remark: a comment, a reply, or a note against a line. */ diff --git a/packages/shared/package.json b/packages/shared/package.json index 4f881507b..75c4f429d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -302,6 +302,10 @@ "./favicon": { "types": "./src/favicon.ts", "import": "./src/favicon.ts" + }, + "./changeRequestUrl": { + "types": "./src/changeRequestUrl.ts", + "import": "./src/changeRequestUrl.ts" } }, "scripts": { diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts new file mode 100644 index 000000000..919e5055c --- /dev/null +++ b/packages/shared/src/changeRequestUrl.ts @@ -0,0 +1,21 @@ +import { canonicalRepositoryKey } from "./sourceControl.ts"; + +export function changeRequestUrlFor( + kind: string | null | undefined, + host: string, + repository: string, + number: number, +): string | null { + switch (kind) { + case "github": + return `https://${host}/${repository}/pull/${number}`; + case "gitlab": + return `https://${host}/${repository}/-/merge_requests/${number}`; + case "bitbucket": + return `https://${host}/${repository}/pull-requests/${number}`; + case "azure-devops": + return `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`; + default: + return null; + } +} diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595..8b14af187 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -245,3 +245,15 @@ export function detectSourceControlProviderFromRemoteUrl( baseUrl: toBaseUrl(host), }; } + +export function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} From 734c2811d1930770f92b0a5a5a7afbf6de4bac0c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:23:08 +0200 Subject: [PATCH 05/93] feat: add PR label editing and resilient media previews --- PATCH.md | 11 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../pullRequest/GitHubPullRequestCli.test.ts | 65 +++++- .../src/pullRequest/GitHubPullRequestCli.ts | 74 +++++++ .../GitHubPullRequestProvider.test.ts | 4 + .../pullRequest/GitHubPullRequestProvider.ts | 17 ++ .../src/pullRequest/PullRequestProvider.ts | 14 ++ .../pullRequest/PullRequestService.test.ts | 128 ++++++++++++ .../src/pullRequest/PullRequestService.ts | 93 +++++++++ .../pullRequest/gitHubPullRequestJson.test.ts | 78 ++++++- .../src/pullRequest/gitHubPullRequestJson.ts | 98 +++++++++ apps/server/src/ws.ts | 10 + apps/swift-ios/App/NativeFeatureClient.swift | 19 ++ apps/swift-ios/Core/PullRequestModels.swift | 15 ++ apps/swift-ios/Core/T3Client.swift | 13 ++ .../Chat/FeatureInlineVideoView.swift | 27 ++- .../Chat/PullRequestDetailSheet.swift | 17 +- .../Chat/PullRequestLabelPickerSheet.swift | 106 ++++++++++ .../Features/Shared/FeatureClient.swift | 4 + .../CoreTests/Fixtures/pullRequestLabels.json | 17 ++ .../CoreTests/PullRequestLabelTests.swift | 22 ++ apps/web/src/components/ChatMarkdown.tsx | 61 ++++-- .../web/src/components/chat/MarkdownMedia.tsx | 142 ++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 26 ++- .../web/src/components/media/MediaActions.tsx | 194 ++++++++++++++++++ .../src/components/media/MediaVideoPlayer.tsx | 187 +++++++++++++++++ .../src/components/media/OpenMediaLink.tsx | 45 ++++ apps/web/src/components/media/mediaContent.ts | 82 ++++++++ .../PullRequestCandidatePicker.tsx | 180 ++++++++++++++++ .../pullRequest/PullRequestCodeTab.tsx | 183 ++++++++++++----- .../pullRequest/PullRequestLabelPicker.tsx | 132 ++++++++++++ .../pullRequest/PullRequestLinkPreview.tsx | 83 ++++++++ .../pullRequest/PullRequestSummaryTab.tsx | 11 +- apps/web/src/components/ui/preview-card.tsx | 44 ++++ apps/web/src/lib/videoFirstFrame.test.ts | 83 ++++++++ apps/web/src/lib/videoFirstFrame.ts | 28 +++ apps/web/src/state/use-atom-query-runner.ts | 8 +- docs/user/composer.md | 5 + docs/user/linked-pull-requests.md | 5 + packages/client-runtime/package.json | 8 + packages/client-runtime/src/mediaActions.ts | 8 + .../client-runtime/src/mediaReference.test.ts | 39 ++++ packages/client-runtime/src/mediaReference.ts | 98 +++++++++ .../client-runtime/src/state/pullRequests.ts | 11 + packages/contracts/src/pullRequest.ts | 24 +++ packages/contracts/src/rpc.ts | 16 ++ scripts/generate-swift-contract-fixtures.ts | 25 +++ 47 files changed, 2446 insertions(+), 116 deletions(-) create mode 100644 apps/swift-ios/Features/Chat/PullRequestLabelPickerSheet.swift create mode 100644 apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestLabels.json create mode 100644 apps/swift-ios/Tests/CoreTests/PullRequestLabelTests.swift create mode 100644 apps/web/src/components/media/MediaActions.tsx create mode 100644 apps/web/src/components/media/MediaVideoPlayer.tsx create mode 100644 apps/web/src/components/media/OpenMediaLink.tsx create mode 100644 apps/web/src/components/media/mediaContent.ts create mode 100644 apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx create mode 100644 apps/web/src/components/ui/preview-card.tsx create mode 100644 apps/web/src/lib/videoFirstFrame.test.ts create mode 100644 apps/web/src/lib/videoFirstFrame.ts create mode 100644 packages/client-runtime/src/mediaActions.ts create mode 100644 packages/client-runtime/src/mediaReference.test.ts create mode 100644 packages/client-runtime/src/mediaReference.ts diff --git a/PATCH.md b/PATCH.md index ca1d69707..8eb25b592 100644 --- a/PATCH.md +++ b/PATCH.md @@ -612,3 +612,14 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera without requiring the host or project to remain available. Stack detail controls use the native parity RPC and capture immutable reviewed heads before submitting merge/rebase. Automatic discovery, source/tombstone metadata and persistent PR caches remain separate ports. + +- GitHub PR labels are editable in web/desktop and Swift detail screens. Optional + host capability and viewer permission flags gate lazy candidate reads and mutations; + the server checks triage access and invalidates detail/list caches after attempted + updates, including partial failures. This host API port has no V1 runtime dependency. + +- Web PR code review also carries the paged changed-file tree; chat PR links load hover + details on demand and use atomic V2 collection actions from their context menu. +- Inline video recovery uses the fork's asset URLs and range streaming. Web previews + preserve an active playhead across URL renewal, prepare a first frame only while idle, + and provide explicit save/copy actions. Swift offers retry and pauses when backgrounded. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a50cb706e..81e55aae3 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -97,6 +97,8 @@ export const RPC_REQUIRED_SCOPES = { // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 33d0d120c..fd8cf7590 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2300,7 +2300,12 @@ layer("GitHubPullRequestCli.layer", (it) => { // One request, because both answers hang off the same repository object. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(callAt(0).args).toContain("number=7"); - expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + expect(access).toEqual({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); }), ); @@ -2463,7 +2468,12 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + expect(access).toEqual({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); }), ); @@ -2603,4 +2613,55 @@ layer("GitHubPullRequestCli.layer", (it) => { ]); }), ); + it.effect("puts labels on by posting to the issue's own collection, all at once", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["bug", "size:XL"], + applied: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + const call = callAt(0); + expect(call.args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/issues/7/labels", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body. + expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] }); + }), + ); + + it.effect("takes labels off one at a time, naming each in the path encoded", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["good first issue", "area/web"], + applied: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue"); + expect(callAt(0).args).toContain("DELETE"); + expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index e28242179..74c890c8d 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,9 @@ +import type { PullRequestLabelCandidateList } from "@t3tools/contracts"; +import { + LABEL_CANDIDATES_GRAPHQL_QUERY, + decodeLabelCandidatesJson, + buildLabelRequestJson, +} from "./gitHubPullRequestJson.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import { decodePullRequestStacksJson, @@ -491,6 +497,24 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly includeDetails?: boolean; }) => Effect.Effect; + /** The repository's labels, and which of them this pull request already wears. */ + readonly listLabelCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setLabels: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly labels: ReadonlyArray; + /** False takes each label off; true adds each to whatever is already there. */ + readonly applied: boolean; + }) => Effect.Effect; + readonly runPullRequestAction: (input: { readonly cwd: string; readonly repository: string; @@ -1671,6 +1695,56 @@ export const make = Effect.gen(function* () { }); }, + listLabelCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listLabelCandidates", + allowReserve: true, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: LABEL_CANDIDATES_GRAPHQL_QUERY, + decode: decodeLabelCandidatesJson, + }); + }, + + setLabels: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // A pull request is an issue to the labels API. Adding posts a list and leaves what was + // already there; taking off is one delete per label, since the endpoint names one in its + // path. The name goes into the path encoded, because a label may carry a space or a slash. + const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; + if (input.applied) { + return github + .execute({ + cwd: input.cwd, + args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"], + stdin: buildLabelRequestJson(input.labels), + }) + .pipe(Effect.asVoid); + } + return Effect.forEach( + input.labels, + (label) => + github.execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "DELETE", + "--hostname", + input.host, + `${issue}/${encodeURIComponent(label)}`, + ], + }), + { concurrency: 1, discard: true }, + ); + }, + listReviewerCandidates: (input) => { const { owner, name } = parseRepositorySelector(input.repository); return graphqlRead({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 9e4e8cfd7..d3044c3dd 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -24,6 +24,7 @@ describe("gitHubViewerPermissions", () => { resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, + labels: true, stackRebase: true, }); }); @@ -40,6 +41,7 @@ describe("gitHubViewerPermissions", () => { verdicts: ["comment", "approve", "request-changes"], // Asking somebody else to review is the one thing read access never stretches to. requestReviewers: false, + labels: false, }); }); @@ -52,6 +54,7 @@ describe("gitHubViewerPermissions", () => { // GitHub refuses an author's approval of their own change, so the page does not offer one. verdicts: ["comment"], requestReviewers: false, + labels: false, }); }); @@ -71,6 +74,7 @@ describe("gitHubViewerPermissions", () => { resolve: false, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, + labels: false, }); }).pipe( Effect.provide( diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index bc0aeadd8..b17280530 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -41,6 +41,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + labels: true, }; /** @@ -78,6 +79,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest // leaves them commenting, which is what an author has to say about their own change anyway. verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, + labels: access.canTriage ?? access.canWrite, ...(access.canWrite ? { stackRebase: true } : {}), ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), }; @@ -419,6 +421,21 @@ export const make = Effect.gen(function* () { cli .getPullRequestStack({ ...input, includeDetails: true }) .pipe(Effect.mapError(fail("getStack"))), + listLabelCandidates: (input) => + cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), + + setLabels: (input) => + cli + .setLabels({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }) + .pipe(Effect.mapError(fail("setLabels"))), + runAction: (input) => cli .runPullRequestAction({ diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index ac25c8e77..1b6cf5ed0 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,3 +1,4 @@ +import type { PullRequestLabelCandidateList } from "@t3tools/contracts"; import type { PullRequestStack, PullRequestStackHead } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -453,6 +454,19 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + readonly listLabelCandidates?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Puts labels on the change request, or takes them off. One call for both directions. */ + readonly setLabels?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly labels: ReadonlyArray; + readonly applied: boolean; + }, + ) => Effect.Effect; + /** Only called when `capabilities.review.reply` is true. */ readonly replyToThread: ( input: ProviderRepositoryRef & { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 3d6b358cf..3263021e4 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3535,3 +3535,131 @@ it.effect("authorizes stack rebases separately from single-branch behind status" assert.equal(taken, 1); }), ); + +it.effect("refuses a label change on a host that has not said it takes one", () => + Effect.gen(function* () { + let changed = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The method is there; the capability that would let it be called is not. + setLabels: () => { + changed = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + labels: ["bug"], + applied: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot change the labels"); + assert.isFalse(changed); + }), +); + +it.effect("refuses a label change this viewer may not make, and says what access it takes", () => + Effect.gen(function* () { + let changed = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, labels: true }, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + labels: false, + }), + listLabelCandidates: () => Effect.die("must not be called"), + setLabels: () => { + changed = true; + return Effect.void; + }, + }), + ], + }); + + const listError = yield* Effect.flip( + service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), + ); + assert.include(listError.message, "Changing labels needs triage access on this repository"); + + const error = yield* Effect.flip( + service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + labels: ["bug"], + applied: true, + }), + ); + assert.include(error.message, "Changing labels needs triage access on this repository"); + assert.isFalse(changed); + }), +); + +it.effect("hands a label change to the host, and reads the labels back for the menu", () => + Effect.gen(function* () { + let received: { labels: ReadonlyArray; applied: boolean } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, labels: true }, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + labels: true, + }), + listLabelCandidates: () => + Effect.succeed({ + candidates: [{ name: "bug", color: null, description: null, isApplied: false }], + truncated: false, + }), + setLabels: (input) => { + received = { labels: input.labels, applied: input.applied }; + return Effect.void; + }, + }), + ], + }); + + const list = yield* service.labelCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + }); + assert.deepStrictEqual( + list.candidates.map((label) => label.name), + ["bug"], + ); + + yield* service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + labels: ["bug"], + applied: false, + }); + assert.deepStrictEqual(received, { labels: ["bug"], applied: false }); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 4317b5f19..827b191d4 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,7 @@ +import type { + PullRequestLabelCandidateList, + PullRequestLabelChangeInput, +} from "@t3tools/contracts"; import type { PullRequestStack } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; @@ -163,6 +167,12 @@ export class PullRequestService extends Context.Service< readonly requestReviewers: ( input: PullRequestReviewerRequestInput, ) => Effect.Effect; + readonly labelCandidates: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly setLabels: ( + input: PullRequestLabelChangeInput, + ) => Effect.Effect; readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect; } >()("t3/pullRequest/PullRequestService") {} @@ -459,6 +469,10 @@ function withRateLimitBackoff( submitReview: interactive("submitReview", api.submitReview), listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates), setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest), + ...(api.listLabelCandidates === undefined + ? {} + : { listLabelCandidates: interactive("listLabelCandidates", api.listLabelCandidates) }), + ...(api.setLabels === undefined ? {} : { setLabels: interactive("setLabels", api.setLabels) }), replyToThread: interactive("replyToThread", api.replyToThread), setReaction: interactive("setReaction", api.setReaction), setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), @@ -1699,6 +1713,75 @@ export const make = Effect.gen(function* () { * the one the request is made from. So the same permission guards both: a page that could open * the menu without it would offer a list whose every press was going to be turned down. */ + const LABEL_CHANGE_REFUSAL = "Changing labels needs triage access on this repository."; + const labelCandidates: PullRequestService["Service"]["labelCandidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const list = project.api.listLabelCandidates; + if (project.api.capabilities.labels !== true || list === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "labelCandidates", + detail: "This host cannot change the labels on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "labelCandidates").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.labels !== true + ? Effect.fail( + new PullRequestOperationError({ + operation: "labelCandidates", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : list({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("labelCandidates"))), + ), + ); + }), + ); + + const setLabels: PullRequestService["Service"]["setLabels"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const change = project.api.setLabels; + if (project.api.capabilities.labels !== true || change === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: "This host cannot change the labels on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "setLabels").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.labels !== true + ? Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : change({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }).pipe(Effect.mapError(toPullRequestError("setLabels"))), + ), + ); + }), + ); + const reviewerCandidates: PullRequestService["Service"]["reviewerCandidates"] = (input) => requireProject(input).pipe( Effect.flatMap( @@ -2161,6 +2244,16 @@ export const make = Effect.gen(function* () { setReaction: invalidatedByMutation(setReaction), // The candidate list is deliberately read fresh per menu-open, so it stays uncached. reviewerCandidates, + labelCandidates, + setLabels: (input) => + invalidatedByMutation(setLabels)(input).pipe( + Effect.ensuring( + Effect.sync(() => { + bumpRefEpoch(input); + listingsEpoch = ++epochCounter; + }), + ), + ), requestReviewers: invalidatedByMutation(requestReviewers), invalidate, }); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac300..b94e9beba 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,7 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + decodeLabelCandidatesJson, buildReviewSubmissionJson, buildReviewerRequestJson, decodeBaseComparisonJson, @@ -763,7 +764,7 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true }); }); it("says no to a passer-by on a repository they can only read", () => { @@ -776,7 +777,7 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false }); + ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false }); }); it("reads silence as permission, but not as authorship", () => { @@ -785,6 +786,7 @@ describe("viewer permission decoding", () => { // and claiming it for someone who did not is how an author's own rules get handed out. expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ canWrite: false, + canTriage: false, canUpdate: true, didAuthor: false, }); @@ -1361,3 +1363,75 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +it("allows triage accounts to label without granting write", () => { + const access = expectSuccess( + decodeViewerPermissionsJson( + JSON.stringify({ + data: { + repository: { + viewerPermission: "TRIAGE", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }, + }, + }), + ), + ); + expect(access.canTriage).toBe(true); + expect(access.canWrite).toBe(false); +}); + +describe("label candidate decoding", () => { + const labelsJson = (input: { + readonly defined: ReadonlyArray>; + readonly applied?: ReadonlyArray; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + labels: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.defined, + }, + pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, + }, + }, + }); + + it("marks the labels the pull request already wears", () => { + const list = expectSuccess( + decodeLabelCandidatesJson( + labelsJson({ + defined: [ + { name: "bug", color: "d73a4a", description: "Something is broken" }, + { name: "size:XL", color: "e4572e", description: null }, + ], + applied: ["size:XL"], + }), + ), + ); + expect(list.candidates).toEqual([ + { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, + { name: "size:XL", color: "e4572e", description: null, isApplied: true }, + ]); + expect(list.truncated).toBe(false); + }); + + it("keeps a worn label the repository no longer defines, so it can be taken off", () => { + const list = expectSuccess( + decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), + ); + expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ + ["legacy", true], + ["bug", false], + ]); + }); + + it("says so when the repository defines more labels than the read asked for", () => { + expect( + expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) + .truncated, + ).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index e367b0a8c..ac2835a4c 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1,3 +1,4 @@ +import type { PullRequestLabelCandidate, PullRequestLabelCandidateList } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; @@ -2124,6 +2125,7 @@ export function buildReviewerRequestJson( * only read access can still be told apart from a passer-by. */ export interface GitHubViewerAccess { + readonly canTriage?: boolean; readonly canWrite: boolean; /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ readonly canUpdate: boolean; @@ -2171,6 +2173,9 @@ export function decodeViewerPermissionsJson( const repository = decoded.success.data.repository; return Result.succeed({ canWrite: toCanWrite(repository.viewerPermission), + canTriage: + repository.viewerPermission?.trim().toUpperCase() === "TRIAGE" || + toCanWrite(repository.viewerPermission), ...toPullRequestViewerFields(repository.pullRequest), }); } @@ -2313,3 +2318,96 @@ export function decodePullRequestStacksJson( })), }); } + +export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { + pageInfo { hasNextPage } + nodes { name color description } + } + pullRequest(number: $number) { + labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } + } + } +}`; + +const RawLabelCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + ...RawLabelSchema.fields, + description: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + labels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), + ), + }), + ), + }), + }), +}); + +const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); + +/** + * The repository's labels, with the ones already on this pull request marked. A label the pull + * request wears that the repository no longer defines — deleted since, or past the page — leads + * the list anyway, because a label that cannot be seen cannot be taken off. + */ +export function decodeLabelCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeLabelCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const applied = new Set( + (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null ? [] : [name]; + }), + ); + const candidates = new Map(); + for (const node of repository.labels?.nodes ?? []) { + const name = trimmed(node?.name); + if (name === null) continue; + candidates.set(name, { + name, + color: trimmed(node?.color), + description: trimmed(node?.description), + isApplied: applied.has(name), + }); + } + const missing = [...applied].filter((name) => !candidates.has(name)); + return Result.succeed({ + candidates: [ + ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), + ...candidates.values(), + ], + truncated: repository.labels?.pageInfo?.hasNextPage === true, + }); +} + +/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ +const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) }); + +const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); + +export function buildLabelRequestJson(labels: ReadonlyArray): string { + return encodeLabelRequest({ labels }); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6d99cc652..4a20b41d0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2139,6 +2139,16 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsLabelCandidates]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsLabelCandidates, + pullRequests.labelCandidates(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsSetLabels]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsReviewerCandidates]: (input) => observeRpcEffect( WS_METHODS.pullRequestsReviewerCandidates, diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 79de8b53a..dc4b1e2e6 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -2006,6 +2006,25 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, return detail } + func pullRequestLabelCandidates(threadID: String, number: Int) async throws -> PullRequestLabelCandidateList { + let route = try threadRoute(for: threadID) + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + return try await route.client.pullRequestLabelCandidates(projectID: project.id, repository: repository, number: number) + } + + func setPullRequestLabels(threadID: String, number: Int, labels: [String], applied: Bool) async throws { + let route = try threadRoute(for: threadID) + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + try await route.client.setPullRequestLabels(projectID: project.id, repository: repository, number: number, labels: labels, applied: applied) + pullRequestPreviewCache.removeAll(keepingCapacity: true) + } + func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? { let route = try threadRoute(for: threadID) guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { return nil } diff --git a/apps/swift-ios/Core/PullRequestModels.swift b/apps/swift-ios/Core/PullRequestModels.swift index 6e179f920..e9a0c092e 100644 --- a/apps/swift-ios/Core/PullRequestModels.swift +++ b/apps/swift-ios/Core/PullRequestModels.swift @@ -173,13 +173,28 @@ public struct PullRequestStack: Codable, Equatable, Sendable { } public struct NativePullRequestCapabilities: Codable, Equatable, Sendable { + public var labels: Bool? = nil public let actions: [String] public let mergeMethods: [String] public let updateMethods: [String]? } public struct NativePullRequestViewerPermissions: Codable, Equatable, Sendable { + public var labels: Bool? = nil public let stackRebase: Bool? public let actions: [String] public let updateMethods: [String]? } + +public struct PullRequestLabelCandidate: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let color: String? + public let description: String? + public let isApplied: Bool + public var id: String { name } +} + +public struct PullRequestLabelCandidateList: Codable, Equatable, Sendable { + public let candidates: [PullRequestLabelCandidate] + public let truncated: Bool +} diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index a58be442c..744e2451c 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -742,6 +742,19 @@ public actor T3Client { ) } + public func pullRequestLabelCandidates(projectID: String, repository: String, number: Int) async throws -> PullRequestLabelCandidateList { + try await rpc.request("pullRequests.labelCandidates", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + ]), as: PullRequestLabelCandidateList.self) + } + + public func setPullRequestLabels(projectID: String, repository: String, number: Int, labels: [String], applied: Bool) async throws { + let _: JSONValue = try await rpc.request("pullRequests.setLabels", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "labels": .array(labels.map { .string($0) }), "applied": .bool(applied), + ]), as: JSONValue.self) + } + public func pullRequestStack(projectID: String, repository: String, number: Int) async throws -> PullRequestStack? { try await rpc.request("pullRequests.stack", payload: .object([ "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), diff --git a/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift b/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift index 7773d6ff3..0463bf4ec 100644 --- a/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift +++ b/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift @@ -11,6 +11,8 @@ struct FeatureInlineVideoView: View { @State private var player: AVPlayer? @State private var failed = false + @State private var retryCount = 0 + @SwiftUI.Environment(\.scenePhase) private var scenePhase var body: some View { Group { @@ -27,6 +29,11 @@ struct FeatureInlineVideoView: View { .font(T3Typography.supporting) .lineLimit(2) .multilineTextAlignment(.center) + if failed { + Button("Retry video", systemImage: "arrow.clockwise") { retryCount += 1 } + .buttonStyle(.bordered) + Link("Open in browser", destination: url).font(T3Typography.supporting) + } } .foregroundStyle(T3Colors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -35,7 +42,16 @@ struct FeatureInlineVideoView: View { } } .aspectRatio(16 / 9, contentMode: .fit) - .task(id: url) { await preparePlayer() } + .task(id: "\(url.absoluteString):\(retryCount)") { await preparePlayer() } + .onChange(of: scenePhase) { _, phase in + if phase != .active { player?.pause() } + } + .onReceive(NotificationCenter.default.publisher(for: .AVPlayerItemFailedToPlayToEndTime)) { notice in + guard let item = notice.object as? AVPlayerItem, item === player?.currentItem else { return } + player?.pause() + player = nil + failed = true + } .onDisappear { player?.pause() player = nil @@ -59,7 +75,14 @@ struct FeatureInlineVideoView: View { return } try Task.checkCancellation() - player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + let prepared = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + let duration = try await asset.load(.duration).seconds + try Task.checkCancellation() + if duration.isFinite && duration > 0 && url.fragment == nil { + await prepared.seek(to: CMTime(seconds: min(0.1, duration / 2), preferredTimescale: 600)) + } + try Task.checkCancellation() + player = prepared } catch is CancellationError { return } catch { diff --git a/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift b/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift index 74def6dac..0f60c03f2 100644 --- a/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift +++ b/apps/swift-ios/Features/Chat/PullRequestDetailSheet.swift @@ -7,6 +7,7 @@ struct PullRequestDetailSheet: View { let threadID: String let number: Int + @State private var editingLabels = false @State private var selectedNumber: Int? @State private var stack: PullRequestStack? @State private var stackError: String? @@ -50,6 +51,9 @@ struct PullRequestDetailSheet: View { pendingStackAction = nil } } + .sheet(isPresented: $editingLabels, onDismiss: { Task { await load() } }) { + PullRequestLabelPickerSheet(client: client, threadID: threadID, number: displayedNumber) + } .accessibilityIdentifier("pull-request-detail-sheet") } @@ -197,11 +201,18 @@ struct PullRequestDetailSheet: View { .frame(maxWidth: .infinity, alignment: .leading) } - if !detail.labels.isEmpty { + if !detail.labels.isEmpty || detail.capabilities?.labels == true { section("Labels") { Text(detail.labels.map(\.name).joined(separator: " · ")) - .font(T3Typography.supporting) - .foregroundStyle(T3Colors.textSecondary) + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + if detail.capabilities?.labels == true { + Button("Change labels", systemImage: "tag") { editingLabels = true } + .disabled(detail.viewerPermissions?.labels != true) + if detail.viewerPermissions?.labels != true { + Text("Changing labels needs triage access on this repository.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + } } } diff --git a/apps/swift-ios/Features/Chat/PullRequestLabelPickerSheet.swift b/apps/swift-ios/Features/Chat/PullRequestLabelPickerSheet.swift new file mode 100644 index 000000000..299053f86 --- /dev/null +++ b/apps/swift-ios/Features/Chat/PullRequestLabelPickerSheet.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct PullRequestLabelPickerSheet: View { + let client: any FeatureClient + let threadID: String + let number: Int + @SwiftUI.Environment(\.dismiss) private var dismiss + @State private var result: PullRequestLabelCandidateList? + @State private var query = "" + @State private var pending: String? + @State private var loading = true + @State private var errorMessage: String? + + private var candidates: [PullRequestLabelCandidate] { + (result?.candidates ?? []).filter { + query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) + || ($0.description?.localizedCaseInsensitiveContains(query) ?? false) + } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { + Text(errorMessage).font(T3Typography.supporting).foregroundStyle(T3Colors.danger) + Button("Refresh labels") { Task { await load() } }.disabled(pending != nil) + } + if loading { + ProgressView().frame(maxWidth: .infinity) + } else if candidates.isEmpty { + Text(query.isEmpty ? "This repository has no labels." : "No matching labels.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } else { + ThreadDetailsSection(title: "Repository labels") { + ForEach(candidates) { candidate in + Button { toggle(candidate) } label: { + HStack(spacing: 10) { + Circle().fill(labelColor(candidate.color)).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 3) { + Text(candidate.name).font(T3Typography.threadBody) + if let description = candidate.description, !description.isEmpty { + Text(description).font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + } + Spacer(minLength: 8) + if pending == candidate.name { ProgressView() } + else if candidate.isApplied { Image(systemName: "checkmark").accessibilityLabel("Applied") } + } + .foregroundStyle(T3Colors.textPrimary).padding(12).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(pending != nil || errorMessage != nil) + .accessibilityValue(candidate.isApplied ? "Applied" : "Not applied") + } + } + } + if result?.truncated == true { + Text("This repository has more labels than are listed here. Apply the rest on the host.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + }.padding(16) + } + .background(T3Colors.background) + .navigationTitle("Change labels") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Search labels") + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() }.disabled(pending != nil) } } + .t3NavigationChrome() + .task { await load() } + } + .interactiveDismissDisabled(pending != nil) + } + + private func labelColor(_ value: String?) -> Color { + guard let value, value.count == 6, let hex = UInt32(value, radix: 16) else { return T3Colors.textSecondary } + return Color(red: Double((hex >> 16) & 255) / 255, green: Double((hex >> 8) & 255) / 255, blue: Double(hex & 255) / 255) + } + + private func load() async { + loading = true + errorMessage = nil + do { + let loaded = try await client.pullRequestLabelCandidates(threadID: threadID, number: number) + guard !Task.isCancelled else { return } + result = loaded + } catch { + guard !Task.isCancelled else { return } + errorMessage = error.localizedDescription + } + loading = false + } + + private func toggle(_ candidate: PullRequestLabelCandidate) { + guard pending == nil, errorMessage == nil else { return } + pending = candidate.name + Task { @MainActor in + do { + try await client.setPullRequestLabels(threadID: threadID, number: number, + labels: [candidate.name], applied: !candidate.isApplied) + await load() + } catch { errorMessage = error.localizedDescription } + pending = nil + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index 56d99e929..0075b119b 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -74,6 +74,8 @@ public protocol FeatureClient: AnyObject { ) async throws -> FeatureLinkedPullRequest? func addThreadPullRequest(threadID: String, number: Int) async throws -> FeatureLinkedPullRequest? func removeThreadPullRequest(threadID: String, link: FeatureLinkedPullRequest) async throws + func pullRequestLabelCandidates(threadID: String, number: Int) async throws -> PullRequestLabelCandidateList + func setPullRequestLabels(threadID: String, number: Int, labels: [String], applied: Bool) async throws func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? func runPullRequestStackAction(threadID: String, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws @@ -262,6 +264,8 @@ public extension FeatureClient { } func addThreadPullRequest(threadID: String, number: Int) async throws -> FeatureLinkedPullRequest? { throw FeatureCapabilityUnavailable("Multiple pull requests") } func removeThreadPullRequest(threadID: String, link: FeatureLinkedPullRequest) async throws { throw FeatureCapabilityUnavailable("Multiple pull requests") } + func pullRequestLabelCandidates(threadID: String, number: Int) async throws -> PullRequestLabelCandidateList { throw FeatureCapabilityUnavailable("Label editing") } + func setPullRequestLabels(threadID: String, number: Int, labels: [String], applied: Bool) async throws { throw FeatureCapabilityUnavailable("Label editing") } func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? { nil } func runPullRequestStackAction(threadID: String, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws { throw FeatureCapabilityUnavailable("Stack actions") } func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestLabels.json b/apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestLabels.json new file mode 100644 index 000000000..262883c59 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/pullRequestLabels.json @@ -0,0 +1,17 @@ +{ + "candidates": [ + { + "name": "bug", + "color": "d73a4a", + "description": "Something is broken", + "isApplied": true + }, + { + "name": "legacy", + "color": null, + "description": null, + "isApplied": false + } + ], + "truncated": true +} diff --git a/apps/swift-ios/Tests/CoreTests/PullRequestLabelTests.swift b/apps/swift-ios/Tests/CoreTests/PullRequestLabelTests.swift new file mode 100644 index 000000000..567ac691c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/PullRequestLabelTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import T3Code + +struct PullRequestLabelTests { + @Test func decodesContractLabelsIncludingMissingColorsAndTruncation() throws { + let fixture = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Fixtures/pullRequestLabels.json") + let list = try JSONDecoder().decode(PullRequestLabelCandidateList.self, from: Data(contentsOf: fixture)) + #expect(list.truncated) + #expect(list.candidates.map(\.name) == ["bug", "legacy"]) + #expect(list.candidates.first?.isApplied == true) + #expect(list.candidates.last?.color == nil) + #expect(list.candidates.last?.description == nil) + } + + @Test func olderPermissionsDoNotOfferLabelEditing() throws { + let data = Data(#"{"actions":[],"comment":true,"resolve":false,"verdicts":[],"requestReviewers":false}"#.utf8) + let viewer = try JSONDecoder().decode(NativePullRequestViewerPermissions.self, from: data) + #expect(viewer.labels == nil) + } +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index cee4a140b..c483fcbcf 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,5 @@ +import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; +import { pullRequestEnvironment } from "~/state/pullRequests"; import { CodexArtifactTemplateCard } from "./CodexArtifactTemplateCard"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { @@ -1935,8 +1937,13 @@ function createChatMarkdownComponents(context: ChatMarkdownComponentsContext): C const api = readLocalApi(); if (!api) return; const pullRequest = resolveThreadPullRequest(href); - const currentPullRequest = - threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; + const shell = threadRef === undefined ? null : readThreadShell(threadRef); + const links = + shell?.linkedPullRequests ?? + (shell?.linkedPullRequest ? [shell.linkedPullRequest] : []); + const currentPullRequest = links.find((link) => + matchesLinkedPullRequestUrl(link, href), + ); const threadLinkAction = currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) ? "unlink-from-thread" @@ -1994,6 +2001,16 @@ function createChatMarkdownComponents(context: ChatMarkdownComponentsContext): C if (!href || (!faviconHost && !unresolvedPathHref)) { return link; } + const pullRequest = resolveThreadPullRequest(href); + if (pullRequest && threadRef) { + return ( + + ); + } return ( @@ -2215,7 +2232,7 @@ function ChatMarkdown({ if ( threadRef === undefined || readThreadShell(threadRef) === null || - threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true + threadServerConfig?.environment.capabilities.threadPullRequestsV2 !== true ) { return null; } @@ -2235,28 +2252,42 @@ function ChatMarkdown({ }, [projects, threadRef, threadServerConfig], ); + const readPullRequestDetail = useAtomCommand(pullRequestEnvironment.readDetail, { + reportFailure: false, + }); const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { if (threadRef === undefined) return; - const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; - if (linked && linkedPullRequest === null) { - throw new Error("The pull request is not available in this environment."); + const shell = readThreadShell(threadRef); + const links = + shell?.linkedPullRequests ?? (shell?.linkedPullRequest ? [shell.linkedPullRequest] : []); + const link = linked + ? resolveThreadPullRequest(href) + : links.find((candidate) => matchesLinkedPullRequestUrl(candidate, href)); + if (!link) { + if (linked) throw new Error("The pull request is not available in this environment."); + return; } - if (!linked) { - const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; - if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { - return; - } + let confirmedLink = link; + if (linked) { + const detail = await readPullRequestDetail({ + environmentId: threadRef.environmentId, + input: link, + }); + if (detail._tag === "Failure") throw squashAtomCommandFailure(detail); + confirmedLink = { ...link, url: detail.value.url }; } const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, linkedPullRequest }, + input: { + threadId: threadRef.threadId, + ...(linked ? { linkPullRequest: confirmedLink } : { unlinkPullRequest: confirmedLink }), + }, }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) throw squashAtomCommandFailure(result); - } }, - [resolveThreadPullRequest, threadRef, updateThreadMetadata], + [resolveThreadPullRequest, threadRef, updateThreadMetadata, readPullRequestDetail], ); const openExternalLinkInPreview = useCallback( (url: string) => { diff --git a/apps/web/src/components/chat/MarkdownMedia.tsx b/apps/web/src/components/chat/MarkdownMedia.tsx index b95f41a29..37f4fe566 100644 --- a/apps/web/src/components/chat/MarkdownMedia.tsx +++ b/apps/web/src/components/chat/MarkdownMedia.tsx @@ -1,3 +1,13 @@ +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; +import { MediaActions } from "../media/MediaActions"; +import { + mediaFileReference, + mediaUrlReference, + type MediaReference, +} from "@t3tools/client-runtime/media-reference"; +import { assetEnvironment } from "../../state/assets"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts"; import { isWorkspaceVideoPreviewPath } from "@t3tools/shared/filePreview"; import { memo, useState } from "react"; @@ -80,42 +90,69 @@ function MediaUnavailable({ name }: { name: string }) { ); } -function ResolvedMedia({ url, name, isVideo }: { url: string; name: string; isVideo: boolean }) { +function ResolvedMedia({ + url, + name, + isVideo, + reference, + asset, + onRetry, +}: { + url: string; + name: string; + isVideo: boolean; + reference?: MediaReference; + asset?: { environmentId: ScopedThreadRef["environmentId"]; resource: AssetResource }; + onRetry?: () => Promise; +}) { const [expanded, setExpanded] = useState(false); const [failedUrl, setFailedUrl] = useState(null); - if (failedUrl === url) { - return ; - } if (isVideo) { return ( -
); @@ -1166,7 +1213,7 @@ export function PullRequestCodeTab({ {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning inside an overflowing element tracks the content's bottom edge, which would carry the trigger away with the first scroll. */} -
+
{body}
{reviewOverlay}
@@ -1310,58 +1357,94 @@ export function PullRequestCodeTab({ ) : null} - {/* Relative wrapper so the review overlay floats over the diff rather than pushing it +
+ {/* Relative wrapper so the review overlay floats over the diff rather than pushing it up; the viewer inside still owns its own scrolling. */} -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // A control inside the header — the collapse chevron — handles itself, and - // this capture listener fires before its own click does. Leave it alone or - // the two toggles cancel out. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { - return; - } - if (node.hasAttribute("data-diffs-header")) { - const filePath = node.querySelector("[data-title]")?.textContent?.trim(); - if (filePath === undefined || filePath === "") return; - const item = items.find( - (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, - ); - if (item !== undefined) toggleFile(item.id); - return; +
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // A control inside the header — the collapse chevron — handles itself, and + // this capture listener fires before its own click does. Leave it alone or + // the two toggles cancel out. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + if (node.hasAttribute("data-diffs-header")) { + const filePath = node.querySelector("[data-title]")?.textContent?.trim(); + if (filePath === undefined || filePath === "") return; + const item = items.find( + (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, + ); + if (item !== undefined) toggleFile(item.id); + return; + } } - } - }} - > - {/* The viewer virtualizes against the element it is told is scrolling and places its + }} + > + {/* The viewer virtualizes against the element it is told is scrolling and places its rows absolutely, so it has to own that element — the thread diff panel hands it the same one. Scrolling from a parent instead leaves it painting over its neighbours. */} - - // Keep scrollbar space stable so file metadata and line numbers do not shift as a - // diff crosses the overflow boundary. The viewer is itself focusable for keyboard - // interaction, but its native host outline clips and competes with the focus - // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" - items={items} - selectedLines={selectedLines} - onSelectedLinesChange={setSelectedLines} - options={diffViewOptions} - // The viewer owns the scroll container, so the sentinel that asks for the next slice - // has to live inside it — at the end of the files, where reaching it means the reader - // is running out of diff. - renderCodeViewFooter={renderCodeViewFooter} - renderHeaderPrefix={renderHeaderPrefix} - renderHeaderMetadata={renderHeaderMetadata} - renderAnnotation={renderAnnotation} - unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} - /> - {reviewOverlay} + + viewerRef={setViewer} + // Keep scrollbar space stable so file metadata and line numbers do not shift as a + // diff crosses the overflow boundary. The viewer is itself focusable for keyboard + // interaction, but its native host outline clips and competes with the focus + // indicators on its actual controls. + className="h-full overflow-auto [scrollbar-gutter:stable]" + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={diffViewOptions} + // The viewer owns the scroll container, so the sentinel that asks for the next slice + // has to live inside it — at the end of the files, where reaching it means the reader + // is running out of diff. + renderCodeViewFooter={renderCodeViewFooter} + renderHeaderPrefix={renderHeaderPrefix} + renderHeaderMetadata={renderHeaderMetadata} + renderAnnotation={renderAnnotation} + unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} + /> + {reviewOverlay} +
+ {fileTreeOpen ? ( +
+ ) + } + /> + + ) : null}
{unstructured}
diff --git a/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx new file mode 100644 index 000000000..799a1e6b2 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx @@ -0,0 +1,132 @@ +/** + * Putting a label on, and taking one off, from the row that says which it already wears. + * + * The repository's labels are read only once this menu opens, for the reason the reviewer menu + * reads its people then: they are worth a request when somebody wants them and worth nothing on + * every pull request they merely open. + */ +import type { EnvironmentId, PullRequestLabelCandidate, PullRequestRef } from "@t3tools/contracts"; +import { CheckIcon, TagIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; + +import { toastManager } from "../ui/toast"; +import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; +import { readableFailure } from "./pullRequestDetail.logic"; +import { pullRequestLabelColor } from "./pullRequestList.logic"; + +/** Narrows only what arrived: the host is asked once, when the menu opens. */ +function matches(candidate: PullRequestLabelCandidate, query: string): boolean { + if (query.length === 0) return true; + const needle = query.toLowerCase(); + return ( + candidate.name.toLowerCase().includes(needle) || + (candidate.description ?? "").toLowerCase().includes(needle) + ); +} + +export function PullRequestLabelPicker({ + environmentId, + reference, + allowed, + onChanged, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + /** False where the host would refuse this account's change. Disabled with the reason rather + * than hidden, like the reviewer control beside it. */ + allowed: boolean; + /** The detail carries the labels, so it is re-read once the host has taken the change. */ + onChanged: () => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [pending, setPending] = useState(null); + + // Mounted with the menu closed, so nothing is asked of the host until it opens. + const candidatesQuery = useEnvironmentQuery( + open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null, + ); + const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { reportFailure: false }); + + const candidates = useMemo( + () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)), + [candidatesQuery.data, query], + ); + + const toggle = async (candidate: PullRequestLabelCandidate) => { + if (pending !== null) return; + setPending(candidate.name); + const result = await setLabels({ + environmentId, + input: { ...reference, labels: [candidate.name], applied: !candidate.isApplied }, + }); + setPending(null); + onChanged(); + candidatesQuery.refresh(); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: candidate.isApplied + ? `Could not take ${candidate.name} off` + : `Could not put ${candidate.name} on`, + description: readableFailure( + squashAtomCommandFailure(result), + "The host refused it. Check that you have triage access on this repository.", + ), + }); + return; + } + }; + + return ( + } + label="Change labels" + allowed={allowed} + disabledReason="Changing labels needs triage access on this repository" + open={open} + onOpenChange={setOpen} + query={query} + onQueryChange={setQuery} + searchLabel="Search labels" + isPending={candidatesQuery.isPending} + error={candidatesQuery.error} + candidates={candidates} + emptyLabel="This repository has no labels." + noMatchLabel="No label matches that." + errorLabel="The labels could not be read." + truncated={candidatesQuery.data?.truncated === true} + truncatedLabel="This repository has more labels than are listed here. Apply the rest on the host." + candidateKey={(candidate) => candidate.name} + disabled={pending !== null} + onSelect={(candidate) => void toggle(candidate)} + > + {(candidate) => { + const dot = pullRequestLabelColor(candidate.color); + return ( + <> + + + {candidate.name} + {candidate.description ? ( + · {candidate.description} + ) : null} + + {candidate.isApplied ? ( + + ) : null} + + ); + }} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx new file mode 100644 index 000000000..2bc16afe0 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -0,0 +1,83 @@ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { useState, type ComponentPropsWithoutRef, type ReactElement } from "react"; + +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; +import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation"; + +interface PullRequestLinkPreviewTarget { + readonly environmentId: EnvironmentId; + readonly input: PullRequestRef; +} + +type PullRequestLinkElement = ReactElement>; + +export function PullRequestLinkPreview({ + link, + originalUrl, + target, +}: { + link: PullRequestLinkElement; + originalUrl: string; + target: PullRequestLinkPreviewTarget; +}) { + const [open, setOpen] = useState(false); + const detailQuery = useEnvironmentQuery( + open + ? pullRequestEnvironment.detail({ + environmentId: target.environmentId, + input: target.input, + }) + : null, + ); + const detail = detailQuery.data; + const state = + detail === null + ? null + : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }); + const authorLabel = + detail?.author === null + ? "ghost" + : detail?.author.name && detail.author.name !== detail.author.login + ? `${detail.author.name} (@${detail.author.login})` + : (detail?.author.login ?? null); + + return ( + + + + {detail === null ? ( +

+ {detailQuery.isPending ? "Loading pull request details…" : originalUrl} +

+ ) : ( +
+
+ {detail.repository} + #{detail.number} + · + {state === null ? null : ( + + + {state.label} + + )} +
+

+ {detail.title} +

+
+ + {authorLabel} + · + opened {formatRelativeTimeLabel(detail.createdAt)} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 7ae6a0a78..de7a96d12 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -39,6 +39,7 @@ import { pullRequestReviewOutcomeRingClassName, pullRequestReviewOutcomeStaleLabel, } from "./pullRequestPresentation"; +import { PullRequestLabelPicker } from "./PullRequestLabelPicker"; import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { @@ -622,7 +623,7 @@ export function PullRequestSummaryTab({ ) : null} - {detail.labels.length > 0 ? ( + {detail.labels.length > 0 || detail.capabilities.labels === true ? ( } label="Labels"> {detail.labels.map((label) => { @@ -641,6 +642,14 @@ export function PullRequestSummaryTab({ ); })} + {detail.capabilities.labels === true ? ( + + ) : null} ) : null} diff --git a/apps/web/src/components/ui/preview-card.tsx b/apps/web/src/components/ui/preview-card.tsx new file mode 100644 index 000000000..e0e1e67b3 --- /dev/null +++ b/apps/web/src/components/ui/preview-card.tsx @@ -0,0 +1,44 @@ +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; + +import { cn } from "~/lib/utils"; + +const PreviewCard = PreviewCardPrimitive.Root; + +function PreviewCardTrigger(props: PreviewCardPrimitive.Trigger.Props) { + return ; +} + +function PreviewCardPopup({ + className, + align = "start", + side = "top", + sideOffset = 6, + ...props +}: PreviewCardPrimitive.Popup.Props & { + align?: PreviewCardPrimitive.Positioner.Props["align"]; + side?: PreviewCardPrimitive.Positioner.Props["side"]; + sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"]; +}) { + return ( + + + + + + ); +} + +export { PreviewCard, PreviewCardPopup, PreviewCardTrigger }; diff --git a/apps/web/src/lib/videoFirstFrame.test.ts b/apps/web/src/lib/videoFirstFrame.test.ts new file mode 100644 index 000000000..f090a503c --- /dev/null +++ b/apps/web/src/lib/videoFirstFrame.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { prepareVideoFirstFrame } from "./videoFirstFrame"; + +type PreviewVideo = Parameters[0]; + +function previewVideo(overrides: Partial = {}): PreviewVideo { + return { + autoplay: false, + paused: true, + seeking: false, + currentTime: 0, + duration: 5, + played: { length: 0, start: () => 0, end: () => 0 }, + src: "https://environment.test/api/assets/signed/video.mp4?signature=example", + ...overrides, + }; +} + +describe("prepareVideoFirstFrame", () => { + it.each([ + [5, 0.1], + [0.05, 0.025], + ])( + "seeks within a %s second video only once when metadata repeats", + (duration, expectedPosition) => { + const video = previewVideo({ duration }); + const seeks: number[] = []; + Object.defineProperty(video, "currentTime", { + get: () => seeks.at(-1) ?? 0, + set: (value: number) => seeks.push(value), + }); + prepareVideoFirstFrame(video); + prepareVideoFirstFrame(video); + + expect(seeks).toEqual([expectedPosition]); + }, + ); + + it.each>([ + { autoplay: true }, + { paused: false }, + { seeking: true }, + { currentTime: 2 }, + { played: { length: 1, start: () => 0, end: () => 2 } }, + { duration: 0 }, + { duration: Number.POSITIVE_INFINITY }, + ])("does not seek over playback or unavailable metadata: %j", (state) => { + const video = previewVideo(state); + const position = video.currentTime; + + prepareVideoFirstFrame(video); + + expect(video.currentTime).toBe(position); + }); + + it.each([ + ["video.mp4#t=0,4", 0], + ["video.mp4#xywh=0,0,100,100&%74=3", 0], + ["video%23t=3.mp4", 0.1], + ])( + "distinguishes temporal fragments from encoded filename hashes: %s", + (path, expectedPosition) => { + const video = previewVideo({ src: `https://environment.test/${path}` }); + + prepareVideoFirstFrame(video); + + expect(video.currentTime).toBe(expectedPosition); + }, + ); + + it("tolerates a browser rejecting the preview seek", () => { + const video = previewVideo(); + Object.defineProperty(video, "currentTime", { + get: () => 0, + set: () => { + throw new Error("The stream is not seekable yet"); + }, + }); + + expect(() => prepareVideoFirstFrame(video)).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/videoFirstFrame.ts b/apps/web/src/lib/videoFirstFrame.ts new file mode 100644 index 000000000..07acdbbc9 --- /dev/null +++ b/apps/web/src/lib/videoFirstFrame.ts @@ -0,0 +1,28 @@ +/** Requests an initial frame without playing or replacing the video's streaming source. */ +export function prepareVideoFirstFrame( + video: Pick< + HTMLVideoElement, + "autoplay" | "currentTime" | "duration" | "paused" | "played" | "seeking" | "src" + >, +): void { + if ( + video.autoplay || + !video.paused || + video.seeking || + video.currentTime !== 0 || + video.played.length > 0 || + !Number.isFinite(video.duration) || + video.duration <= 0 + ) { + return; + } + + const fragment = video.src.split("#", 2)[1]; + if (fragment && new URLSearchParams(fragment).has("t")) return; + + try { + video.currentTime = Math.min(0.1, video.duration / 2); + } catch { + // A rejected preview seek must leave the native Play control usable. + } +} diff --git a/apps/web/src/state/use-atom-query-runner.ts b/apps/web/src/state/use-atom-query-runner.ts index 22f971e09..691b1f43c 100644 --- a/apps/web/src/state/use-atom-query-runner.ts +++ b/apps/web/src/state/use-atom-query-runner.ts @@ -1,7 +1,7 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomCommandOptions, + type AtomQueryOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, - options?: string | AtomCommandOptions, + options?: string | AtomQueryOptions, ): (target: T) => Promise> { const registry = useContext(RegistryContext); const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const refresh = typeof options === "string" ? false : (options?.refresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + refresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, registry, refresh, reportDefect, reportFailure], ); } diff --git a/docs/user/composer.md b/docs/user/composer.md index 2c79b4cfa..11afb76dc 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -153,3 +153,8 @@ the gallery offers **Share original** and **Save image**. Native iOS remembers **Fast** on or off separately for each environment and provider account. The choice follows new tasks when the selected model supports that option. + +If an inline video fails to load, choose **Retry video**. Videos pause when the app +moves into the background. Web and desktop media menus offer save actions; image +menus also offer **Copy image** when the browser supports it. These actions may need +the environment to be connected or the remote host to allow browser downloads. diff --git a/docs/user/linked-pull-requests.md b/docs/user/linked-pull-requests.md index f2fe26172..52286ae72 100644 --- a/docs/user/linked-pull-requests.md +++ b/docs/user/linked-pull-requests.md @@ -29,3 +29,8 @@ changing the request on its host. In GitHub PR details, open **Stack** to browse layers, refresh their state, or review a merge/rebase. The confirmation captures the revisions you reviewed. A failure requires closing and refreshing before another attempt. + +Choose **Change labels** in a GitHub PR’s summary to search repository labels and +apply or remove them. Applied labels show a checkmark. Editing needs triage access +and a server that supports it. The list loads when opened; if the repository has +more labels than the list can show, use GitHub for the remainder. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 037ca9d79..c4bbebd51 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -214,6 +214,14 @@ "./work-log/command-label": { "types": "./src/work-log/commandLabel.ts", "default": "./src/work-log/commandLabel.ts" + }, + "./media-reference": { + "types": "./src/mediaReference.ts", + "default": "./src/mediaReference.ts" + }, + "./media-actions": { + "types": "./src/mediaActions.ts", + "default": "./src/mediaActions.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts new file mode 100644 index 000000000..e0256dff9 --- /dev/null +++ b/packages/client-runtime/src/mediaActions.ts @@ -0,0 +1,8 @@ +/** Menu action ids shared by every client so labels and handlers line up across surfaces. */ +export type MediaActionId = + | "copy-full-path" + | "copy-relative-path" + | "copy-url" + | "open-file" + | "save" + | "copy-image"; diff --git a/packages/client-runtime/src/mediaReference.test.ts b/packages/client-runtime/src/mediaReference.test.ts new file mode 100644 index 000000000..236953ad8 --- /dev/null +++ b/packages/client-runtime/src/mediaReference.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mediaFileReference, mediaReferenceFileName } from "./mediaReference.ts"; + +describe("mediaFileReference", () => { + it.each([ + ["/work/project/./media/../clip.mp4", "/work/project/", "clip.mp4"], + ["/work/project/../outside.mp4", "/work/project", undefined], + ["/work/project-other/clip.mp4", "/work/project", undefined], + ["/work/Project/clip.mp4", "/work/project", undefined], + ["/work/project/a\\b.mp4", "/work/project", "a\\b.mp4"], + ["/work/clip.mp4", "/work/other/..", "clip.mp4"], + ["/clip.mp4", "/", "clip.mp4"], + ["C:\\WORK\\project\\media\\..\\Clip.mp4", "c:/work/project/", "Clip.mp4"], + ["D:\\work\\clip.mp4", "C:\\work", undefined], + ["\\\\Server\\Share\\Project\\Clip.mp4", "//server/share/project", "Clip.mp4"], + ["\\\\server\\other\\clip.mp4", "\\\\server\\share", undefined], + ["../clip.mp4", "/work/project", undefined], + ])("preserves %s and only labels paths inside %s as relative", (path, root, relativePath) => { + expect(mediaFileReference(path, root)).toEqual({ + kind: "file", + path, + ...(relativePath === undefined ? {} : { relativePath }), + }); + }); +}); + +describe("mediaReferenceFileName", () => { + it.each([ + [{ kind: "file", path: "/tmp/take\\one%20.mp4" }, "take\\one%20.mp4"], + [{ kind: "file", path: "C:\\clips/take\\one.mp4" }, "one.mp4"], + [{ kind: "file", path: "\\\\server\\share\\one.mp4" }, "one.mp4"], + [{ kind: "url", url: "//cdn.example/clip%20one%2Emp4?sig=a+b#t=2" }, "clip one.mp4"], + [{ kind: "url", url: "https://cdn.example/clip%2520.mp4" }, "clip%20.mp4"], + [{ kind: "url", url: "https://cdn.example/clip%20%oops.mp4" }, "clip%20%oops.mp4"], + ] as const)("preserves filename semantics for %j", (reference, name) => { + expect(mediaReferenceFileName(reference)).toBe(name); + }); +}); diff --git a/packages/client-runtime/src/mediaReference.ts b/packages/client-runtime/src/mediaReference.ts new file mode 100644 index 000000000..3ae0046aa --- /dev/null +++ b/packages/client-runtime/src/mediaReference.ts @@ -0,0 +1,98 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** The authored media location, never the temporary URL used to load its bytes. */ +export type MediaReference = + | { + readonly kind: "file"; + readonly path: string; + readonly relativePath?: string; + } + | { readonly kind: "url"; readonly url: string }; + +function absolutePathParts(path: string) { + const windows = isWindowsAbsolutePath(path) || path.startsWith("//"); + const normalized = windows ? path.replaceAll("\\", "/") : path; + const prefix = windows + ? /^(?:[a-z]:\/|\/\/[^/]+\/[^/]+(?:\/|$))/i.exec(normalized)?.[0] + : normalized.startsWith("/") + ? "/" + : undefined; + if (!prefix) return undefined; + + const segments: string[] = []; + for (const segment of normalized.slice(prefix.length).split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") segments.pop(); + else segments.push(segment); + } + const root = prefix.replace(/\/$/, ""); + return { root: windows ? root.toLowerCase() : root, segments, windows }; +} + +/** Compares paths lexically for the copy menu; it does not resolve filesystem symlinks. */ +export function mediaFileReference( + path: string, + workspaceRoot?: string | null, +): Extract { + const target = absolutePathParts(path); + const workspace = workspaceRoot ? absolutePathParts(workspaceRoot) : undefined; + if ( + !target || + !workspace || + target.windows !== workspace.windows || + target.root !== workspace.root || + target.segments.length <= workspace.segments.length || + !workspace.segments.every((segment, index) => + workspace.windows + ? segment.toLowerCase() === target.segments[index]?.toLowerCase() + : segment === target.segments[index], + ) + ) { + return { kind: "file", path }; + } + return { + kind: "file", + path, + relativePath: target.segments.slice(workspace.segments.length).join("/"), + }; +} + +/** Pass the authored source, not a generated URL used by the media player. */ +export function mediaUrlReference( + url: string, +): Extract | undefined { + if (!/^(?:https?:\/\/|\/\/)/i.test(url)) return undefined; + try { + const parsed = new URL(url.startsWith("//") ? `https:${url}` : url); + return parsed.protocol === "http:" || parsed.protocol === "https:" + ? { kind: "url", url } + : undefined; + } catch { + return undefined; + } +} + +/** Local paths are already decoded; URL filename escapes are decoded exactly once. */ +export function mediaReferenceFileName(reference: MediaReference): string | undefined { + if (reference.kind === "file") { + const windows = isWindowsAbsolutePath(reference.path) || reference.path.startsWith("//"); + return reference.path.split(windows ? /[\\/]/ : "/").at(-1) || undefined; + } + + let basename: string | undefined; + try { + const url = reference.url; + basename = new URL(url.startsWith("//") ? `https:${url}` : url).pathname.split("/").at(-1); + } catch { + return undefined; + } + return basename ? safeDecodeURIComponent(basename) : undefined; +} diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 93e5902f3..307cf0d6e 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -200,6 +200,17 @@ export function createPullRequestEnvironmentAtoms( * for a minute, because who has access to a repository changes far more slowly than the * change request it is being read for. */ + labelCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:label-candidates", + tag: WS_METHODS.pullRequestsLabelCandidates, + staleTimeMs: 15_000, + }), + setLabels: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:set-labels", + tag: WS_METHODS.pullRequestsSetLabels, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), reviewerCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:reviewer-candidates", tag: WS_METHODS.pullRequestsReviewerCandidates, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index fa2516fa2..25ed2ed68 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -357,6 +357,7 @@ export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabili * buttons. */ export const PullRequestCapabilities = Schema.Struct({ + labels: Schema.optionalKey(Schema.Boolean), /** A unified patch can be fetched for the change request. */ diff: Schema.Boolean, /** A comment can be posted, and the conversation read back. */ @@ -406,6 +407,7 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; * offering one they may not use ends in the host's own refusal — which at least says why. */ export const PullRequestViewerPermissions = Schema.Struct({ + labels: Schema.optionalKey(Schema.Boolean), /** Stack rebases need write access even when the selected branch is not behind its base. */ stackRebase: Schema.optionalKey(Schema.Boolean), /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ @@ -1146,3 +1148,25 @@ export class PullRequestOperationError extends Schema.TaggedErrorClass Date: Thu, 10 Sep 2026 13:31:56 +0200 Subject: [PATCH 06/93] feat: add custom model pricing across web and Swift --- PATCH.md | 5 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/usage/UsageService.test.ts | 47 +- apps/server/src/usage/UsageService.ts | 29 +- apps/server/src/usage/usageAggregation.ts | 9 +- apps/server/src/usage/usagePricing.test.ts | 70 +++ apps/server/src/usage/usagePricing.ts | 40 +- apps/swift-ios/App/NativeFeatureClient.swift | 4 + apps/swift-ios/Core/Models.swift | 3 + apps/swift-ios/Core/ServerConfigModels.swift | 26 + .../Settings/SettingsModelPricesView.swift | 176 ++++++ .../Features/Settings/SettingsUsageView.swift | 6 + .../CoreTests/Fixtures/usageModelPrice.json | 5 + .../Tests/FeatureTests/ModelPriceTests.swift | 29 + .../settings/ProviderSettingsPanel.logic.ts | 167 ++++++ apps/web/src/components/usage/UsagePage.tsx | 18 + .../components/usage/UsagePriceOverrides.tsx | 554 ++++++++++++++++++ .../components/usage/usagePriceForm.test.ts | 73 +++ .../src/components/usage/usagePriceForm.ts | 62 ++ .../components/usage/usagePriceTable.test.ts | 170 ++++++ .../src/components/usage/usagePriceTable.ts | 97 +++ .../usage/usagePriceTargets.test.ts | 143 +++++ .../src/components/usage/usagePriceTargets.ts | 65 ++ docs/user/usage.md | 12 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/settings.ts | 20 + packages/shared/src/serverSettings.test.ts | 23 + packages/shared/src/serverSettings.ts | 7 + scripts/generate-swift-contract-fixtures.ts | 18 + 29 files changed, 1867 insertions(+), 13 deletions(-) create mode 100644 apps/swift-ios/Features/Settings/SettingsModelPricesView.swift create mode 100644 apps/swift-ios/Tests/CoreTests/Fixtures/usageModelPrice.json create mode 100644 apps/swift-ios/Tests/FeatureTests/ModelPriceTests.swift create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.ts create mode 100644 apps/web/src/components/usage/UsagePriceOverrides.tsx create mode 100644 apps/web/src/components/usage/usagePriceForm.test.ts create mode 100644 apps/web/src/components/usage/usagePriceForm.ts create mode 100644 apps/web/src/components/usage/usagePriceTable.test.ts create mode 100644 apps/web/src/components/usage/usagePriceTable.ts create mode 100644 apps/web/src/components/usage/usagePriceTargets.test.ts create mode 100644 apps/web/src/components/usage/usagePriceTargets.ts diff --git a/PATCH.md b/PATCH.md index 8eb25b592..1699badd1 100644 --- a/PATCH.md +++ b/PATCH.md @@ -623,3 +623,8 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera - Inline video recovery uses the fork's asset URLs and range streaming. Web previews preserve an active playhead across URL renewal, prepare a first frame only while idle, and provide explicit save/copy actions. Swift offers retry and pauses when backgrounded. + +- Custom usage prices are server-authoritative, keyed by exact model ID, with sparse + per-model replacement/deletion. Usage scans capture price settings and reprice cached + transcript records, including provider-reported costs. Web supports multi-environment + edits with per-environment retry; Swift exposes per-environment model-price editing. diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9671adcd1..f7b65ba5d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -165,6 +165,7 @@ export const make = Effect.gen(function* () { threadPullRequestLinking: true, threadPullRequestsV2: true, pullRequestStackActions: true, + usagePriceOverrides: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8fc86ee3d..697733651 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -19,7 +19,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; -function claudeLine(id: number, outputTokens: number): string { +function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", @@ -27,7 +27,7 @@ function claudeLine(id: number, outputTokens: number): string { sessionId: "session-1", message: { id: `msg_${id}`, - model: "claude-fable-5", + model, usage: { input_tokens: 10, output_tokens: outputTokens }, }, })}\n`; @@ -92,6 +92,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + + yield* Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + + const original = yield* service.readSummary(WINDOW); + assert.strictEqual(original.buckets[0]?.costUsd, 0); + assert.strictEqual(original.buckets[0]?.unpricedRecords, 1); + + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }, + }); + const overridden = yield* service.readSummary(WINDOW); + assert.closeTo(overridden.buckets[0]?.costUsd ?? -1, 0.00006, 1e-12); + assert.strictEqual(overridden.buckets[0]?.costSource, "modelPriced"); + assert.strictEqual(overridden.buckets[0]?.unpricedRecords, 0); + assert.deepStrictEqual(overridden.buckets[0]?.totals, original.buckets[0]?.totals); + + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 16 }, + }, + }); + const edited = yield* service.readSummary(WINDOW); + assert.closeTo(edited.buckets[0]?.costUsd ?? -1, 0.00012, 1e-12); + + yield* settingsService.updateSettings({ usagePriceOverrides: { "example-model": null } }); + const restored = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, original.buckets); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-price-overrides-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("counts appended usage on a rescan of a grown transcript", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 16a7478d9..d8b8d5168 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,3 +1,4 @@ +import type { UsageModelPriceOverride } from "@t3tools/contracts"; /** * UsageService - scans provider transcripts and returns priced usage buckets. * @@ -42,7 +43,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { UsageAggregator } from "./usageAggregation.ts"; -import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, readDirectoryVolumeId, @@ -376,7 +377,10 @@ export const make = Effect.gen(function* () { return scanned; }); - const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { + const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( + input: UsageSummaryInput, + prices: Readonly>, + ) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -436,6 +440,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, + priceOverrides: createOverrideRateTable(prices), }); const sources: UsageSource[] = []; @@ -531,7 +536,10 @@ export const make = Effect.gen(function* () { */ const inflightScans = new Map>(); - const scanKey = (input: UsageSummaryInput): string => + const scanKey = ( + input: UsageSummaryInput, + prices: Readonly>, + ): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -539,10 +547,21 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, + prices, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { - const key = scanKey(input); + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Server settings could not be read.", + cause, + }), + ), + ); + const key = scanKey(input, settings.usagePriceOverrides); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); @@ -554,7 +573,7 @@ export const make = Effect.gen(function* () { inflightScans.set(key, created); // Detached so one departing client cannot tear the scan out from under // the fibers awaiting it; a finished scan warms the cache either way. - yield* scanSummary(input).pipe( + yield* scanSummary(input, settings.usagePriceOverrides).pipe( Effect.onExit((exit) => Effect.sync(() => inflightScans.delete(key)).pipe( Effect.andThen(Deferred.done(created, exit)), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index e100be76e..01a1195ef 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -61,6 +61,7 @@ export interface AggregateOptions { readonly sinceDay: string; readonly untilDay: string; readonly rates: RateTable; + readonly priceOverrides?: RateTable; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -165,11 +166,17 @@ export class UsageAggregator { record.model, record.totals, record.reportedCostUsd, + this.#options.priceOverrides, ); bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; - bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); + bucket.cacheSavingsUsd += cacheSavingsUsd( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ); bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ea27375b..b4b687eff 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,3 +1,4 @@ +import { createOverrideRateTable, priceUsage, cacheSavingsUsd } from "./usagePricing.ts"; import { describe, expect, it } from "@effect/vitest"; import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; @@ -53,3 +54,72 @@ describe("usage pricing", () => { expect(lookupRate(table, "example-model")).toBeNull(); }); }); + +describe("custom model pricing", () => { + const totals = { + uncachedInputTokens: 1_000_000, + cachedInputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningTokens: 500_000, + }; + + it("uses custom token rates ahead of public and provider-reported costs", () => { + const table = parseRateTable({ "example-model": rate(1) }); + const overrides = createOverrideRateTable({ + "example-model": { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0.5, + cacheWriteCostPerMillionTokens: 3, + }, + }); + + for (const reportedCostUsd of [null, 99]) { + expect(priceUsage(table, "example-model", totals, reportedCostUsd, overrides)).toEqual({ + costUsd: 13.5, + costSource: "modelPriced", + }); + } + expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(1.5); + }); + + it("prices unknown models offline and uses input prices for omitted cache rates", () => { + const table = parseRateTable({}); + const overrides = createOverrideRateTable({ + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }); + + expect(priceUsage(table, "example-model", totals, null, overrides)).toEqual({ + costUsd: 14, + costSource: "modelPriced", + }); + expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(0); + }); + + it("preserves explicit zero rates and matches only the exact trimmed model ID", () => { + const table = parseRateTable({}); + const overrides = createOverrideRateTable({ + " vendor/example-model[1m] ": { + inputCostPerMillionTokens: 0, + outputCostPerMillionTokens: 0, + }, + }); + expect(priceUsage(table, " vendor/example-model[1m] ", totals, 99, overrides)).toEqual({ + costUsd: 0, + costSource: "modelPriced", + }); + for (const model of [ + "example-model[1m]", + "vendor/example-model", + "vendor/Example-model[1m]", + "other/example-model[1m]", + ]) { + expect(priceUsage(table, model, totals, null, overrides).costSource).toBe("unpriced"); + expect(priceUsage(table, model, totals, 99, overrides)).toEqual({ + costUsd: 99, + costSource: "providerReported", + }); + } + }); +}); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 3d7f5fd29..7739ede7d 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -7,7 +7,11 @@ * * @module usagePricing */ -import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; +import type { + UsageCostSource, + UsageTokenTotals, + UsageModelPriceOverride, +} from "@t3tools/contracts"; /** * The subset of a LiteLLM entry we price against. All values are USD per token. @@ -26,6 +30,25 @@ export interface ModelRate { export type RateTable = ReadonlyMap; +/** Custom IDs keep their case, provider prefix, and variant suffix. */ +export function createOverrideRateTable( + overrides: Readonly>, +): RateTable { + return new Map( + Object.entries(overrides).map(([model, prices]) => [ + model.trim(), + { + inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000, + outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000, + cacheReadCostPerToken: + (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + cacheCreationCostPerToken: + (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + }, + ]), + ); +} + /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ interface LiteLlmEntry { readonly input_cost_per_token?: unknown; @@ -158,12 +181,14 @@ export function priceUsage( model: string, totals: UsageTokenTotals, reportedCostUsd: number | null, + overrides?: RateTable, ): PricedUsage { - if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + const override = overrides?.get(model.trim()); + if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = lookupRate(table, model); + const rate = override ?? lookupRate(table, model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const costUsd = @@ -179,8 +204,13 @@ export function priceUsage( * What the cached input would have cost at full input rates, minus what it * actually cost. Drives the "cache savings" figure. */ -export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { - const rate = lookupRate(table, model); +export function cacheSavingsUsd( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): number { + const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index dc4b1e2e6..8823a26f2 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -6309,6 +6309,10 @@ extension NativeFeatureClient: FeatureServerSettingsManaging { environmentID: String, patch: ServerSettingsPatchInput ) async throws -> FeatureEnvironmentPreferences { + if patch.usagePriceOverrides != nil, + (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.usagePriceOverrides != true { + throw FeatureCapabilityUnavailable("Custom model pricing") + } let client = try await environmentClient(id: environmentID) let settings = try await client.updateServerSettings(patch: patch) // Fold the server's answer into the cached config now. The active diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index 0bc2d3815..d7b6bfe48 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -51,6 +51,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { /// sending a command the server will reject. public let threadPullRequestLinking: Bool? public let threadPullRequestsV2: Bool? + public let usagePriceOverrides: Bool? public let pullRequestStackActions: Bool? public let pullRequests: Bool? public let serverSelfUpdate: String? @@ -65,6 +66,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadTitleRegeneration case threadPullRequestLinking case threadPullRequestsV2 + case usagePriceOverrides case pullRequestStackActions case pullRequests case serverSelfUpdate @@ -86,6 +88,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { forKey: .threadTitleRegeneration ) threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) + usagePriceOverrides = try container.decodeIfPresent(Bool.self, forKey: .usagePriceOverrides) pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) threadPullRequestLinking = try container.decodeIfPresent( Bool.self, diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift index 61ea5b0d1..afefda66a 100644 --- a/apps/swift-ios/Core/ServerConfigModels.swift +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -245,7 +245,23 @@ public struct ProviderModelPreferencesSnapshot: Codable, Equatable, Sendable { /// New-thread preferences are server-authoritative, so every saved environment /// can resolve these differently even though they share one mobile client. +public struct UsageModelPriceOverride: Codable, Equatable, Sendable { + public let inputCostPerMillionTokens: Double + public let outputCostPerMillionTokens: Double + public let cacheReadCostPerMillionTokens: Double? + public let cacheWriteCostPerMillionTokens: Double? + + public var json: JSONValue { + var fields: [String: JSONValue] = ["inputCostPerMillionTokens": .number(inputCostPerMillionTokens), + "outputCostPerMillionTokens": .number(outputCostPerMillionTokens)] + if let cacheReadCostPerMillionTokens { fields["cacheReadCostPerMillionTokens"] = .number(cacheReadCostPerMillionTokens) } + if let cacheWriteCostPerMillionTokens { fields["cacheWriteCostPerMillionTokens"] = .number(cacheWriteCostPerMillionTokens) } + return .object(fields) + } +} + public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { + public let usagePriceOverrides: [String: UsageModelPriceOverride]? /// The default window matching `DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS` in /// `packages/contracts`, applied when a server predates the setting. public static let defaultSidebarAutoSettleAfterDays: Double = 3 @@ -284,6 +300,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public let defaultThemeSetAt: String public init( + usagePriceOverrides: [String: UsageModelPriceOverride]? = nil, defaultThreadEnvMode: ServerThreadEnvironmentMode = .local, newWorktreesStartFromOrigin: Bool = true, sidebarAutoSettleAfterDays: Double? = ServerSettingsSnapshot @@ -297,6 +314,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { defaultTheme: String = "", defaultThemeSetAt: String = "" ) { + self.usagePriceOverrides = usagePriceOverrides self.defaultThreadEnvMode = defaultThreadEnvMode self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin self.sidebarAutoSettleAfterDays = sidebarAutoSettleAfterDays @@ -309,6 +327,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { } private enum CodingKeys: String, CodingKey { + case usagePriceOverrides case defaultThreadEnvMode case newWorktreesStartFromOrigin case sidebarAutoSettleAfterDays @@ -333,6 +352,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + usagePriceOverrides = try container.decodeIfPresent([String: UsageModelPriceOverride].self, forKey: .usagePriceOverrides) defaultThreadEnvMode = try container.decode( ServerThreadEnvironmentMode.self, forKey: .defaultThreadEnvMode @@ -369,6 +389,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(usagePriceOverrides, forKey: .usagePriceOverrides) try container.encode(defaultThreadEnvMode, forKey: .defaultThreadEnvMode) try container.encode(newWorktreesStartFromOrigin, forKey: .newWorktreesStartFromOrigin) // Encoded as explicit null so "never" survives a round trip instead of @@ -396,6 +417,8 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { /// whatever another client changed in between. Add a field here — and one line /// to `json` — as each new server setting reaches this client. public struct ServerSettingsPatchInput: Equatable, Sendable { + /// A present nil entry resets one model. Omitted models are unchanged. + public var usagePriceOverrides: [String: UsageModelPriceOverride?]? public var enableAgentBrowserAccess: Bool? /// Claude's auto-compaction threshold, as the string the server validates: /// an integer from 100000 to 1000000, or empty to fall back to Claude's own @@ -404,10 +427,12 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { public var hiddenModelsByProvider: [String: [String]]? public init( + usagePriceOverrides: [String: UsageModelPriceOverride?]? = nil, enableAgentBrowserAccess: Bool? = nil, claudeAutoCompactWindow: String? = nil, hiddenModelsByProvider: [String: [String]]? = nil ) { + self.usagePriceOverrides = usagePriceOverrides self.enableAgentBrowserAccess = enableAgentBrowserAccess self.claudeAutoCompactWindow = claudeAutoCompactWindow self.hiddenModelsByProvider = hiddenModelsByProvider @@ -427,6 +452,7 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { ]), ]) } + if let usagePriceOverrides { fields["usagePriceOverrides"] = .object(usagePriceOverrides.mapValues { $0?.json ?? .null }) } if let hiddenModelsByProvider { fields["providerModelPreferences"] = .object(hiddenModelsByProvider.mapValues { .object(["hiddenModels": .array($0.map(JSONValue.string))]) diff --git a/apps/swift-ios/Features/Settings/SettingsModelPricesView.swift b/apps/swift-ios/Features/Settings/SettingsModelPricesView.swift new file mode 100644 index 000000000..a3616d1ac --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsModelPricesView.swift @@ -0,0 +1,176 @@ +import SwiftUI + +struct SettingsModelPricesView: View { + @Bindable var model: FeatureRootModel + @State private var environmentID = "" + @State private var prices: [String: UsageModelPriceOverride]? + @State private var loading = false + @State private var errorMessage: String? + @State private var draft: PriceDraft? + + private var manager: any FeatureServerSettingsManaging { + (model.client as? any FeatureServerSettingsManaging) ?? EmptyFeatureServerSettingsManager.shared + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Picker("Environment", selection: $environmentID) { + ForEach(model.snapshot.environments) { environment in + Text(environment.name).tag(environment.id) + } + }.disabled(draft != nil) + Text("Custom prices apply to past and future usage on this environment. Prices are USD per million tokens.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + if let errorMessage { SettingsErrorBanner(message: errorMessage) } + if loading { ProgressView().frame(maxWidth: .infinity) } + else if let prices { + ThreadDetailsSection(title: "Custom model prices", footer: "Blank cache rates use the input price. Enter 0 for free tokens. Model IDs are case-sensitive.") { + ForEach(prices.keys.sorted(), id: \.self) { name in + ThreadDetailsRow(systemImage: "dollarsign.circle", title: name, + subtitle: "Input $\(prices[name]!.inputCostPerMillionTokens.formatted()) · Output $\(prices[name]!.outputCostPerMillionTokens.formatted())", + action: { draft = PriceDraft(model: name, price: prices[name]) }) + } + ThreadDetailsRow(systemImage: "plus", title: "Add model price", action: { draft = PriceDraft() }) + } + } else if !loading && errorMessage == nil { + Text("Connect an environment with model pricing support to edit prices.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + }.padding(18) + } + .background(T3Colors.background) + .navigationTitle("Model prices") + .navigationBarTitleDisplayMode(.inline) + .onAppear { if environmentID.isEmpty { environmentID = model.snapshot.environments.first?.id ?? "" } } + .task(id: environmentID) { await load() } + .refreshable { await load() } + .sheet(item: $draft, onDismiss: { Task { await load() } }) { initial in + ModelPriceEditor(initial: initial, existingModels: Set(prices?.keys.map { $0 } ?? [])) { name, price in + try await manager.updateServerSettings(environmentID: environmentID, + patch: ServerSettingsPatchInput(usagePriceOverrides: [name: price])) + draft = nil + } + } + } + + private func load() async { + let requestedID = environmentID + guard !requestedID.isEmpty else { return } + prices = nil + errorMessage = nil + loading = true + do { + let config = try await manager.providerModelConfiguration(environmentID: requestedID) + guard !Task.isCancelled, environmentID == requestedID else { return } + prices = config.settings?.usagePriceOverrides + } catch { + guard !Task.isCancelled, environmentID == requestedID else { return } + errorMessage = error.localizedDescription + } + loading = false + } +} + +struct PriceDraft: Identifiable { + let id = UUID() + var model: String + let isNew: Bool + var input: String + var output: String + var cacheRead: String + var cacheWrite: String + + init(model: String = "", price: UsageModelPriceOverride? = nil) { + self.model = model + isNew = price == nil + input = price.map { String($0.inputCostPerMillionTokens) } ?? "" + output = price.map { String($0.outputCostPerMillionTokens) } ?? "" + cacheRead = price?.cacheReadCostPerMillionTokens.map { String($0) } ?? "" + cacheWrite = price?.cacheWriteCostPerMillionTokens.map { String($0) } ?? "" + } + + var parsed: UsageModelPriceOverride? { + func number(_ text: String) -> Double? { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(text), value.isFinite, value >= 0 else { return nil } + return value + } + guard !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let input = number(input), let output = number(output) else { return nil } + let read = cacheRead.trimmingCharacters(in: .whitespacesAndNewlines) + let write = cacheWrite.trimmingCharacters(in: .whitespacesAndNewlines) + guard read.isEmpty || number(read) != nil, write.isEmpty || number(write) != nil else { return nil } + return UsageModelPriceOverride(inputCostPerMillionTokens: input, outputCostPerMillionTokens: output, + cacheReadCostPerMillionTokens: number(read), cacheWriteCostPerMillionTokens: number(write)) + } +} + +private struct ModelPriceEditor: View { + @State var draft: PriceDraft + let existingModels: Set + let save: (String, UsageModelPriceOverride?) async throws -> Void + @State private var busy = false + @State private var errorMessage: String? + @SwiftUI.Environment(\.dismiss) private var dismiss + + init(initial: PriceDraft, existingModels: Set, save: @escaping (String, UsageModelPriceOverride?) async throws -> Void) { + _draft = State(initialValue: initial) + self.existingModels = existingModels + self.save = save + } + + private var valid: Bool { + draft.parsed != nil && (!draft.isNew || !existingModels.contains(draft.model.trimmingCharacters(in: .whitespacesAndNewlines))) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + field("Model ID", value: $draft.model, numeric: false).disabled(!draft.isNew || busy) + field("Input", value: $draft.input) + field("Output", value: $draft.output) + field("Cache read (optional)", value: $draft.cacheRead) + field("Cache write (optional)", value: $draft.cacheWrite) + Text("USD per million tokens. Blank cache rates use the input rate; 0 means free.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + if !valid { Text("Use a unique model ID and non-negative input and output prices.").font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) } + if let errorMessage { SettingsErrorBanner(message: errorMessage) } + SettingsActionButton(title: "Save price", systemImage: "checkmark", tone: .primary, + isBusy: busy, isDisabled: !valid, action: { perform(reset: false) }) + if !draft.isNew { + Button("Reset to automatic pricing") { perform(reset: true) }.disabled(busy) + } + }.padding(18) + } + .background(T3Colors.background) + .navigationTitle(draft.isNew ? "Add model price" : "Edit model price") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.disabled(busy) } } + .t3NavigationChrome() + }.interactiveDismissDisabled(busy) + } + + private func field(_ title: String, value: Binding, numeric: Bool = true) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + TextField(title, text: value).textFieldStyle(.roundedBorder) + .keyboardType(numeric ? .decimalPad : .default) + .textInputAutocapitalization(.never).autocorrectionDisabled().disabled(busy) + } + } + + private func perform(reset: Bool) { + guard !busy, reset || valid else { return } + busy = true + errorMessage = nil + let name = draft.model.trimmingCharacters(in: .whitespacesAndNewlines) + let price = reset ? nil : draft.parsed + Task { @MainActor in + do { try await save(name, price) } + catch { errorMessage = error.localizedDescription } + busy = false + } + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsUsageView.swift b/apps/swift-ios/Features/Settings/SettingsUsageView.swift index 101aca7f2..6f277b880 100644 --- a/apps/swift-ios/Features/Settings/SettingsUsageView.swift +++ b/apps/swift-ios/Features/Settings/SettingsUsageView.swift @@ -43,6 +43,12 @@ public struct SettingsUsageView: View { .padding(.horizontal, SettingsMetrics.cardInset) if showsLimits { SettingsUsageLimitsView(model: model, refreshTrigger: limitsRefreshID) } else { + NavigationLink { + SettingsModelPricesView(model: model).onDisappear { Task { await reload() } } + } label: { + Label("Model prices", systemImage: "dollarsign.circle") + .font(T3Typography.threadBody).padding(.horizontal, SettingsMetrics.cardInset) + } windowSection switch state { diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/usageModelPrice.json b/apps/swift-ios/Tests/CoreTests/Fixtures/usageModelPrice.json new file mode 100644 index 000000000..f0db2cc73 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/usageModelPrice.json @@ -0,0 +1,5 @@ +{ + "inputCostPerMillionTokens": 2, + "outputCostPerMillionTokens": 8, + "cacheReadCostPerMillionTokens": 0 +} diff --git a/apps/swift-ios/Tests/FeatureTests/ModelPriceTests.swift b/apps/swift-ios/Tests/FeatureTests/ModelPriceTests.swift new file mode 100644 index 000000000..6a2ec0d40 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ModelPriceTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import T3Code + +struct ModelPriceTests { + @Test func decodesExactContractRatesAndEncodesOnlyTheEditedModel() throws { + let fixture = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("CoreTests/Fixtures/usageModelPrice.json") + let price = try JSONDecoder().decode(UsageModelPriceOverride.self, from: Data(contentsOf: fixture)) + #expect(price.cacheReadCostPerMillionTokens == 0) + #expect(price.cacheWriteCostPerMillionTokens == nil) + let patch = ServerSettingsPatchInput(usagePriceOverrides: ["Vendor/Model": price]) + #expect(patch.json["usagePriceOverrides"]?["Vendor/Model"] == price.json) + #expect(ServerSettingsPatchInput(usagePriceOverrides: ["Vendor/Model": nil]).json["usagePriceOverrides"]?["Vendor/Model"] == JSONValue.null) + } + + @Test func validatesRatesAndKeepsZeroDifferentFromBlank() { + var draft = PriceDraft(model: "Vendor/Model") + draft.input = "2" + draft.output = "8" + #expect(draft.parsed?.cacheReadCostPerMillionTokens == nil) + draft.cacheRead = "0" + #expect(draft.parsed?.cacheReadCostPerMillionTokens == 0) + for invalid in ["-1", "nan", "inf", "invalid"] { + draft.input = invalid + #expect(draft.parsed == nil) + } + } +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts new file mode 100644 index 000000000..b415b5f69 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -0,0 +1,167 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type EnvironmentId, +} from "@t3tools/contracts"; + +export interface ProviderEnvironmentOptionLike { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function isProviderSettingsEnvironmentAvailable(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; +}): boolean { + return input.connectionPhase === "connected" && input.hasServerConfig; +} + +export function buildProviderEnvironmentOptions( + environments: ReadonlyArray, + primaryEnvironmentId: EnvironmentId | null, +): ReadonlyArray { + return environments.toSorted((left, right) => { + const leftIsPrimary = left.environmentId === primaryEnvironmentId; + const rightIsPrimary = right.environmentId === primaryEnvironmentId; + if (leftIsPrimary !== rightIsPrimary) { + return leftIsPrimary ? -1 : 1; + } + return ( + left.label.localeCompare(right.label) || + String(left.environmentId).localeCompare(String(right.environmentId)) + ); + }); +} + +export function resolveSelectedProviderEnvironmentId( + environments: ReadonlyArray, + selectedEnvironmentId: EnvironmentId | null, + primaryEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + if ( + selectedEnvironmentId !== null && + environments.some((environment) => environment.environmentId === selectedEnvironmentId) + ) { + return selectedEnvironmentId; + } + if ( + primaryEnvironmentId !== null && + environments.some((environment) => environment.environmentId === primaryEnvironmentId) + ) { + return primaryEnvironmentId; + } + return environments[0]?.environmentId ?? null; +} + +export type ProviderEnvironmentAccess = + | { readonly kind: "editable" } + /** `reason` distinguishes waiting on the device from waiting on permissions. */ + | { readonly kind: "loading"; readonly reason: "config" | "permissions" } + | { readonly kind: "read-only" } + | { readonly kind: "unavailable" } + | { readonly kind: "error" }; + +/** + * Whether the session may change provider configuration on an environment. + * `pending` means the answer is still unknown, which must not be presented as + * editable: rendering controls we already know might be rejected only turns a + * permission problem into a failed write. + */ +export type ProviderOperateAccess = "granted" | "denied" | "pending"; + +/** + * Resolve operate access from an environment's `/api/auth/session` answer. + * + * Cached session data wins over an in-flight revalidation. The session atoms + * are SWR-backed, so they report `isPending` on every background refresh; + * treating that as unknown would flip a working panel back to loading and + * discard in-progress edits. + * + * `missingScopesAccess` decides the case where the session resolved but did + * not report scopes: the primary serves the web app itself so its server + * always reports them (absence means denial), while a remote device may run an + * older server version that predates scope reporting, where denial would lock + * out a legitimate session. The environment RPC layer stays authoritative + * either way. + */ +function resolveSessionOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; + readonly missingScopesAccess: "granted" | "denied"; +}): ProviderOperateAccess { + if (input.session === null) { + if (input.isPending) { + return "pending"; + } + // A failed session fetch is a transport problem, not a permission + // decision — locking the panel read-only would misreport it. Stay + // optimistic; the environment RPC layer still rejects unauthorized writes. + return input.hasError ? "granted" : "denied"; + } + if (!input.session.authenticated) { + return "denied"; + } + if (input.session.scopes === undefined) { + return input.missingScopesAccess; + } + return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; +} + +/** Operate access for the primary environment's own browser session. */ +export function resolvePrimaryOperateAccess(input: { + readonly isPrimary: boolean; + readonly hasDesktopBridge: boolean; + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + if (!input.isPrimary || input.hasDesktopBridge) { + return "granted"; + } + return resolveSessionOperateAccess({ + session: input.session, + isPending: input.isPending, + hasError: input.hasError, + missingScopesAccess: "denied", + }); +} + +/** + * Operate access for a non-primary environment, derived from the scopes its + * `/api/auth/session` endpoint reports for this client's credential. + */ +export function resolveRemoteOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + return resolveSessionOperateAccess({ + ...input, + missingScopesAccess: "granted", + }); +} + +export function classifyProviderEnvironmentAccess(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; + readonly operateAccess: ProviderOperateAccess; +}): ProviderEnvironmentAccess { + if (input.connectionPhase === "error") { + return { kind: "error" }; + } + if (input.connectionPhase !== "connected") { + return { kind: "unavailable" }; + } + if (!input.hasServerConfig) { + return { kind: "loading", reason: "config" }; + } + if (input.operateAccess === "pending") { + return { kind: "loading", reason: "permissions" }; + } + if (input.operateAccess === "denied") { + return { kind: "read-only" }; + } + return { kind: "editable" }; +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 9e4838c0d..96068fde1 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,3 +1,4 @@ +import { UsagePriceOverrides } from "./UsagePriceOverrides"; import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -43,6 +44,7 @@ const WINDOW_OPTIONS = [ ] as const; export function UsagePage() { + const [pricesOpen, setPricesOpen] = useState(false); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), @@ -152,6 +154,9 @@ export function UsagePage() { ))} + @@ -195,6 +200,9 @@ export function UsagePage() { ))} + @@ -204,6 +212,16 @@ export function UsagePage() { return ( + {pricesOpen && ( + { + setPricesOpen(open); + if (!open) refresh(); + }} + /> + )}
{topbarContent} diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx new file mode 100644 index 000000000..9f3e5697b --- /dev/null +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -0,0 +1,554 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useRef, useState } from "react"; + +import { isElectron } from "../../env"; +import { cn } from "../../lib/utils"; +import { environmentPresentations } from "../../state/presentation"; +import { serverEnvironment } from "../../state/server"; +import { environmentSession } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { EnvironmentUsageStatus } from "../../state/usage"; +import { + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, +} from "../settings/ProviderSettingsPanel.logic"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { USAGE_PRICE_FIELDS } from "./usagePriceForm"; +import { + isEmptyUsagePriceDraft, + usagePriceCell, + usagePriceTableChanges, + usagePriceTableErrors, + type UsagePriceDraft, + type UsagePriceField, +} from "./usagePriceTable"; +import { + writeUsagePrices, + type UsagePriceTarget, + type UsagePriceWriteResult, +} from "./usagePriceTargets"; + +const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => + [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => { + const settings = get(serverEnvironment.settingsValueAtom(environmentId)); + const session = get(environmentSession.sessionStateAtom(environmentId)); + const sessionAccess = { + session: Option.getOrNull(AsyncResult.value(session)), + isPending: session.waiting, + hasError: session._tag === "Failure", + }; + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + const access = isPrimary + ? resolvePrimaryOperateAccess({ ...sessionAccess, isPrimary, hasDesktopBridge: isElectron }) + : resolveRemoteOperateAccess(sessionAccess); + return { + environmentId, + label: environment.entry.target.label, + prices: settings?.usagePriceOverrides ?? null, + unavailable: + environment.connection.phase !== "connected" + ? "Offline" + : settings === null + ? "Prices not loaded" + : environment.serverConfig?.environment.capabilities.usagePriceOverrides !== true + ? "Update server to edit prices" + : access === "pending" + ? "Checking permissions…" + : access === "denied" + ? "Read-only access" + : null, + }; + }), +); + +type SaveAttempt = { + readonly drafts: readonly UsagePriceDraft[]; + readonly destinations: readonly { + readonly environmentId: EnvironmentId; + readonly label: string; + }[]; + readonly results: ReadonlyMap< + EnvironmentId, + UsagePriceWriteResult | { readonly status: "saving" } + >; +}; + +export function UsagePriceOverrides({ + usage, + initialSelectedEnvironmentIds, + onOpenChange, +}: { + readonly usage: readonly EnvironmentUsageStatus[]; + readonly initialSelectedEnvironmentIds: ReadonlySet | null; + readonly onOpenChange: (open: boolean) => void; +}) { + const environments = useAtomValue(priceTargetsAtom); + const [selectedIds, setSelectedIds] = useState(initialSelectedEnvironmentIds); + const selected = environments.filter( + (environment) => selectedIds === null || selectedIds.has(environment.environmentId), + ); + const [drafts, setDrafts] = useState([]); + const [pending, setPending] = useState(false); + const [attempt, setAttempt] = useState(null); + const focusRowRef = useRef(null); + const nextRowId = useRef(0); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const customModels = [ + ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))), + ].sort(); + const models = [ + ...new Set([ + ...customModels, + ...usage + .filter((environment) => + selected.some((target) => target.environmentId === environment.environmentId), + ) + .flatMap((environment) => environment.summary?.buckets.map((bucket) => bucket.model) ?? []), + ]), + ].sort(); + // Keep new rows in place while successful environments publish their updated settings. + const newModels = new Set( + drafts.filter((draft) => draft.isNew).map((draft) => draft.model.trim()), + ); + const rows: readonly UsagePriceDraft[] = [ + ...customModels + .filter((model) => attempt === null || !newModels.has(model)) + .map( + (model) => + drafts.find((draft) => draft.id === `model:${model}`) ?? { + id: `model:${model}`, + model, + isNew: false, + values: {}, + }, + ), + ...drafts.filter((draft) => draft.isNew), + ]; + const stagedDrafts = drafts.filter((draft) => !isEmptyUsagePriceDraft(draft)); + const errors = usagePriceTableErrors(selected, stagedDrafts); + for (const draft of stagedDrafts) { + if (!draft.isNew) continue; + if (draft.model.trim() === "") errors.set(draft.id, "Enter a model ID."); + else if ( + attempt === null && + (customModels.includes(draft.model.trim()) || + drafts.some((other) => other.id !== draft.id && other.model.trim() === draft.model.trim())) + ) + errors.set(draft.id, "This model already has a row. Edit its prices there."); + } + const failedDestinations = + attempt?.destinations.filter( + (destination) => attempt.results.get(destination.environmentId)?.status === "failed", + ) ?? []; + const locked = pending || failedDestinations.length > 0; + const hasChanges = stagedDrafts.length > 0; + const destinationLabel = + selected.length === 1 ? selected[0]!.label : `${selected.length} environments`; + const selectionLabel = selectedIds === null ? "All environments" : destinationLabel; + const discard = () => { + setDrafts([]); + setAttempt(null); + }; + const selectEnvironments = (ids: ReadonlySet | null) => { + setSelectedIds(ids); + setAttempt(null); + }; + const updateDraft = (draft: UsagePriceDraft) => { + setDrafts((previous) => + previous.some((entry) => entry.id === draft.id) + ? previous.map((entry) => (entry.id === draft.id ? draft : entry)) + : [...previous, draft], + ); + setAttempt(null); + }; + const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => { + const values = { ...row.values, [field]: value }; + const original = usagePriceCell(selected, row.model, field); + if ( + !row.isNew && + original.placeholder !== "Mixed" && + original.placeholder !== "Unavailable" && + value === original.value + ) + delete values[field]; + if (!row.isNew && Object.keys(values).length === 0) + setDrafts((previous) => previous.filter((entry) => entry.id !== row.id)); + else updateDraft({ ...row, values }); + }; + const save = async (retry = false) => { + if (pending) return; + const destinations = retry ? failedDestinations : selected; + const edits = retry && attempt ? attempt.drafts : stagedDrafts; + if (destinations.length === 0 || edits.length === 0) return; + const targets = destinations.map( + (destination) => + environments.find( + (environment) => environment.environmentId === destination.environmentId, + ) ?? { ...destination, prices: null, unavailable: "Environment removed" }, + ); + const changes = new Map( + targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]), + ); + setPending(true); + setAttempt((previous) => ({ + drafts: edits, + destinations: retry && previous ? previous.destinations : targets, + results: new Map([ + ...(retry && previous ? previous.results : []), + ...targets.map((target) => [target.environmentId, { status: "saving" } as const] as const), + ]), + })); + let failed = false; + await writeUsagePrices({ + targets: targets.map((target) => ({ + ...target, + unavailable: + target.unavailable ?? [...changes.get(target.environmentId)!.errors.values()][0] ?? null, + })), + changes: new Map([...changes].map(([id, plan]) => [id, plan.changes])), + write: updateSettings, + onResult: (environmentId, result) => { + if (result.status === "failed") failed = true; + setAttempt((previous) => + previous === null + ? null + : { ...previous, results: new Map(previous.results).set(environmentId, result) }, + ); + }, + }); + setPending(false); + if (!failed) setDrafts([]); + }; + + return ( + { + if (!pending) onOpenChange(open); + }} + > + + + Custom model prices + + Prices apply to all past and future usage on the environments you select. + + + +
+
+ + + } + aria-labelledby="usage-prices-apply-label usage-prices-selection" + disabled={pending || hasChanges} + > + + {selectionLabel} + + + + + selectEnvironments(checked ? null : new Set())} + > + All environments + + + {environments.map((environment) => ( + { + const next = new Set(selected.map((target) => target.environmentId)); + if (checked) next.add(environment.environmentId); + else next.delete(environment.environmentId); + selectEnvironments(next.size === environments.length ? null : next); + }} + > + + {environment.label} + {environment.unavailable ? ( + + {environment.unavailable} + + ) : null} + + + ))} + + +
+ USD / million tokens +
+ {selected.length === 0 ? ( +

+ {environments.length === 0 + ? "Connect an environment to set model prices." + : "Select an environment to see and change its model prices."} +

+ ) : ( + <> +
+ + + + {USAGE_PRICE_FIELDS.map((field) => ( + + ))} + + + + + Model ID + {USAGE_PRICE_FIELDS.map((field) => ( + {field.label} + ))} + + + + + + + {rows.length === 0 ? ( + + + {selected.some((environment) => environment.prices === null) + ? "Some environment prices are unavailable." + : "No custom prices. Add a row to override automatic pricing."} + + + ) : ( + rows.map((row) => ( + + + {row.isNew ? ( + { + if (node && focusRowRef.current === row.id) { + node.focus(); + focusRowRef.current = null; + } + }} + aria-label="New model ID" + aria-invalid={ + (row.model.trim() !== "" && errors.has(row.id)) || undefined + } + list="usage-price-models" + placeholder="Model ID" + autoComplete="off" + spellCheck={false} + disabled={locked} + onChange={(event) => + updateDraft({ ...row, model: event.target.value }) + } + /> + ) : ( + + {row.model} + + )} + {errors.has(row.id) && + (row.model.trim() !== "" || + Object.values(row.values).some((value) => value !== "")) ? ( +

+ {errors.get(row.id)} +

+ ) : null} +
+ {row.removed ? ( + + Automatic pricing after saving + + ) : ( + USAGE_PRICE_FIELDS.map((field) => { + const cell = usagePriceCell(selected, row.model, field.key); + return ( + + + editCell(row, field.key, event.target.value) + } + /> + + ); + }) + )} + + + } + disabled={locked} + aria-label={ + row.removed + ? `Undo reset for ${row.model}` + : row.isNew + ? "Remove new model" + : `Reset price for ${row.model} to automatic` + } + onClick={() => { + if (row.isNew) + setDrafts((previous) => + previous.filter((entry) => entry.id !== row.id), + ); + else if (row.removed) { + if (Object.keys(row.values).length === 0) + setDrafts((previous) => + previous.filter((entry) => entry.id !== row.id), + ); + else updateDraft({ ...row, removed: false }); + } else updateDraft({ ...row, removed: true }); + }} + > + {row.isNew ? : } + + + {row.removed + ? "Undo reset" + : row.isNew + ? "Remove row" + : "Reset to automatic"} + + + +
+ )) + )} +
+
+
+ + {models + .filter((model) => !customModels.includes(model)) + .map((model) => ( + +

+ Blank cache rates use the input price. Enter 0 for free tokens. + {selected.length > 1 + ? " Mixed cells keep each environment’s rate until you edit them." + : ""} +

+ + )} + {attempt ? ( +
+ {attempt.destinations.map((destination) => { + const result = attempt.results.get(destination.environmentId); + return ( +
+ {destination.label} + + {result?.status === "failed" + ? `Not saved · ${result.error}` + : result?.status === "saved" + ? "Saved" + : "Saving…"} + +
+ ); + })} +
+ ) : null} +
+ + + {hasChanges ? `Changes apply to ${destinationLabel}` : ""} + +
+ {hasChanges ? ( + + ) : null} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/usage/usagePriceForm.test.ts b/apps/web/src/components/usage/usagePriceForm.test.ts new file mode 100644 index 000000000..47c555651 --- /dev/null +++ b/apps/web/src/components/usage/usagePriceForm.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseUsagePriceForm, usagePriceForm } from "./usagePriceForm.ts"; + +describe("model price entry", () => { + it("preserves exact model IDs and fractional rates", () => { + expect( + parseUsagePriceForm({ + ...usagePriceForm(), + model: " vendor/Example-Model ", + inputCostPerMillionTokens: "2.5", + outputCostPerMillionTokens: "10", + }), + ).toEqual({ + model: "vendor/Example-Model", + price: { inputCostPerMillionTokens: 2.5, outputCostPerMillionTokens: 10 }, + }); + }); + + it("keeps free cache tokens distinct from blank cache prices", () => { + const parsed = parseUsagePriceForm({ + ...usagePriceForm(), + model: "example-model", + inputCostPerMillionTokens: "2", + outputCostPerMillionTokens: "8", + cacheReadCostPerMillionTokens: "0", + cacheWriteCostPerMillionTokens: " ", + }); + expect(parsed?.price).toEqual({ + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0, + }); + }); + + it("preserves all prices when editing, including small fractional prices", () => { + const price = { + inputCostPerMillionTokens: 0, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0.0000001, + cacheWriteCostPerMillionTokens: 3.5, + }; + expect(parseUsagePriceForm(usagePriceForm("example-model", price))).toEqual({ + model: "example-model", + price, + }); + }); + + it.each(["", " ", "-1", "Infinity", "NaN", "1e999", "$2", "0x10"])( + "rejects invalid required prices: %j", + (inputCostPerMillionTokens) => { + expect( + parseUsagePriceForm({ + ...usagePriceForm("example-model", { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + }), + inputCostPerMillionTokens, + }), + ).toBeNull(); + }, + ); + + it("rejects invalid optional rates and missing model IDs", () => { + const form = usagePriceForm("example-model", { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + }); + expect(parseUsagePriceForm({ ...form, cacheReadCostPerMillionTokens: "-1" })).toBeNull(); + expect(parseUsagePriceForm({ ...form, cacheWriteCostPerMillionTokens: "invalid" })).toBeNull(); + expect(parseUsagePriceForm({ ...form, model: " " })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/usage/usagePriceForm.ts b/apps/web/src/components/usage/usagePriceForm.ts new file mode 100644 index 000000000..c52178300 --- /dev/null +++ b/apps/web/src/components/usage/usagePriceForm.ts @@ -0,0 +1,62 @@ +import type { UsageModelPriceOverride } from "@t3tools/contracts"; + +export const USAGE_PRICE_FIELDS = [ + { key: "inputCostPerMillionTokens", label: "Input", optional: false }, + { key: "outputCostPerMillionTokens", label: "Output", optional: false }, + { key: "cacheReadCostPerMillionTokens", label: "Cache read", optional: true }, + { key: "cacheWriteCostPerMillionTokens", label: "Cache write", optional: true }, +] as const; + +export type UsagePriceForm = { model: string } & Record< + (typeof USAGE_PRICE_FIELDS)[number]["key"], + string +>; + +export function usagePriceForm(model = "", price?: UsageModelPriceOverride): UsagePriceForm { + return { + model, + inputCostPerMillionTokens: price?.inputCostPerMillionTokens.toString() ?? "", + outputCostPerMillionTokens: price?.outputCostPerMillionTokens.toString() ?? "", + cacheReadCostPerMillionTokens: price?.cacheReadCostPerMillionTokens?.toString() ?? "", + cacheWriteCostPerMillionTokens: price?.cacheWriteCostPerMillionTokens?.toString() ?? "", + }; +} + +/** Blank cache prices use the input rate; explicit zero means free. */ +export function parseUsagePriceForm( + form: UsagePriceForm, +): { model: string; price: UsageModelPriceOverride } | null { + const model = form.model.trim(); + if (model.length === 0) return null; + const rates: Partial> = {}; + for (const field of USAGE_PRICE_FIELDS) { + const raw = form[field.key].trim(); + if (raw === "") { + if (field.optional) continue; + return null; + } + if (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(raw)) return null; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return null; + rates[field.key] = value; + } + if ( + rates.inputCostPerMillionTokens === undefined || + rates.outputCostPerMillionTokens === undefined + ) { + return null; + } + return { + model, + price: { + inputCostPerMillionTokens: rates.inputCostPerMillionTokens, + outputCostPerMillionTokens: rates.outputCostPerMillionTokens, + ...(rates.cacheReadCostPerMillionTokens === undefined + ? {} + : { cacheReadCostPerMillionTokens: rates.cacheReadCostPerMillionTokens }), + ...(rates.cacheWriteCostPerMillionTokens === undefined + ? {} + : { cacheWriteCostPerMillionTokens: rates.cacheWriteCostPerMillionTokens }), + }, + }; +} diff --git a/apps/web/src/components/usage/usagePriceTable.test.ts b/apps/web/src/components/usage/usagePriceTable.test.ts new file mode 100644 index 000000000..57be2cac8 --- /dev/null +++ b/apps/web/src/components/usage/usagePriceTable.test.ts @@ -0,0 +1,170 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + usagePriceCell, + usagePriceTableChanges, + usagePriceTableErrors, + type UsagePriceDraft, +} from "./usagePriceTable"; +import type { UsagePriceTarget } from "./usagePriceTargets"; + +const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; +const target = (name: string, prices: UsagePriceTarget["prices"]): UsagePriceTarget => ({ + environmentId: EnvironmentId.make(name), + label: name, + prices, + unavailable: null, +}); +const draft = (values: UsagePriceDraft["values"]): UsagePriceDraft => ({ + id: "model:example", + model: "example", + isNew: false, + values, +}); + +describe("price table edits", () => { + it("shows shared and mixed cells separately, and never treats missing settings as automatic", () => { + const targets = [ + target("a", { example: price }), + target("b", { example: { ...price, outputCostPerMillionTokens: 10 } }), + ]; + expect(usagePriceCell(targets, "example", "inputCostPerMillionTokens").value).toBe("2"); + expect(usagePriceCell(targets, "example", "outputCostPerMillionTokens")).toEqual({ + value: "", + placeholder: "Mixed", + }); + expect(usagePriceCell(targets, "example", "cacheReadCostPerMillionTokens")).toEqual({ + value: "", + placeholder: "Input rate", + }); + expect( + usagePriceCell([target("a", {})], "example", "inputCostPerMillionTokens").placeholder, + ).toBe("Automatic"); + expect( + usagePriceCell([targets[0]!, target("b", null)], "example", "inputCostPerMillionTokens") + .placeholder, + ).toBe("Unavailable"); + }); + + it.each(["constructor", "toString", "__proto__"])( + "handles an exact model ID named %s", + (model) => { + const environment = target("a", {}); + expect(usagePriceCell([environment], model, "inputCostPerMillionTokens")).toEqual({ + value: "", + placeholder: "Automatic", + }); + const result = usagePriceTableChanges(environment, [ + { + ...draft({ inputCostPerMillionTokens: "2", outputCostPerMillionTokens: "8" }), + model, + isNew: true, + }, + ]); + expect(result.changes).toEqual([{ model, price }]); + expect( + usagePriceCell([target("a", { [model]: price })], model, "inputCostPerMillionTokens").value, + ).toBe("2"); + }, + ); + + it("changes only edited columns while preserving each environment's other prices and models", () => { + const edits = [draft({ inputCostPerMillionTokens: "3" })]; + const a = usagePriceTableChanges( + target("a", { example: { ...price, cacheReadCostPerMillionTokens: 0 }, untouched: price }), + edits, + ); + const b = usagePriceTableChanges( + target("b", { example: { ...price, outputCostPerMillionTokens: 10 } }), + edits, + ); + expect(a.changes).toEqual([ + { + model: "example", + price: { ...price, inputCostPerMillionTokens: 3, cacheReadCostPerMillionTokens: 0 }, + }, + ]); + expect(b.changes).toEqual([ + { model: "example", price: { inputCostPerMillionTokens: 3, outputCostPerMillionTokens: 10 } }, + ]); + }); + + it("batches new rows and resets, and distinguishes blank cache rates from explicit zero", () => { + const plan = usagePriceTableChanges(target("a", { example: price }), [ + { ...draft({}), removed: true }, + { + id: "new:1", + model: " vendor/Model ", + isNew: true, + values: { + inputCostPerMillionTokens: "2", + outputCostPerMillionTokens: "8", + cacheReadCostPerMillionTokens: "0", + cacheWriteCostPerMillionTokens: "", + }, + }, + ]); + expect(plan.errors.size).toBe(0); + expect(plan.changes).toEqual([ + { model: "example", price: null }, + { model: "vendor/Model", price: { ...price, cacheReadCostPerMillionTokens: 0 } }, + ]); + }); + + it("requires complete prices before turning automatic pricing into an override", () => { + const edits = [draft({ inputCostPerMillionTokens: "3" })]; + expect(usagePriceTableChanges(target("a", { example: price }), edits).errors.size).toBe(0); + const missing = usagePriceTableChanges(target("b", {}), edits); + expect(missing.changes).toEqual([]); + expect(missing.errors.has("model:example")).toBe(true); + }); + + it("ignores empty new rows without blocking other edits", () => { + const environment = target("a", { example: price }); + const empty: UsagePriceDraft = { + id: "new:1", + model: " ", + isNew: true, + values: { inputCostPerMillionTokens: " " }, + }; + const plan = usagePriceTableChanges(environment, [ + draft({ inputCostPerMillionTokens: "3" }), + empty, + ]); + expect(plan.errors.size).toBe(0); + expect(plan.changes).toEqual([ + { model: "example", price: { ...price, inputCostPerMillionTokens: 3 } }, + ]); + expect(usagePriceTableChanges(environment, [empty]).changes).toEqual([]); + expect( + usagePriceTableChanges(environment, [ + { ...empty, values: { inputCostPerMillionTokens: "0" } }, + ]).errors.get(empty.id), + ).toBe("Enter a model ID."); + }); + + it.each(["Offline", "Read-only access", "Update server to edit prices"])( + "does not let %s destinations block a valid edit elsewhere", + (unavailable) => { + const edits = [draft({ inputCostPerMillionTokens: "3" })]; + const writable = target("writable", { example: price }); + const other = target("other", {}); + expect(usagePriceTableErrors([writable, { ...other, unavailable }], edits).size).toBe(0); + expect(usagePriceTableErrors([writable, other], edits).has("model:example")).toBe(true); + }, + ); + + it("does not write unchanged rates and rejects invalid edited cells", () => { + const environment = target("a", { example: price }); + expect( + usagePriceTableChanges(environment, [draft({ inputCostPerMillionTokens: "2.00" })]).changes, + ).toEqual([]); + expect( + usagePriceTableChanges(environment, [draft({ cacheReadCostPerMillionTokens: "-1" })]).errors + .size, + ).toBe(1); + expect( + usagePriceTableChanges(environment, [draft({ outputCostPerMillionTokens: "" })]).errors.size, + ).toBe(1); + }); +}); diff --git a/apps/web/src/components/usage/usagePriceTable.ts b/apps/web/src/components/usage/usagePriceTable.ts new file mode 100644 index 000000000..0a9fd16c4 --- /dev/null +++ b/apps/web/src/components/usage/usagePriceTable.ts @@ -0,0 +1,97 @@ +import { + parseUsagePriceForm, + usagePriceForm, + USAGE_PRICE_FIELDS, + type UsagePriceForm, +} from "./usagePriceForm"; +import type { UsagePriceChange, UsagePriceTarget } from "./usagePriceTargets"; + +export type UsagePriceField = (typeof USAGE_PRICE_FIELDS)[number]["key"]; + +export interface UsagePriceDraft { + readonly id: string; + readonly model: string; + readonly isNew: boolean; + readonly values: Partial>; + readonly removed?: boolean; +} + +export function isEmptyUsagePriceDraft(draft: UsagePriceDraft) { + return ( + draft.isNew && + draft.model.trim() === "" && + Object.values(draft.values).every((value) => value.trim() === "") + ); +} + +function modelPrice(target: UsagePriceTarget, model: string) { + return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined; +} + +export function usagePriceCell( + targets: readonly UsagePriceTarget[], + model: string, + field: UsagePriceField, +) { + const optional = USAGE_PRICE_FIELDS.find((entry) => entry.key === field)!.optional; + const values = targets.map((target) => + modelPrice(target, model) ? usagePriceForm(model, modelPrice(target, model))[field] : null, + ); + if (targets.some((target) => target.prices === null)) + return { value: "", placeholder: "Unavailable" }; + if (values.some((value) => value !== values[0])) return { value: "", placeholder: "Mixed" }; + return { + value: values[0] ?? "", + placeholder: values[0] === null ? "Automatic" : optional ? "Input rate" : "0.00", + }; +} + +/** Only edited cells replace rates; untouched cells retain each environment's own values. */ +export function usagePriceTableChanges( + target: UsagePriceTarget, + drafts: readonly UsagePriceDraft[], +) { + const changes: UsagePriceChange[] = []; + const errors = new Map(); + for (const draft of drafts) { + if (isEmptyUsagePriceDraft(draft)) continue; + const model = draft.model.trim(); + if (draft.removed) { + if (modelPrice(target, model)) changes.push({ model, price: null }); + continue; + } + const original = usagePriceForm(model, modelPrice(target, model)); + const form = { ...original, ...draft.values }; + const parsed = parseUsagePriceForm(form); + if (parsed === null) { + const missing = USAGE_PRICE_FIELDS.find( + (field) => !field.optional && form[field.key].trim() === "", + ); + errors.set( + draft.id, + model === "" + ? "Enter a model ID." + : missing + ? `${missing.label} is required on ${target.label}.` + : "Use non-negative numbers for prices.", + ); + continue; + } + const next = usagePriceForm(model, parsed.price); + if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key])) + changes.push(parsed); + } + return { changes, errors }; +} + +/** Unavailable destinations report a save failure without blocking writable environments. */ +export function usagePriceTableErrors( + targets: readonly UsagePriceTarget[], + drafts: readonly UsagePriceDraft[], +) { + return new Map( + targets + .filter((target) => target.unavailable === null && target.prices !== null) + .flatMap((target) => [...usagePriceTableChanges(target, drafts).errors]), + ); +} diff --git a/apps/web/src/components/usage/usagePriceTargets.test.ts b/apps/web/src/components/usage/usagePriceTargets.test.ts new file mode 100644 index 000000000..f445c9b42 --- /dev/null +++ b/apps/web/src/components/usage/usagePriceTargets.test.ts @@ -0,0 +1,143 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + writeUsagePrices, + type UsagePriceTarget, + type UsagePriceWriteResult, +} from "./usagePriceTargets"; + +const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; +const target = (id: string, overrides: Partial = {}): UsagePriceTarget => ({ + environmentId: EnvironmentId.make(id), + label: id, + prices: { example: price }, + unavailable: null, + ...overrides, +}); + +describe("model price writes", () => { + it("sends all table changes in one patch per environment and skips unchanged destinations", async () => { + const write = vi.fn(async () => ({ _tag: "Success" as const })); + const onResult = vi.fn(); + await writeUsagePrices({ + targets: [target("edited"), target("unchanged")], + changes: new Map([ + [ + EnvironmentId.make("edited"), + [ + { model: "new-model", price }, + { model: "example", price: null }, + ], + ], + ]), + write, + onResult, + }); + expect(write).toHaveBeenCalledExactlyOnceWith({ + environmentId: "edited", + input: { patch: { usagePriceOverrides: { "new-model": price, example: null } } }, + }); + expect(onResult).toHaveBeenCalledWith("unchanged", { status: "saved" }); + }); + + it("saves independently, skips unavailable targets, and retries failures without rewriting successes", async () => { + let resolveSlow!: (result: { _tag: "Success" | "Failure" }) => void; + const slow = new Promise<{ _tag: "Success" | "Failure" }>((resolve) => { + resolveSlow = resolve; + }); + let resolveFastSaved!: () => void; + const fastSaved = new Promise((resolve) => { + resolveFastSaved = resolve; + }); + const targets = [target("fast"), target("slow"), target("offline", { unavailable: "Offline" })]; + const results = new Map(); + const write = vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => + environmentId === "slow" ? slow : { _tag: "Success" as const }, + ); + const change = { model: "example", price }; + const running = writeUsagePrices({ + targets, + changes: new Map(targets.map((target) => [target.environmentId, [change]])), + write, + onResult: (id, result) => { + results.set(id, result); + if (id === "fast") resolveFastSaved(); + }, + }); + await fastSaved; + expect(results.get(EnvironmentId.make("fast"))).toEqual({ status: "saved" }); + expect(results.get(EnvironmentId.make("offline"))).toEqual({ + status: "failed", + error: "Offline", + }); + expect(results.has(EnvironmentId.make("slow"))).toBe(false); + resolveSlow({ _tag: "Failure" }); + await running; + expect(write).toHaveBeenCalledTimes(2); + expect(write).toHaveBeenCalledWith({ + environmentId: "fast", + input: { patch: { usagePriceOverrides: { example: price } } }, + }); + const retry = vi.fn(async () => ({ _tag: "Success" as const })); + await writeUsagePrices({ + targets: targets + .filter((entry) => results.get(entry.environmentId)?.status === "failed") + .map((entry) => ({ ...entry, unavailable: null })), + changes: new Map(targets.map((target) => [target.environmentId, [change]])), + write: retry, + onResult: (id, result) => { + results.set(id, result); + }, + }); + expect(retry.mock.calls).toHaveLength(2); + expect(retry).not.toHaveBeenCalledWith(expect.objectContaining({ environmentId: "fast" })); + expect([...results.values()].every((result) => result.status === "saved")).toBe(true); + }); + + it("resets only the chosen model on the selected destination", async () => { + const write = vi.fn(async () => ({ _tag: "Success" as const })); + const onResult = vi.fn(); + await writeUsagePrices({ + targets: [target("selected")], + changes: new Map([[EnvironmentId.make("selected"), [{ model: "example", price: null }]]]), + write, + onResult, + }); + expect(write).toHaveBeenCalledExactlyOnceWith({ + environmentId: "selected", + input: { patch: { usagePriceOverrides: { example: null } } }, + }); + expect(onResult).toHaveBeenCalledWith("selected", { status: "saved" }); + }); + + it("reports thrown writes and permission/version restrictions without aborting other saves", async () => { + const onResult = vi.fn(); + const write = vi.fn(async () => { + throw new Error("connection lost"); + }); + await writeUsagePrices({ + targets: [ + target("lost"), + target("denied", { unavailable: "Read-only access" }), + target("old", { unavailable: "Update server to edit prices" }), + ], + changes: new Map([[EnvironmentId.make("lost"), [{ model: "example", price }]]]), + write, + onResult, + }); + expect(write).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith("lost", { + status: "failed", + error: "Could not save. Try again.", + }); + expect(onResult).toHaveBeenCalledWith("denied", { + status: "failed", + error: "Read-only access", + }); + expect(onResult).toHaveBeenCalledWith("old", { + status: "failed", + error: "Update server to edit prices", + }); + }); +}); diff --git a/apps/web/src/components/usage/usagePriceTargets.ts b/apps/web/src/components/usage/usagePriceTargets.ts new file mode 100644 index 000000000..377724532 --- /dev/null +++ b/apps/web/src/components/usage/usagePriceTargets.ts @@ -0,0 +1,65 @@ +import type { + EnvironmentId, + ServerSettingsPatch, + UsageModelPriceOverride, +} from "@t3tools/contracts"; + +export interface UsagePriceTarget { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly prices: Readonly> | null; + readonly unavailable: string | null; +} + +export interface UsagePriceChange { + readonly model: string; + readonly price: UsageModelPriceOverride | null; +} + +export type UsagePriceWriteResult = + | { readonly status: "saved" } + | { readonly status: "failed"; readonly error: string }; + +/** Each destination settles independently; retry callers pass only the failed destinations. */ +export async function writeUsagePrices(input: { + readonly targets: readonly UsagePriceTarget[]; + readonly changes: ReadonlyMap; + readonly write: (input: { + environmentId: EnvironmentId; + input: { patch: ServerSettingsPatch }; + }) => Promise<{ readonly _tag: "Success" | "Failure" }>; + readonly onResult: (environmentId: EnvironmentId, result: UsagePriceWriteResult) => void; +}) { + await Promise.all( + input.targets.map(async (target) => { + let result: UsagePriceWriteResult; + if (target.unavailable !== null) { + result = { status: "failed", error: target.unavailable }; + } else { + try { + const changes = input.changes.get(target.environmentId) ?? []; + const saved = + changes.length === 0 + ? { _tag: "Success" as const } + : await input.write({ + environmentId: target.environmentId, + input: { + patch: { + usagePriceOverrides: Object.fromEntries( + changes.map((change) => [change.model, change.price]), + ), + }, + }, + }); + result = + saved._tag === "Success" + ? { status: "saved" } + : { status: "failed", error: "Could not save. Try again." }; + } catch { + result = { status: "failed", error: "Could not save. Try again." }; + } + } + input.onResult(target.environmentId, result); + }), + ); +} diff --git a/docs/user/usage.md b/docs/user/usage.md index b18f7ab5c..7d602554b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -32,3 +32,15 @@ Account names appear in the comparison and task list without displaying email ad In **Settings → Agents**, toggle individual models or enable/disable all built-in models for an account. These choices are saved to that environment and control its model picker. + +## Custom model prices + +Open **Model prices** from Usage to override an exact model ID’s rates in USD per +million tokens. These rates recalculate past and future usage, including estimates +that previously used provider-reported costs. Blank cache rates use the input rate; +enter `0` for free tokens. Reset a model to return to automatic pricing. + +Web and desktop can apply edits to several environments. Mixed cells keep each +environment’s existing rate until you change them. If some saves fail, retry applies +only to those environments. On iPhone and iPad, choose an environment before editing +its model prices. A connected server with pricing support and write access is required. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index abc153b8d..4b71c957c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -92,6 +92,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), /** V2 atomic metadata link/unlink operations and a linkedPullRequests collection. */ threadPullRequestsV2: Schema.optionalKey(Schema.Boolean), + usagePriceOverrides: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d6696a00f..1aa697520 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1009,6 +1009,20 @@ export const BackgroundActivitySettings = Schema.Struct({ }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; +const UsageModelTokenPrice = Schema.Number.check( + Schema.isFinite(), + Schema.isGreaterThanOrEqualTo(0), +); + +/** USD per million tokens. Omitted cache rates use the input rate. */ +export const UsageModelPriceOverride = Schema.Struct({ + inputCostPerMillionTokens: UsageModelTokenPrice, + outputCostPerMillionTokens: UsageModelTokenPrice, + cacheReadCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice), + cacheWriteCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice), +}); +export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type; + export const ServerSettings = Schema.Struct({ // Legacy token-by-token assistant output. Deliberately a fresh key (was // `enableAssistantStreaming`): decoding drops the old key, so everyone, @@ -1137,6 +1151,9 @@ export const ServerSettings = Schema.Struct({ projectFileBackfillApplied: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1359,6 +1376,9 @@ export const ServerSettingsPatch = Schema.Struct({ providerModelPreferencesMigrated: Schema.optionalKey(Schema.Boolean), hermesProactiveDefaultApplied: Schema.optionalKey(Schema.Boolean), projectFileBackfillApplied: Schema.optionalKey(Schema.Boolean), + usagePriceOverrides: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), + ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 43da73a61..49d8772a1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -565,3 +565,26 @@ describe("serverSettings helpers", () => { expect(resolved.pauseWhenOnBattery).toBe(false); }); }); + +it("patches model prices per entry and replaces optional cache rates", () => { + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + usagePriceOverrides: { + one: { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 1, + }, + two: { inputCostPerMillionTokens: 3, outputCostPerMillionTokens: 9 }, + }, + }); + const second = applyServerSettingsPatch(first, { + usagePriceOverrides: { + one: { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 10 }, + }, + }); + expect(second.usagePriceOverrides.one?.cacheReadCostPerMillionTokens).toBeUndefined(); + expect(second.usagePriceOverrides.two).toEqual(first.usagePriceOverrides.two); + expect( + applyServerSettingsPatch(second, { usagePriceOverrides: { one: null } }).usagePriceOverrides, + ).toEqual({ two: first.usagePriceOverrides.two }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 6f56e1334..03c7bb25b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -132,6 +132,7 @@ export function applyServerSettingsPatch( providerHealthRefreshInterval, backgroundActivityProfile, backgroundActivity, + usagePriceOverrides: pricePatch, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -170,8 +171,14 @@ export function applyServerSettingsPatch( } : undefined; const next = deepMerge(current, patchForMerge); + const usagePriceOverrides = { ...current.usagePriceOverrides }; + for (const [model, price] of Object.entries(pricePatch ?? {})) { + if (price === null) delete usagePriceOverrides[model]; + else usagePriceOverrides[model] = price; + } const nextWithReplacementsBase = { ...next, + usagePriceOverrides, ...(backgroundActivity !== undefined ? { backgroundActivity: { diff --git a/scripts/generate-swift-contract-fixtures.ts b/scripts/generate-swift-contract-fixtures.ts index 8d8aa1ba7..d281a7935 100644 --- a/scripts/generate-swift-contract-fixtures.ts +++ b/scripts/generate-swift-contract-fixtures.ts @@ -18,6 +18,7 @@ import { ServerProviderUsageLimits, PullRequestStack, PullRequestLabelCandidateList, + UsageModelPriceOverride, CheckpointId, CheckpointScopeId, ContextHandoffId, @@ -470,3 +471,20 @@ if (process.argv.includes("--check")) { } else { NodeFS.writeFileSync(labelsPath, labelsSerialized); } + +const pricePath = NodePath.join(NodePath.dirname(outputPath), "usageModelPrice.json"); +const priceSerialized = `${JSON.stringify( + Schema.encodeSync(UsageModelPriceOverride)({ + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0, + }), + null, + 2, +)}\n`; +if (process.argv.includes("--check")) { + if (!NodeFS.existsSync(pricePath) || NodeFS.readFileSync(pricePath, "utf8") !== priceSerialized) { + console.error("[swift-fixtures] usageModelPrice.json is stale; regenerate fixtures."); + process.exit(1); + } +} else NodeFS.writeFileSync(pricePath, priceSerialized); From 206ec362bf4fd08d1c500add5c632ae51788b59b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:38:42 +0200 Subject: [PATCH 07/93] feat: attach V2 task progress and notices to the composer --- PATCH.md | 5 + .../Features/Chat/ComposerTasksView.swift | 60 +++ .../Features/Chat/ThreadDetailView.swift | 1 + apps/web/src/components/ChatView.tsx | 66 ++- .../chat/ComposerActivityStatus.tsx | 5 +- .../src/components/chat/ComposerBanner.tsx | 36 +- .../chat/ComposerBannerStack.test.tsx | 97 ++--- .../components/chat/ComposerBannerStack.tsx | 375 ++++++++++++------ .../chat/ComposerPendingApprovalActions.tsx | 18 +- .../chat/ComposerPendingApprovalPanel.tsx | 11 +- .../components/chat/ComposerTasksBadge.tsx | 230 +++++++++++ docs/user/composer.md | 5 + 12 files changed, 685 insertions(+), 224 deletions(-) create mode 100644 apps/swift-ios/Features/Chat/ComposerTasksView.swift create mode 100644 apps/web/src/components/chat/ComposerTasksBadge.tsx diff --git a/PATCH.md b/PATCH.md index 1699badd1..a542a546a 100644 --- a/PATCH.md +++ b/PATCH.md @@ -628,3 +628,8 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera per-model replacement/deletion. Usage scans capture price settings and reprice cached transcript records, including provider-reported costs. Web supports multi-environment edits with per-environment retry; Swift exposes per-environment model-price editing. + +- Composer task summaries derive only from the current V2 run's todo list. Web's + attached banner stack keeps task activity in front and retains urgent fork notices; + Swift adds an expandable task row beside, outside, the voice composer's gesture tree. + Approval controls use compact web styling while preserving V2 non-resumable guards. diff --git a/apps/swift-ios/Features/Chat/ComposerTasksView.swift b/apps/swift-ios/Features/Chat/ComposerTasksView.swift new file mode 100644 index 000000000..267541fda --- /dev/null +++ b/apps/swift-ios/Features/Chat/ComposerTasksView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// The current run's V2 task list, kept adjacent to the composer and out of its gesture tree. +struct ComposerTasksView: View { + let detail: FeatureThreadDetail + @State private var expanded = false + + private var activeRunID: String? { + ThreadWorkflows.resolveActiveRun(runs: detail.workflow.runs)?.id + } + + private var steps: [OrchestrationV2PlanStep] { + guard detail.approvals.isEmpty, detail.userInputs.isEmpty, let activeRunID, + detail.thread.state == .working, + let item = detail.timelineItems.last(where: { + $0.item.base.runId == activeRunID && $0.item.type == "todo_list" + }), case let .todoList(_, steps, _) = item.item.payload, + steps.contains(where: { $0.status == "running" }) else { return [] } + return steps + } + + var body: some View { + let steps = steps + if !steps.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Button { withAnimation(.easeInOut(duration: 0.18)) { expanded.toggle() } } label: { + HStack(spacing: 8) { + Image(systemName: "list.bullet.clipboard") + Text(steps.first(where: { $0.status == "running" })?.text ?? "Tasks") + .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) + Text("\(steps.filter { $0.status == "completed" }.count)/\(steps.count)").monospacedDigit() + Image(systemName: "chevron.down").rotationEffect(.degrees(expanded ? 180 : 0)) + } + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + .padding(12).contentShape(Rectangle()) + }.buttonStyle(.plain) + .accessibilityLabel("Tasks, \(steps.filter { $0.status == "completed" }.count) of \(steps.count) complete") + .accessibilityHint(expanded ? "Collapse task list" : "Expand task list") + if expanded { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(steps.enumerated()), id: \.offset) { _, step in + HStack(alignment: .top, spacing: 8) { + Image(systemName: step.status == "completed" ? "checkmark.circle.fill" : step.status == "running" ? "circle.inset.filled" : "circle") + Text(step.text).frame(maxWidth: .infinity, alignment: .leading) + Text(step.status == "running" ? "Running" : step.status == "completed" ? "Completed" : "Pending") + .foregroundStyle(T3Colors.textSecondary) + }.font(T3Typography.supporting) + .foregroundStyle(step.status == "completed" ? T3Colors.textSecondary : T3Colors.textPrimary) + } + }.padding(.horizontal, 12).padding(.bottom, 12) + }.frame(maxHeight: 180) + } + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 24) + .onChange(of: activeRunID) { _, _ in expanded = false } + } + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index eff2b98f6..5dfd01acc 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -509,6 +509,7 @@ public struct ThreadDetailView: View { .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { queueSurfaces + ComposerTasksView(detail: detail) composer(detail) } .background { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a180b320..bd616ecce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { ComposerTasksContent } from "./chat/ComposerTasksBadge"; import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, @@ -2615,6 +2616,51 @@ function ChatViewContent(props: ChatViewProps) { () => deriveActivePlanState(serverProjection, activeLatestRun?.runId ?? undefined), [activeLatestRun?.runId, serverProjection], ); + const [tasksDrawerExpanded, setTasksDrawerExpanded] = useState(false); + useEffect(() => setTasksDrawerExpanded(false), [activeLatestRun?.runId, activeThread?.id]); + const composerTasks = useMemo(() => { + if ( + !activePlan || + !activeLatestRun || + latestRunSettled || + activePlan.runId !== activeLatestRun.runId || + pendingApprovals.length > 0 || + pendingUserInputs.length > 0 + ) + return null; + const current = activePlan.steps.find((step) => step.status === "inProgress"); + if (!current) return null; + return { + steps: activePlan.steps, + progress: { + step: current.step, + completedSteps: activePlan.steps.filter((step) => step.status === "completed").length, + totalSteps: activePlan.steps.length, + }, + }; + }, [ + activePlan, + activeLatestRun, + latestRunSettled, + pendingApprovals.length, + pendingUserInputs.length, + ]); + const composerTasksNotice = + composerTasks === null + ? null + : { + id: "active-tasks", + priority: "activity" as const, + variant: "default" as const, + content: ( + setTasksDrawerExpanded((open) => !open)} + progress={composerTasks.progress} + steps={composerTasks.steps} + /> + ), + }; const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && @@ -7664,7 +7710,7 @@ function ChatViewContent(props: ChatViewProps) { ref={draftHeroTransition.transitionGroupRef} className="chat-composer-horizontal-inset w-full" > -
+
{isDraftHeroState ? (
- +
) : ( - + )} {isServerThread ? ( - + = { default: neutralOutline, error: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]", - info: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--info)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--info)_4%,transparent)]", - success: - "[--chat-composer-attached-outline:color-mix(in_srgb,var(--success)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--success)_4%,transparent)]", + info: neutralOutline, + success: neutralOutline, warning: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", }; @@ -57,9 +56,13 @@ function Surface({ : "[--chat-composer-attachment-overlap:0px] before:rounded-[1rem]", "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)", "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", - "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", + // The mask cut-off bleeds one pixel past the seam: Chromium drops the last + // device-pixel row of a filtered backdrop when the cut-off lands off the + // device-pixel grid, and the composer's surface starts exactly there. The + // composer's own glass covers the extra row, so the overlap never shows. + "before:mask-[linear-gradient(to_top,transparent_0_calc(var(--chat-composer-attachment-overlap)-1px),black_calc(var(--chat-composer-attachment-overlap)-1px))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)", className, )} {...props} @@ -71,8 +74,8 @@ function Surface({ const peekBorder: Record = { default: "border-(--chat-composer-attached-outline)", error: "border-destructive/24", - info: "border-info/24", - success: "border-success/24", + info: "border-(--chat-composer-attached-outline)", + success: "border-(--chat-composer-attached-outline)", warning: "border-warning/24", }; @@ -90,7 +93,7 @@ function Peek({ neutralOutline, "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]", "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:bg-(--chat-composer-attached-surface)", "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring", peekBorder[variant], className, @@ -105,7 +108,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) {
[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", @@ -144,11 +147,13 @@ function Column({ className, ...props }: ComponentProps<"div">) { function Root({ className, + density = "default", placement = "attached", variant = "default", width = "fill", ...props }: ComponentProps<"div"> & { + density?: "default" | "comfortable"; placement?: "attached" | "floating"; variant?: ComposerBannerVariant; width?: "fill" | "content"; @@ -156,7 +161,8 @@ function Root({ return ( & { - layout?: "inline" | "wrap-actions"; + layout?: "inline" | "wrap-actions" | "wrap-actions-narrow"; }) { const rowProps = { className: cn( @@ -184,7 +190,9 @@ function Row({ "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]", "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring", layout === "wrap-actions" && - "@max-[400px]:flex @max-[400px]:flex-wrap @max-[400px]:gap-y-1 @max-[400px]:*:data-[slot=composer-banner-actions]:ms-auto @max-[400px]:*:data-[slot=composer-banner-actions]:max-w-full @max-[400px]:has-[>[data-slot=composer-banner-icon]]:*:data-[slot=composer-banner-actions]:max-w-[calc(100%-var(--composer-banner-icon-column)-(--spacing(1)))] @max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column) @max-[400px]:*:data-[slot=composer-banner-content]:flex-[1_1_10rem]", + "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", + layout === "wrap-actions-narrow" && + "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", className, ), "data-composer-banner-row": "true", @@ -244,6 +252,8 @@ function Actions({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-actions" className={cn( "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1", + "@max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:justify-start", + "@max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:justify-start", className, )} {...props} diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 6eed4fb05..6bad2f825 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -1,75 +1,40 @@ -import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; +import { orderComposerBanners, type ComposerBannerStackEntry } from "./ComposerBannerStack"; -import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; - -const banner = ( +const notice = ( id: string, - variant: ComposerBannerStackItem["variant"] = "warning", -): ComposerBannerStackItem => ({ + options: Partial = {}, +): ComposerBannerStackEntry => ({ id, - variant, - icon: , - title: `${id} warning`, + variant: "default", + icon: null, + title: id, + ...options, }); -describe("ComposerBannerStack", () => { - it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => { - const markup = renderToStaticMarkup( - , - ); - - const expandedItems = markup.match( - /
/, - ); - - expect(expandedItems?.[1]).toContain("grid-rows-[0fr]"); - expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]"); - expect(expandedItems?.[1]).toContain("z-20"); - expect(expandedItems?.[1]).not.toContain("absolute"); - expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning")); - expect(markup).toContain("invisible pointer-events-none"); - expect(markup).toContain("group-focus-within/banner-stack:visible"); - }); - - it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => { - const neutralBehind = renderToStaticMarkup( - , - ); - expect(neutralBehind).toContain("border-border"); - expect(neutralBehind).not.toContain("border-warning/24"); - - const warningBehind = renderToStaticMarkup( - , - ); - expect(warningBehind).toContain("border-warning/24"); +describe("composer notice ordering", () => { + it("keeps live activity attached while surfacing urgent notices before ordinary notices", () => { + const notices = [ + notice("info"), + notice("urgent", { urgent: true }), + notice("activity", { priority: "activity" }), + notice("warning", { variant: "warning" }), + ]; + expect(orderComposerBanners(notices).map((item) => item.id)).toEqual([ + "activity", + "urgent", + "warning", + "info", + ]); + expect(notices.map((item) => item.id)).toEqual(["info", "urgent", "activity", "warning"]); }); - - it("does not render an expandable region for a single banner", () => { - const markup = renderToStaticMarkup(); - - expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); - expect(markup).toContain("alert-glass"); - expect(markup).toContain('data-variant="warning"'); - expect(markup).toContain("transform:none"); - expect(markup).not.toContain("will-change:transform"); - }); - - it("applies item-specific surface and action layout classes", () => { - const markup = renderToStaticMarkup( - Repair, - }, - ]} - />, - ); - - expect(markup).toContain("branch-surface"); - expect(markup).toContain("branch-actions"); + it("retains arrival order at equal priority and does not promote success notices", () => { + expect( + orderComposerBanners([ + notice("one", { variant: "success" }), + notice("two"), + notice("three", { variant: "info" }), + ]).map((item) => item.id), + ).toEqual(["one", "two", "three"]); }); }); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index ff2f79e87..7d745cc89 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -1,60 +1,68 @@ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { XIcon } from "lucide-react"; +import { InfoIcon } from "lucide-react"; +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; +// Match the duration-220 exit transition before removing a dismissed notice. const DISMISS_TRANSITION_MS = 220; -const frontExitStyle = { - opacity: 0, - transform: "translate3d(0, 4rem, 0)", -} satisfies CSSProperties; -const stackedExitStyle = { - opacity: 0, - transform: "translate3d(0, 7rem, 0)", -} satisfies CSSProperties; -const restingStyle = { - opacity: 1, - transform: "none", -} satisfies CSSProperties; -const exitTransitionStyle = { - transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, -} satisfies CSSProperties; - -// The collapsed cap peeking above the front banner is the only hint that more -// banners are stacked behind it, so its border must match the severity of the -// first hidden banner — a neutral banner must not masquerade as a warning. -const stackCapBorderClass: Record = { - default: "border-border", - error: "border-destructive/24", - info: "border-info/24", - success: "border-success/24", - warning: "border-warning/24", -}; export interface ComposerBannerStackItem { readonly id: string; - readonly variant: "default" | "error" | "info" | "success" | "warning"; - // Ordering hint for stack assemblers: front this banner even though its - // variant is calm (e.g. live update progress). The stack itself ignores it. + readonly variant: ComposerBannerVariant; + readonly priority?: "urgent" | "activity" | "notice"; readonly urgent?: boolean; + readonly className?: string; + readonly actionClassName?: string; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; + readonly children?: ReactNode; readonly actions?: ReactNode; - readonly className?: string; - readonly actionClassName?: string; readonly dismissLabel?: string; readonly onDismiss?: () => void; } +export type ComposerBannerStackContent = Pick< + ComposerBannerStackItem, + "id" | "variant" | "priority" +> & { readonly content: ReactNode }; + +export type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent; + +function bannerPriority(item: ComposerBannerStackEntry) { + if (item.priority === "activity") { + return 0; + } + if ( + ("urgent" in item && item.urgent) || + item.priority === "urgent" || + item.variant === "error" || + item.variant === "warning" + ) { + return 1; + } + return 2; +} + +export function orderComposerBanners(items: ReadonlyArray) { + return items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b)); +} + interface ComposerBannerStackProps { readonly className?: string; - readonly items: ReadonlyArray; + readonly items: ReadonlyArray; } export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { + const [stackExpanded, setStackExpanded] = useState(false); + const noticesRef = useRef(null); + const peekRef = useRef(null); + const expandedItemsRef = useRef(null); + const pendingFocusRef = useRef<"peek" | "notice" | null>(null); + const expandedItemsId = useId(); const [requestedExitingItemId, setExitingItemId] = useState(null); const dismissTimeoutRef = useRef | null>(null); const exitingItemId = @@ -70,21 +78,40 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro }; }, []); + useEffect(() => { + if (items.length < 2) setStackExpanded(false); + }, [items.length]); + + useLayoutEffect(() => { + if (stackExpanded && pendingFocusRef.current === "notice") { + pendingFocusRef.current = null; + const firstControl = expandedItemsRef.current?.querySelector( + 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]', + ); + (firstControl ?? expandedItemsRef.current)?.focus({ preventScroll: true }); + } else if (!stackExpanded && pendingFocusRef.current === "peek") { + pendingFocusRef.current = null; + peekRef.current?.focus({ preventScroll: true }); + } + }, [stackExpanded]); + if (items.length === 0) { return null; } - const frontItem = items[0]; + // Activity stays attached. Urgency and severity only order the notices behind it. + const orderedItems = orderComposerBanners(items); + const frontItem = orderedItems[0]; if (!frontItem) { return null; } - const stackedItems = items.slice(1); + const stackedItems = orderedItems.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; const firstStackedItem = stackedItems[0]; - const requestDismiss = (item: ComposerBannerStackItem) => { - if (!item.onDismiss || exitingItemId) { + const requestDismiss = (item: ComposerBannerStackEntry) => { + if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) { return; } setExitingItemId(item.id); @@ -98,127 +125,221 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro }; return ( -
-
- {showCollapsedStackCap && firstStackedItem ? ( -
); } +function TimelineMinimapNavigationButton({ + interactive, + direction, + disabled, + onClick, +}: { + interactive: boolean; + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +}) { + const previous = direction === "previous"; + const label = previous ? "Previous turn" : "Next turn"; + const Icon = previous ? ChevronUpIcon : ChevronDownIcon; + + return ( + + + } + > + + + {label} + + ); +} + // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- diff --git a/apps/web/src/components/chat/timelineMinimapNavigation.test.ts b/apps/web/src/components/chat/timelineMinimapNavigation.test.ts new file mode 100644 index 000000000..970c1d946 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapNavigation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveTimelineMinimapCurrentIndex } from "./MessagesTimeline.logic"; + +describe("minimap turn navigation", () => { + const itemBounds = [ + { top: 80, height: 20 }, + { top: 120, height: 20 }, + { top: 220, height: 20 }, + ]; + it("navigates relative to the first visible user turn", () => { + expect( + resolveTimelineMinimapCurrentIndex({ scrollTop: 100, scrollBottom: 500, itemBounds }), + ).toBe(1); + }); + it("keeps the preceding turn current while reading its long response", () => { + expect( + resolveTimelineMinimapCurrentIndex({ scrollTop: 150, scrollBottom: 200, itemBounds }), + ).toBe(1); + }); + it("does not invent a current turn before the first marker or for unmeasured rows", () => { + expect( + resolveTimelineMinimapCurrentIndex({ scrollTop: 0, scrollBottom: 50, itemBounds }), + ).toBeNull(); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 0, + scrollBottom: 50, + itemBounds: [{ top: null, height: null }], + }), + ).toBeNull(); + }); + it("uses a visible marker with an unknown height", () => { + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 80, + scrollBottom: 100, + itemBounds: [{ top: 80, height: null }], + }), + ).toBe(0); + }); +}); diff --git a/docs/user/composer.md b/docs/user/composer.md index bbe7e32da..56e39f83e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -163,3 +163,7 @@ While an agent works through a task list, the composer shows the current task an completion count. Expand the row to see the full list. Other notices stay accessible above it; on web, use the notice peek or keyboard focus to reveal them and Escape to collapse the stack. + +On web and desktop, the timeline minimap includes **Previous turn** and **Next turn** +buttons. They follow the turn at your reading position; keyboard focus on the rail +starts there too. iPhone and iPad offer the same navigation in the thread menu. From cba146fcabda03c8b69a86bc32f8ee1d8395eeb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:59:51 +0200 Subject: [PATCH 10/93] feat: send larger attachments through signed uploads --- PATCH.md | 21 ++++---- .../features/sharing/incoming-share-model.ts | 10 ++-- .../src/lib/composerAttachmentKinds.test.ts | 23 ++++++++- .../mobile/src/lib/composerAttachmentKinds.ts | 19 ++++++- apps/mobile/src/lib/composerDocuments.ts | 8 ++- .../src/environment/ServerEnvironment.test.ts | 5 +- .../hermes/HermesHistoryNormalization.test.ts | 2 +- apps/swift-ios/Core/Attachments.swift | 11 ++-- apps/swift-ios/Core/HTTP.swift | 39 +++++++++++--- apps/swift-ios/Core/Models.swift | 6 +++ apps/swift-ios/Core/T3Client.swift | 51 +++++++++++++++++++ .../Extensions/Shared/ShareInbox.swift | 2 +- .../Chat/ComposerAttachmentKinds.swift | 2 +- .../Features/Chat/ImageAttachmentViews.swift | 2 +- .../SignedAttachmentUploadTests.swift | 46 +++++++++++++++++ .../ComposerAttachmentKindsTests.swift | 2 +- .../ComposerDocumentAttachmentTests.swift | 2 +- .../IncomingShareAttachmentTests.swift | 8 +-- apps/web/src/components/ChatView.tsx | 38 +++++++++++--- apps/web/src/components/chat/ChatComposer.tsx | 32 ++++++++---- .../chat/ComposerAttachmentChips.tsx | 39 +++++++++++++- .../web/src/lib/attachmentUploadQueue.test.ts | 50 +++++++++++++++++- apps/web/src/lib/attachmentUploadQueue.ts | 35 ++++++++----- docs/user/attachments.md | 5 +- packages/contracts/src/chatAttachment.test.ts | 28 ++++++++++ packages/contracts/src/chatAttachment.ts | 2 +- 26 files changed, 410 insertions(+), 78 deletions(-) create mode 100644 apps/swift-ios/Tests/CoreTests/SignedAttachmentUploadTests.swift diff --git a/PATCH.md b/PATCH.md index 9a6be3601..443f3c752 100644 --- a/PATCH.md +++ b/PATCH.md @@ -86,14 +86,13 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera because the union's open member is typed `type: string` and defeats literal narrowing. The composer's own attachment types are drawn from `ChatKnownAttachment`: composer attachments are always locally created and validated, so an unknown kind can never reach them. -- Keeps `PROVIDER_SEND_TURN_MAX_FILE_BYTES` at 20MB rather than upstream's 50MB (`8f49132214`). - Upstream raised the cap alongside a streaming upload path for generic files; on this fork the - composer still sends file/pdf/video attachments through the inline base64 path, whose - `PROVIDER_SEND_TURN_MAX_DATA_URL_CHARS` cap tops out around 21MB, so advertising 50MB would - promise a size the client cannot send. The signed-upload contract widening itself is carried - (`assets.ts` accepts `type: "file"` uploads, `AssetAccess` mints download disposition and - filename/mime claims, `attachmentStore` encodes the extension in the attachment id, and - `http.ts` serves range requests for inline video). +- Ports upstream's 50 MB file limit through signed HTTP uploads in web/desktop and Swift. + PDF/video/file discriminators are preserved in V2 message references. Images retain 10 MB; + the OpenCode native part limit remains 20 MB (larger files use workspace materialization). + Native share intake follows 50 MB, and older servers still enforce their advertised limit + or the 20 MB inline fallback. Inline base64 limits are not widened. Web chips display upload + progress and retry, preserving the fork's attachment keyboard and local-persistence behavior. + The frozen Expo picker/share intake explicitly retains its 20 MB inline transport limit. - Does not carry upstream's "retry failed thread bootstraps with a fresh id" (`8824f8f24f`). It reports a deleted bootstrap thread through the V1 `OrchestrationDispatchCommandError`, which the fork does not define; the fork launches threads through V2 `launchThread`, which keeps the @@ -240,10 +239,8 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera uploads into the thread inside the V1 `Normalizer`; the fork claims them in `ws.ts` on the V2 `dispatchCommand` (`message.dispatch`) and `launchThread` handlers, releasing the claimed copies when the dispatch fails. `launchThread` can only claim when the caller named the thread id — a - server-allocated id has nothing to claim into yet. The upload contract accepts image mime types - only, so the composer's file/pdf/video attachments still ride the inline base64 path, and the - fork does not carry upstream's per-chip upload progress UI (it belongs to the composer drawer - redesign the fork already declined). + server-allocated id has nothing to claim into yet. Signed uploads now handle all known attachment kinds, with per-chip progress and retry. + Pending claims remain at the V2 command boundary; no V1 normalizer is restored. - Does not carry upstream's Codex MCP-elicitation approvals end to end (`7c6163c67`). The contract widening (`ProviderRequestKind`'s `mcp-elicitation`, `ProviderApprovalDecision`'s `acceptAlways`, `ProviderApprovalOption`) lives in the fork's `providerPolicy.ts` rather than upstream's diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts index b68548fd1..cef341555 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -1,8 +1,8 @@ +import { validateInlineComposerAttachment } from "../../lib/composerAttachmentKinds"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; import { inferExtensionFromMimeType, inferMimeTypeFromFileName, - validateComposerAttachment, } from "@t3tools/shared/composerAttachments"; import * as Schema from "effect/Schema"; import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; @@ -183,7 +183,11 @@ export async function buildIncomingShareDraft(input: { } const declaredSize = resolved?.contentSize ?? null; if (declaredSize !== null) { - const preflight = validateComposerAttachment({ name, sizeBytes: declaredSize, mimeType }); + const preflight = validateInlineComposerAttachment({ + name, + sizeBytes: declaredSize, + mimeType, + }); if (!preflight.accepted) { // Reject before reading: no reason to pull 30 MB into memory first. warnings.push(preflight.message); @@ -195,7 +199,7 @@ export async function buildIncomingShareDraft(input: { try { const base64 = await input.fileReader.readBase64(uri); const sizeBytes = declaredSize ?? estimateBase64ByteSize(base64); - const validation = validateComposerAttachment({ name, sizeBytes, mimeType }); + const validation = validateInlineComposerAttachment({ name, sizeBytes, mimeType }); if (!validation.accepted) { warnings.push(validation.message); continue; diff --git a/apps/mobile/src/lib/composerAttachmentKinds.test.ts b/apps/mobile/src/lib/composerAttachmentKinds.test.ts index 5a153367c..83ea7057e 100644 --- a/apps/mobile/src/lib/composerAttachmentKinds.test.ts +++ b/apps/mobile/src/lib/composerAttachmentKinds.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { documentAttachmentKind, toUploadChatDocumentAttachments } from "./composerAttachmentKinds"; +import { + documentAttachmentKind, + toUploadChatDocumentAttachments, + validateInlineComposerAttachment, +} from "./composerAttachmentKinds"; describe("documentAttachmentKind", () => { it("splits kinds the way the attachment contract does", () => { @@ -55,3 +59,20 @@ describe("documentAttachmentKind agreement with web", () => { expect(documentAttachmentKind("", "itinerary.pdf")).toBe("pdf"); }); }); + +it("keeps inline Expo files within the transport limit after the shared file cap increases", () => { + expect( + validateInlineComposerAttachment({ + name: "large.pdf", + mimeType: "application/pdf", + sizeBytes: 25 * 1024 * 1024, + }), + ).toMatchObject({ accepted: false, message: expect.stringContaining("20 MB") }); + expect( + validateInlineComposerAttachment({ + name: "small.pdf", + mimeType: "application/pdf", + sizeBytes: 20 * 1024 * 1024, + }), + ).toMatchObject({ accepted: true, type: "pdf" }); +}); diff --git a/apps/mobile/src/lib/composerAttachmentKinds.ts b/apps/mobile/src/lib/composerAttachmentKinds.ts index 44325a87c..371f59469 100644 --- a/apps/mobile/src/lib/composerAttachmentKinds.ts +++ b/apps/mobile/src/lib/composerAttachmentKinds.ts @@ -1,5 +1,8 @@ import type { UploadChatAttachment, UploadChatImageAttachment } from "@t3tools/contracts"; -import { classifyComposerAttachment } from "@t3tools/shared/composerAttachments"; +import { + classifyComposerAttachment, + validateComposerAttachment, +} from "@t3tools/shared/composerAttachments"; /** * Pure attachment-kind helpers, kept free of native imports so they stay @@ -63,3 +66,17 @@ export function toUploadChatAttachments( /** @deprecated Use toUploadChatAttachments — kept for the document-only path. */ export const toUploadChatDocumentAttachments = toUploadChatAttachments; + +/** The frozen Expo composer still sends inline base64, capped below 28M characters. */ +export function validateInlineComposerAttachment( + input: Parameters[0], +): ReturnType { + const result = validateComposerAttachment(input); + if (result.accepted && result.type !== "image" && input.sizeBytes > 20 * 1024 * 1024) { + return { + accepted: false, + message: `'${result.name}' exceeds this client's 20 MB attachment limit.`, + }; + } + return result; +} diff --git a/apps/mobile/src/lib/composerDocuments.ts b/apps/mobile/src/lib/composerDocuments.ts index 7383dc38d..8f02954f3 100644 --- a/apps/mobile/src/lib/composerDocuments.ts +++ b/apps/mobile/src/lib/composerDocuments.ts @@ -1,12 +1,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; -import { - inferExtensionFromMimeType, - validateComposerAttachment, -} from "@t3tools/shared/composerAttachments"; +import { inferExtensionFromMimeType } from "@t3tools/shared/composerAttachments"; import { estimateBase64ByteSize } from "./base64"; import { documentAttachmentKind, + validateInlineComposerAttachment, type DraftComposerDocumentAttachment, } from "./composerAttachmentKinds"; import { uuidv4 } from "./uuid"; @@ -79,7 +77,7 @@ export async function pickComposerDocuments(input: { readonly existingCount: num } const sizeBytes = asset.size ?? estimateBase64ByteSize(base64); - const validation = validateComposerAttachment({ name, sizeBytes, mimeType }); + const validation = validateInlineComposerAttachment({ name, sizeBytes, mimeType }); if (!validation.accepted) { error = validation.message; continue; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 903ae11d1..9dc1a8eb5 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -91,10 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); - // 20MB, not upstream's 50MB: this fork's composer still sends - // file/pdf/video attachments through the inline base64 path, whose - // PROVIDER_SEND_TURN_MAX_DATA_URL_CHARS cap tops out around 21MB. - expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 20 * 1024 * 1024 }); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); diff --git a/apps/server/src/hermes/HermesHistoryNormalization.test.ts b/apps/server/src/hermes/HermesHistoryNormalization.test.ts index da73b15d7..5a2876ea8 100644 --- a/apps/server/src/hermes/HermesHistoryNormalization.test.ts +++ b/apps/server/src/hermes/HermesHistoryNormalization.test.ts @@ -236,7 +236,7 @@ describe("Hermes imported history normalization", () => { NodeFS.writeFileSync(arbitraryTemp, PNG_BYTES); NodeFS.writeFileSync(unsupported, "not media"); NodeFS.writeFileSync(oversized, PNG_BYTES); - NodeFS.truncateSync(oversized, 20 * 1024 * 1024 + 1); + NodeFS.truncateSync(oversized, 50 * 1024 * 1024 + 1); NodeFS.symlinkSync(outside, symlink); const persist = (sourcePath: string, expectedKind: "image" | "video" = "image") => diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift index 3c5349fb3..fff26a7ef 100644 --- a/apps/swift-ios/Core/Attachments.swift +++ b/apps/swift-ios/Core/Attachments.swift @@ -22,9 +22,8 @@ public enum ImageAttachmentError: LocalizedError, Equatable, Sendable { /// Inline upload shape accepted by `thread.turn.start`. /// -/// There is intentionally no standalone attachment-upload endpoint in the T3 -/// contract. New image bytes travel as a base64 data URL on the turn command; -/// the server normalizes them into a persisted `ChatAttachment`. +/// Local draft representation. T3Client uses signed HTTP uploads when the +/// environment supports them, with inline persistence for older servers. public struct UploadChatImageAttachment: Codable, Equatable, Sendable { public static let maximumBytes = 10 * 1024 * 1024 @@ -188,3 +187,9 @@ public struct UploadChatAttachment: Codable, Equatable, Sendable { ]) } } + +public struct AttachmentUploadURLResult: Codable, Equatable, Sendable { + public let attachmentId: String + public let relativeUrl: String + public let expiresAt: Double +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index 815ea5ae9..6d57ac330 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -95,6 +95,8 @@ public enum HTTPError: LocalizedError, Sendable { } } +private struct EmptyHTTPResponse: Decodable, Sendable {} + private struct ErrorBody: Decodable { let message: String? let reason: String? @@ -230,12 +232,33 @@ public actor EnvironmentAPI { ) } + /// Uses the environment's authenticated transport so relay-bound sessions + /// retain their request proof and credential refresh behavior. + public func uploadAttachment( + for environment: Environment, relativeURL: String, data: Data, mimeType: String + ) async throws { + guard let url = URL(string: relativeURL, relativeTo: environment.httpBaseURL)?.absoluteURL, + url.scheme == environment.httpBaseURL.scheme, + url.host == environment.httpBaseURL.host, + url.port == environment.httpBaseURL.port, + url.path.hasPrefix("/api/attachments/upload/"), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw RPCError.protocolViolation("The server returned an invalid attachment upload URL.") + } + let _: EmptyHTTPResponse = try await authorized( + environment: environment, path: url.path, queryItems: components.queryItems ?? [], + method: "POST", body: data, contentType: mimeType, timeoutInterval: 300, + as: EmptyHTTPResponse.self + ) + } + private func authorized( environment: Environment, path: String, queryItems: [URLQueryItem] = [], method: String, body: Data? = nil, + contentType: String = "application/json", timeoutInterval: TimeInterval? = nil, as type: Result.Type ) async throws -> Result { @@ -253,7 +276,8 @@ public actor EnvironmentAPI { path: path, queryItems: queryItems, method: method, - body: body + body: body, + contentType: contentType ) if let timeoutInterval { request.timeoutInterval = timeoutInterval @@ -290,7 +314,8 @@ public actor EnvironmentAPI { path: path, queryItems: queryItems, method: method, - body: body + body: body, + contentType: contentType ), environment: environment, credential: current @@ -320,7 +345,8 @@ public actor EnvironmentAPI { path: path, queryItems: queryItems, method: method, - body: body + body: body, + contentType: contentType ), environment: environment, credential: current @@ -338,7 +364,8 @@ public actor EnvironmentAPI { path: String, queryItems: [URLQueryItem], method: String, - body: Data? + body: Data?, + contentType: String = "application/json" ) -> URLRequest { var request = URLRequest( url: endpoint(environment.httpBaseURL, path: path, queryItems: queryItems) @@ -346,7 +373,7 @@ public actor EnvironmentAPI { request.httpMethod = method request.httpBody = body if body != nil { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(contentType, forHTTPHeaderField: "Content-Type") } return request } @@ -410,7 +437,7 @@ public actor EnvironmentAPI { traceID: body?.traceId ) } - return try JSONDecoder.t3.decode(type, from: data) + return try JSONDecoder.t3.decode(type, from: data.isEmpty && type == EmptyHTTPResponse.self ? Data("{}".utf8) : data) } } diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index d7b6bfe48..abc168f34 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -51,6 +51,9 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { /// sending a command the server will reject. public let threadPullRequestLinking: Bool? public let threadPullRequestsV2: Bool? + public struct FileAttachments: Codable, Equatable, Sendable { public let maxUploadBytes: Int } + public let attachmentUploads: Bool? + public let fileAttachments: FileAttachments? public let usagePriceOverrides: Bool? public let pullRequestStackActions: Bool? public let pullRequests: Bool? @@ -66,6 +69,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadTitleRegeneration case threadPullRequestLinking case threadPullRequestsV2 + case attachmentUploads, fileAttachments case usagePriceOverrides case pullRequestStackActions case pullRequests @@ -88,6 +92,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { forKey: .threadTitleRegeneration ) threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) + attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) + fileAttachments = try container.decodeIfPresent(FileAttachments.self, forKey: .fileAttachments) usagePriceOverrides = try container.decodeIfPresent(Bool.self, forKey: .usagePriceOverrides) pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) threadPullRequestLinking = try container.decodeIfPresent( diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index 744e2451c..b116c63df 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -287,6 +287,14 @@ public actor T3Client { attachments: [UploadChatAttachment] ) async throws -> [JSONValue] { guard !attachments.isEmpty else { return [] } + let capabilities = environment.descriptor?.capabilities + if capabilities?.attachmentUploads == true, let fileLimit = capabilities?.fileAttachments?.maxUploadBytes { + return try await persistSignedAttachments(attachments, fileLimit: fileLimit) + } + for attachment in attachments where attachment.type != .image && attachment.sizeBytes > 20 * 1024 * 1024 { + throw ImageAttachmentError.tooLarge(actualBytes: attachment.sizeBytes, maximumBytes: 20 * 1024 * 1024) + } + let result = try await rpc.request( RPCMethod.assetsPersistChatAttachments.rawValue, payload: .object([ @@ -304,6 +312,49 @@ public actor T3Client { return persisted } + private func persistSignedAttachments(_ attachments: [UploadChatAttachment], fileLimit: Int) async throws -> [JSONValue] { + var minted: [String] = [] + do { + var persisted: [JSONValue] = [] + // Bound memory and transfers: a phone sends one file at a time. + for attachment in attachments { + try Task.checkCancellation() + let limit = attachment.type == .image ? ComposerAttachments.maximumImageBytes : min(fileLimit, ComposerAttachments.maximumFileBytes) + guard attachment.sizeBytes <= limit else { + throw ImageAttachmentError.tooLarge(actualBytes: attachment.sizeBytes, maximumBytes: limit) + } + guard let comma = attachment.dataUrl.firstIndex(of: ","), + let bytes = Data(base64Encoded: String(attachment.dataUrl[attachment.dataUrl.index(after: comma)...])), + bytes.count == attachment.sizeBytes else { + throw RPCError.protocolViolation("The attachment data is invalid.") + } + let result = try await rpc.request( + "attachments.createUploadUrl", + payload: .object([ + "type": .string(attachment.type == .image ? "image" : "file"), + "name": .string(attachment.name), "mimeType": .string(attachment.mimeType), + "sizeBytes": .number(Double(attachment.sizeBytes)), + ]), as: AttachmentUploadURLResult.self + ) + minted.append(result.attachmentId) + try await api.uploadAttachment(for: environment, relativeURL: result.relativeUrl, data: bytes, mimeType: attachment.mimeType) + persisted.append(.object([ + "type": .string(attachment.type.rawValue), "id": .string(result.attachmentId), + "name": .string(attachment.name), "mimeType": .string(attachment.mimeType), + "sizeBytes": .number(Double(attachment.sizeBytes)), + ])) + } + return persisted + } catch { + // Only pending uploads from this attempt are eligible for deletion. + // The server also expires abandoned uploads if the connection failed. + for id in minted { + _ = try? await rpc.request("attachments.delete", payload: .object(["attachmentId": .string(id)]), as: JSONValue.self) + } + throw error + } + } + /// Hands the thread to the provider as feedback and returns the identifier /// it filed the report under, which the reader is shown so they can quote /// it. Needs a live provider session: the upload is the running agent's own diff --git a/apps/swift-ios/Extensions/Shared/ShareInbox.swift b/apps/swift-ios/Extensions/Shared/ShareInbox.swift index 16216fed0..117aaec7b 100644 --- a/apps/swift-ios/Extensions/Shared/ShareInbox.swift +++ b/apps/swift-ios/Extensions/Shared/ShareInbox.swift @@ -222,7 +222,7 @@ enum T3IncomingShareStore { // ExtensionContractTests asserts they agree, the same way it pins the app // group identifier that also lives in three places. static let maximumImageBytes = 10 * 1_024 * 1_024 - static let maximumFileBytes = 20 * 1_024 * 1_024 + static let maximumFileBytes = 50 * 1_024 * 1_024 static func maximumBytes(isImage: Bool) -> Int { isImage ? maximumImageBytes : maximumFileBytes diff --git a/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift b/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift index cad65ff21..8153e751a 100644 --- a/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift +++ b/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift @@ -39,7 +39,7 @@ public enum ComposerAttachments { public static let maximumImageBytes = 10 * 1024 * 1024 /// `PROVIDER_SEND_TURN_MAX_FILE_BYTES` — PDFs, video and generic files all /// share the looser cap. - public static let maximumFileBytes = 20 * 1024 * 1024 + public static let maximumFileBytes = 50 * 1024 * 1024 public static func maximumBytes(for kind: ComposerAttachmentKind) -> Int { kind == .image ? maximumImageBytes : maximumFileBytes diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift index c92078069..e014679e5 100644 --- a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -658,7 +658,7 @@ enum FeatureImageAttachmentError: LocalizedError, Equatable { /// Turns a picked file into a draft attachment without re-encoding it. /// /// The size cap comes from the classified kind rather than a single constant: -/// the contract gives PDFs, video and generic files 20 MB while images keep the +/// the contract gives PDFs, video and generic files 50 MB while images keep the /// tighter 10 MB limit, and validating here means a rejection is a picker error /// instead of a failed turn. enum FeatureDocumentProcessor { diff --git a/apps/swift-ios/Tests/CoreTests/SignedAttachmentUploadTests.swift b/apps/swift-ios/Tests/CoreTests/SignedAttachmentUploadTests.swift new file mode 100644 index 000000000..4cdc15195 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/SignedAttachmentUploadTests.swift @@ -0,0 +1,46 @@ +import Foundation +import XCTest +@testable import T3Code + +final class SignedAttachmentUploadTests: XCTestCase { + func testUploadsRawBytesToTheEnvironmentWithAuthenticationAndAccepts204() async throws { + let environment = Environment(id: "env", label: "Remote", httpBaseURL: URL(string: "https://host.test")!, webSocketBaseURL: URL(string: "wss://host.test/ws")!) + let transport = AttachmentHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: InMemoryCredentialStore(credentials: ["env": EnvironmentCredential(accessToken: "test-token")])) + try await api.uploadAttachment(for: environment, relativeURL: "/api/attachments/upload/signed-token", data: Data([1, 2, 3]), mimeType: "application/pdf") + let request = await transport.lastRequest + XCTAssertEqual(request?.httpMethod, "POST") + XCTAssertEqual(request?.httpBody, Data([1, 2, 3])) + XCTAssertEqual(request?.value(forHTTPHeaderField: "Content-Type"), "application/pdf") + XCTAssertEqual(request?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + XCTAssertEqual(request?.timeoutInterval, 300) + } + + func testRejectsAnUploadURLOutsideTheEnvironmentBeforeSendingCredentials() async throws { + let environment = Environment(id: "env", label: "Remote", httpBaseURL: URL(string: "https://host.test")!, webSocketBaseURL: URL(string: "wss://host.test/ws")!) + let transport = AttachmentHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: InMemoryCredentialStore()) + do { + try await api.uploadAttachment(for: environment, relativeURL: "https://elsewhere.test/api/attachments/upload/token", data: Data([1]), mimeType: "application/pdf") + XCTFail("Unexpected upload") + } catch is RPCError {} + let request = await transport.lastRequest + XCTAssertNil(request) + } + + func testLargerFileCapabilityIsOptionalForOlderServers() throws { + let old = try JSONDecoder().decode(EnvironmentDescriptor.Capabilities.self, from: Data("{}".utf8)) + XCTAssertNil(old.fileAttachments) + let current = try JSONDecoder().decode(EnvironmentDescriptor.Capabilities.self, from: Data(#"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":52428800}}"#.utf8)) + XCTAssertEqual(current.fileAttachments?.maxUploadBytes, ComposerAttachments.maximumFileBytes) + XCTAssertEqual(current.attachmentUploads, true) + } +} + +private actor AttachmentHTTPTransport: HTTPTransport { + private(set) var lastRequest: URLRequest? + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + lastRequest = request + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerAttachmentKindsTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerAttachmentKindsTests.swift index 892f7d3fa..5027550f0 100644 --- a/apps/swift-ios/Tests/FeatureTests/ComposerAttachmentKindsTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ComposerAttachmentKindsTests.swift @@ -56,7 +56,7 @@ final class ComposerAttachmentKindsTests: XCTestCase { for kind in [ComposerAttachmentKind.pdf, .video, .file] { XCTAssertEqual( ComposerAttachments.maximumBytes(for: kind), - 20 * 1024 * 1024, + 50 * 1024 * 1024, "\(kind.rawValue) should share the file cap" ) } diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerDocumentAttachmentTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerDocumentAttachmentTests.swift index a45f8d1d2..3778d7c5d 100644 --- a/apps/swift-ios/Tests/FeatureTests/ComposerDocumentAttachmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ComposerDocumentAttachmentTests.swift @@ -88,7 +88,7 @@ final class ComposerDocumentAttachmentTests: XCTestCase { ) { error in XCTAssertEqual( error as? FeatureDocumentAttachmentError, - .tooLarge(name: "big.pdf", maximumBytes: 20 * 1024 * 1024) + .tooLarge(name: "big.pdf", maximumBytes: 50 * 1024 * 1024) ) } } diff --git a/apps/swift-ios/Tests/PlatformTests/IncomingShareAttachmentTests.swift b/apps/swift-ios/Tests/PlatformTests/IncomingShareAttachmentTests.swift index 34dd88c73..47108ce81 100644 --- a/apps/swift-ios/Tests/PlatformTests/IncomingShareAttachmentTests.swift +++ b/apps/swift-ios/Tests/PlatformTests/IncomingShareAttachmentTests.swift @@ -111,7 +111,7 @@ final class IncomingShareAttachmentTests: XCTestCase { ComposerAttachments.maximumFileBytes ) XCTAssertEqual(T3IncomingShareStore.maximumBytes(isImage: true), 10 * 1_024 * 1_024) - XCTAssertEqual(T3IncomingShareStore.maximumBytes(isImage: false), 20 * 1_024 * 1_024) + XCTAssertEqual(T3IncomingShareStore.maximumBytes(isImage: false), 50 * 1_024 * 1_024) } // MARK: - Pre-read validation @@ -142,15 +142,15 @@ final class IncomingShareAttachmentTests: XCTestCase { } /// Just over the cap rounds to the same number as the cap, and - /// "is 20 MB — over the 20 MB limit" reads like a bug. + /// "is 50 MB — over the 50 MB limit" reads like a bug. func testAFileJustOverTheCapDoesNotReportTheSameNumberTwice() { let outcome = PlatformIncomingShareValidation.validate( fileName: "archive.zip", mimeType: "application/zip", - byteCount: 20 * 1_024 * 1_024 + 1 + byteCount: 50 * 1_024 * 1_024 + 1 ) - XCTAssertEqual(outcome, .rejected("'archive.zip' is over the 20 MB limit for a file.")) + XCTAssertEqual(outcome, .rejected("'archive.zip' is over the 50 MB limit for a file.")) } func testAnEmptyFileIsRejected() { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b69cb6974..594f6413a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2341,6 +2341,9 @@ function ChatViewContent(props: ChatViewProps) { const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; + const supportsFileAttachmentUploads = + supportsAttachmentUploads && + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments !== undefined; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -6255,11 +6258,27 @@ function ChatViewContent(props: ChatViewProps) { return; } + const fileLimit = supportsFileAttachmentUploads + ? Math.min( + 50 * 1024 * 1024, + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? + 20 * 1024 * 1024, + ) + : 20 * 1024 * 1024; + const oversizedFile = composerImagesSnapshot.find( + (attachment) => attachment.type !== "image" && attachment.sizeBytes > fileLimit, + ); + if (oversizedFile) { + setThreadError( + threadIdForSend, + `'${oversizedFile.name}' exceeds this server's ${Math.floor(fileLimit / 1024 / 1024)} MB file limit.`, + ); + return; + } + sendInFlightRef.current = true; - // Only images have a signed-upload path; the composer's other attachment - // kinds are still encoded inline below. const uploadableImagesSnapshot = composerImagesSnapshot.filter( - (image): image is ComposerImageAttachment => image.type === "image", + (image) => image.type === "image" || supportsFileAttachmentUploads, ); if (supportsAttachmentUploads && uploadableImagesSnapshot.length > 0) { for (const image of uploadableImagesSnapshot) { @@ -6268,7 +6287,10 @@ function ChatViewContent(props: ChatViewProps) { await awaitAttachmentUploads(uploadableImagesSnapshot.map((image) => image.id)); if (getUploadedAttachments({ environmentId, images: uploadableImagesSnapshot }) === null) { sendInFlightRef.current = false; - setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + setThreadError( + threadIdForSend, + "Retry or remove failed attachment uploads before sending.", + ); return; } } @@ -6306,10 +6328,13 @@ function ChatViewContent(props: ChatViewProps) { const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => { - if (supportsAttachmentUploads && image.type === "image") { + if ( + supportsAttachmentUploads && + (image.type === "image" || supportsFileAttachmentUploads) + ) { const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; if (!uploaded) { - throw new Error(`Image '${image.name}' did not finish uploading.`); + throw new Error(`Attachment '${image.name}' did not finish uploading.`); } return uploaded; } @@ -7784,6 +7809,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId={environmentId} attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} + supportsFileAttachmentUploads={supportsFileAttachmentUploads} routeKind={routeKind} draftId={draftId} activeThreadId={activeThreadId} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d75f90f28..821ae05de 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -84,10 +84,7 @@ import { startAttachmentUpload, useAttachmentUploadStore, } from "../../lib/attachmentUploadQueue"; -import { - attachmentUploadBlockReason, - formatAttachmentUploadProgress, -} from "../../lib/attachmentUploadState"; +import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import { resolveShortcutCommand } from "../../keybindings"; @@ -569,6 +566,7 @@ export interface ChatComposerProps { environmentId: EnvironmentId; attachmentUploadsCapabilityKnown: boolean; supportsAttachmentUploads: boolean; + supportsFileAttachmentUploads?: boolean; routeKind: "server" | "draft"; draftId: DraftId | null; @@ -713,6 +711,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, attachmentUploadsCapabilityKnown, supportsAttachmentUploads, + supportsFileAttachmentUploads = false, routeKind, draftId, activeThreadId, @@ -802,7 +801,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); const attachmentBlockReason = supportsAttachmentUploads ? attachmentUploadBlockReason({ - imageIds: composerImages.map((image) => image.id), + imageIds: composerImages + .filter((image) => image.type === "image" || supportsFileAttachmentUploads) + .map((image) => image.id), uploadsByImageId, environmentId, }) @@ -854,14 +855,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } - // Image-only: the signed-upload contract accepts image mime types alone, - // so the composer's file/pdf/video attachments still ride the send path's - // inline encoding. for (const image of composerImages) { - if (image.type !== "image") continue; + if (image.type !== "image" && !supportsFileAttachmentUploads) continue; startAttachmentUpload({ environmentId, image }); } - }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]); + }, [ + attachmentUploadsCapabilityKnown, + composerImages, + environmentId, + supportsAttachmentUploads, + supportsFileAttachmentUploads, + ]); // ------------------------------------------------------------------ // Model state @@ -3504,9 +3508,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: image.mimeType, sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, + upload: + uploadsByImageId[image.id]?.environmentId === environmentId + ? uploadsByImageId[image.id] + : undefined, }))} nonPersistedIds={nonPersistedComposerImageIdSet} onRemove={removeComposerImage} + onRetry={(id) => { + const image = composerImages.find((item) => item.id === id); + if (image) retryAttachmentUpload({ environmentId, image }); + }} onPreview={(id) => { const preview = buildExpandedImagePreview(composerImages, id); if (!preview) return; diff --git a/apps/web/src/components/chat/ComposerAttachmentChips.tsx b/apps/web/src/components/chat/ComposerAttachmentChips.tsx index bb800ee06..2c1551e2e 100644 --- a/apps/web/src/components/chat/ComposerAttachmentChips.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentChips.tsx @@ -7,9 +7,13 @@ */ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; import { formatAttachmentSize, middleTruncateFileName } from "@t3tools/shared/composerAttachments"; -import { CircleAlertIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useCallback, useRef, useState } from "react"; +import { + formatAttachmentUploadProgress, + type AttachmentUploadState, +} from "../../lib/attachmentUploadState"; import { useTheme } from "../../hooks/useTheme"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; @@ -31,11 +35,13 @@ export interface ComposerAttachmentChip { readonly mimeType: string; readonly sizeBytes: number; readonly previewUrl?: string | undefined; + readonly upload?: AttachmentUploadState | undefined; } export function ComposerAttachmentChips(props: { readonly attachments: ReadonlyArray; readonly nonPersistedIds: ReadonlySet; + readonly onRetry?: (id: string) => void; readonly onRemove: (id: string) => void; readonly onPreview: (id: string) => void; readonly onFocusEditor: () => void; @@ -168,6 +174,37 @@ export function ComposerAttachmentChips(props: { )} + {attachment.upload?.status === "uploading" && ( + + {formatAttachmentUploadProgress(attachment.upload.progress)} + + )} + {attachment.upload?.status === "failed" && props.onRetry && ( + + props.onRetry?.(attachment.id)} + /> + } + > + + + {attachment.upload.reason} + + )} + {props.nonPersistedIds.has(attachment.id) && ( Draft attachment could not be saved locally and may be lost on navigation. diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index 2b2b94431..cb36463d3 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { ComposerImageAttachment } from "../composerDraftStore"; +import type { ComposerImageAttachment, ComposerAttachment } from "../composerDraftStore"; const mocks = vi.hoisted(() => ({ createUploadUrl: Symbol("create-upload-url"), @@ -189,6 +189,54 @@ describe("attachmentUploadQueue", () => { ); }); + it.each([ + ["file", "archive.zip", "application/zip"], + ["pdf", "spec.pdf", "application/pdf"], + ["video", "demo.mp4", "video/mp4"], + ] as const)( + "uploads %s bytes separately and retains the message kind", + async (type, name, mimeType) => { + const file = new File([new Uint8Array(25 * 1024 * 1024)], name, { type: mimeType }); + const attachment: ComposerAttachment = { + type, + id: "document", + name, + mimeType, + sizeBytes: file.size, + file, + previewUrl: "", + role: "upload", + }; + startAttachmentUpload({ environmentId: firstEnvironment, image: attachment }); + await Promise.resolve(); + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.createUploadUrl, + { + environmentId: firstEnvironment, + input: { type: "file", name, mimeType, sizeBytes: file.size }, + }, + expect.anything(), + ); + const settled = awaitAttachmentUploads([attachment.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + expect( + getUploadedAttachments({ environmentId: firstEnvironment, images: [attachment] }), + ).toEqual([ + { + type, + id: `pending-environment-1-${name}`, + name, + mimeType, + sizeBytes: file.size, + role: "upload", + }, + ]); + releaseAttachmentUploads([attachment]); + }, + ); + it("retries rejected uploads", async () => { const image = makeImage("image-retry"); startAttachmentUpload({ environmentId: firstEnvironment, image }); diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 37eb924ca..e008d2997 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -7,7 +7,7 @@ import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { create } from "zustand"; -import type { ComposerImageAttachment } from "../composerDraftStore"; +import type { ComposerAttachment } from "../composerDraftStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { attachmentEnvironment } from "../state/attachments"; import { readPreparedConnection } from "../state/session"; @@ -25,7 +25,7 @@ export const useAttachmentUploadStore = create(() => ({ })); interface UploadJob { - readonly image: ComposerImageAttachment; + readonly image: ComposerAttachment; readonly environmentId: EnvironmentId; readonly previous?: ReadyAttachmentUpload; readonly settled: Promise; @@ -104,7 +104,7 @@ async function runUpload(job: UploadJob): Promise { const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), ); - if (!mimeType) { + if (job.image.type === "image" && !mimeType) { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, @@ -119,11 +119,19 @@ async function runUpload(job: UploadJob): Promise { attachmentEnvironment.createUploadUrl, { environmentId: job.environmentId, - input: { - name: job.image.name, - mimeType, - sizeBytes: job.image.file.size, - }, + input: + job.image.type === "image" && mimeType + ? { + name: job.image.name, + mimeType, + sizeBytes: job.image.file.size, + } + : { + type: "file", + name: job.image.name, + mimeType: job.image.mimeType, + sizeBytes: job.image.file.size, + }, }, { reportFailure: false }, ); @@ -249,7 +257,7 @@ function pumpUploads(): void { export function startAttachmentUpload(input: { readonly environmentId: EnvironmentId; - readonly image: ComposerImageAttachment; + readonly image: ComposerAttachment; }): void { const existingJob = jobsByImageId.get(input.image.id); if (existingJob?.environmentId === input.environmentId) { @@ -342,7 +350,7 @@ export function releaseAttachmentUpload(imageId: string): void { export function retryAttachmentUpload(input: { readonly environmentId: EnvironmentId; - readonly image: ComposerImageAttachment; + readonly image: ComposerAttachment; }): void { const previous = readAttachmentUpload(input.image.id); cancelAttachmentUpload(input.image.id); @@ -363,7 +371,7 @@ export async function awaitAttachmentUploads(imageIds: ReadonlyArray): P export function getUploadedAttachments(input: { readonly environmentId: EnvironmentId; - readonly images: ReadonlyArray; + readonly images: ReadonlyArray; }): ChatAttachment[] | null { const attachments: ChatAttachment[] = []; for (const image of input.images) { @@ -372,17 +380,18 @@ export function getUploadedAttachments(input: { return null; } attachments.push({ - type: "image", + type: image.type, id: upload.attachmentId, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, + ...(image.role === undefined ? {} : { role: image.role }), }); } return attachments; } -export function releaseAttachmentUploads(images: ReadonlyArray): void { +export function releaseAttachmentUploads(images: ReadonlyArray): void { for (const image of images) { releaseAttachmentUpload(image.id); } diff --git a/docs/user/attachments.md b/docs/user/attachments.md index ef89f5629..8594eeb1c 100644 --- a/docs/user/attachments.md +++ b/docs/user/attachments.md @@ -9,7 +9,7 @@ Attachments work in the chat composer on web, desktop, and mobile. - **Web and desktop**: drag files onto the composer, paste them, or use **+ → Attach files**. - **Mobile**: tap the attach button for **Camera**, **Photos**, or **Files**, or share a file into T3 Code from another app. -You can attach up to 8 files per message. Images can be up to 10 MB and other files up to 20 MB. Large images are scaled down to fit rather than rejected. +You can attach up to 8 files per message. Images can be up to 10 MB and other files up to 50 MB on current servers. Large images are scaled down to fit rather than rejected. Any file type works — PDFs, spreadsheets, CSVs, logs, archives, audio, source files. There is no longer a separate list of file types per provider. @@ -43,3 +43,6 @@ In web and desktop, open an image from the conversation, then click it to zoom i to fit. Scroll to zoom and drag to pan. With the image focused, **+** and **−** change zoom and **0** returns to fit. Arrow keys pan while zoomed and move between images while fitted. Downloading still saves the original image. + +Uploads show progress in the web composer. If an upload fails, retry it from its chip or +remove it; the draft stays available. Older servers may accept smaller files. diff --git a/packages/contracts/src/chatAttachment.test.ts b/packages/contracts/src/chatAttachment.test.ts index 26cd0786a..61f5e3f42 100644 --- a/packages/contracts/src/chatAttachment.test.ts +++ b/packages/contracts/src/chatAttachment.test.ts @@ -47,3 +47,31 @@ it("rejects malformed known attachment types instead of tolerating them", () => }), ); }); + +it("accepts 50 MB uploaded file references while keeping the 10 MB image cap", () => { + for (const [type, mimeType] of [ + ["file", "application/zip"], + ["pdf", "application/pdf"], + ["video", "video/mp4"], + ]) { + assert.strictEqual( + Schema.decodeUnknownSync(ChatAttachment)({ + type, + mimeType, + id: "pending-file", + name: "document", + sizeBytes: 50 * 1024 * 1024, + }).sizeBytes, + 50 * 1024 * 1024, + ); + } + assert.throws(() => + Schema.decodeUnknownSync(ChatAttachment)({ + type: "image", + mimeType: "image/png", + id: "pending-image", + name: "photo.png", + sizeBytes: 11 * 1024 * 1024, + }), + ); +}); diff --git a/packages/contracts/src/chatAttachment.ts b/packages/contracts/src/chatAttachment.ts index 13025fec9..9620e6cf7 100644 --- a/packages/contracts/src/chatAttachment.ts +++ b/packages/contracts/src/chatAttachment.ts @@ -5,7 +5,7 @@ import { MessageId, NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./ba export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; -export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 20 * 1024 * 1024; +export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ "image/gif", "image/jpeg", From 5a36c3834eddbd32280a7ffe2236a3d0d9970523 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:12:17 +0200 Subject: [PATCH 11/93] feat: port Codex app-access approvals onto V2 --- PATCH.md | 15 +- .../Adapters/CodexAdapterV2.test.ts | 117 +++++++++ .../Adapters/CodexAdapterV2.ts | 105 ++++++++- .../src/provider/codexMcpElicitation.test.ts | 206 ++++++++++++++++ .../src/provider/codexMcpElicitation.ts | 222 ++++++++++++++++++ apps/swift-ios/App/NativeFeatureClient.swift | 11 +- .../Core/OrchestrationV2Models.swift | 15 +- .../Chat/FeatureComposerRequestViews.swift | 12 +- .../Chat/ThreadActivityInspector.swift | 2 +- .../Features/Chat/ThreadWorkLog.swift | 2 +- .../Features/Shared/FeatureModels.swift | 23 +- .../Fixtures/orchestrationV2Projection.json | 32 ++- .../OrchestrationV2ContractTests.swift | 12 + apps/web/src/components/chat/ChatComposer.tsx | 2 + .../chat/ComposerPendingApprovalActions.tsx | 64 +++-- .../chat/ComposerPendingApprovalPanel.tsx | 2 +- docs/user/composer.md | 5 + .../client-runtime/src/state/entities.test.ts | 2 + .../src/state/threadRequests.ts | 5 + packages/contracts/src/orchestrationV2.ts | 3 + scripts/generate-swift-contract-fixtures.ts | 5 + 21 files changed, 795 insertions(+), 67 deletions(-) create mode 100644 apps/server/src/provider/codexMcpElicitation.test.ts create mode 100644 apps/server/src/provider/codexMcpElicitation.ts diff --git a/PATCH.md b/PATCH.md index 443f3c752..0a184ed52 100644 --- a/PATCH.md +++ b/PATCH.md @@ -241,15 +241,12 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera when the dispatch fails. `launchThread` can only claim when the caller named the thread id — a server-allocated id has nothing to claim into yet. Signed uploads now handle all known attachment kinds, with per-chip progress and retry. Pending claims remain at the V2 command boundary; no V1 normalizer is restored. -- Does not carry upstream's Codex MCP-elicitation approvals end to end (`7c6163c67`). The contract - widening (`ProviderRequestKind`'s `mcp-elicitation`, `ProviderApprovalDecision`'s `acceptAlways`, - `ProviderApprovalOption`) lives in the fork's `providerPolicy.ts` rather than upstream's - `orchestration.ts`, and `CodexSessionRuntime` carries upstream's handler — but that module is - V1 leftovers the fork's V2 stack does not run, and `CodexAdapterV2` registers no - `mcpServer/elicitation/request` handler. Codex app-access prompts therefore do not reach the - fork's clients yet; the approval panels only label the kind. `acceptAlways` collapses to - `acceptForSession` on the wire, which is the widest grant Codex's command/file-change approval - responses can carry. +- Ports Codex MCP app-access approvals (`7c6163c67`) onto `CodexAdapterV2`, with + provider-labelled choices carried in the V2 JSON turn-item projection and web/Swift composers. + The live adapter rejects decisions the provider did not offer. Session/permanent grants are + returned as MCP form content and persistence metadata; unsupported input forms and URL + elicitations remain declined, matching upstream. This imports only the isolated form helpers, + never the V1 runtime. Ordinary Codex command/file approvals retain their session-only ceiling. - Keeps the fork's `MarkdownMedia` path for chat markdown images instead of upstream's `classifyMarkdownImageSource` renderer (`77c9d1eb5`, `5a7a7cf29`, `55c909334`). The fork's path already resolves workspace files through signed asset URLs and additionally handles browser diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 08ef7ff21..abe1a5d75 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1115,10 +1115,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); const events: Array = []; const terminalReceipt = yield* Deferred.make(); + const approvalReceipt = + yield* Deferred.make>(); yield* runtime.events.pipe( Stream.runForEach((event) => Effect.gen(function* () { events.push(event); + if (event.type === "turn_item.updated" && event.turnItem.type === "approval_request") + yield* Deferred.succeed(approvalReceipt, event); if (event.type === "turn.terminal") yield* Deferred.succeed(terminalReceipt, undefined); }), ), @@ -1146,6 +1150,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { continuationRequests, terminalEvents, awaitTerminal: Deferred.await(terminalReceipt), + awaitApproval: Deferred.await(approvalReceipt), subagentUpdates, hasPendingBackgroundWork, }; @@ -1157,6 +1162,118 @@ describe("CodexAdapterV2 post-settle continuation", () => { event.type === "message.updated" && event.message.role === "assistant", ); + it.effect( + "routes Codex app-access approval choices through V2 and rejects unoffered persistence", + () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "mcp-thread"; + const nativeTurnId = "mcp-turn"; + const transcript = makeCodexReplayTranscript({ + scenario: "mcp-approval", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Use Safari." }), + { + type: "emit_inbound", + label: "mcp approval", + frame: { + id: 99, + method: "mcpServer/elicitation/request", + params: { + mode: "form", + threadId: nativeThreadId, + turnId: nativeTurnId, + serverName: "computer-use", + message: "Allow ChatGPT to use Safari?", + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Once" }, + { const: "session", title: "Allow Safari this session" }, + ], + }, + }, + required: ["approval"], + }, + }, + }, + }, + { + type: "expect_outbound", + label: "approval response", + frame: { + id: 99, + result: { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }, + }, + }, + { + type: "emit_inbound", + label: "turn completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("mcp-attempt"), + text: "Use Safari.", + }), + ); + const event = yield* harness.awaitApproval; + if (event.turnItem.type !== "approval_request") + return yield* Effect.die("Missing approval"); + const item = event.turnItem; + assert.equal(item.requestKind, "mcp-elicitation"); + assert.equal(item.prompt, "Allow ChatGPT to use Safari?"); + assert.equal(item.title, "Safari"); + const requestNode = harness.events.find( + (event) => event.type === "node.updated" && event.node.id === item.nodeId, + ); + assert.ok(requestNode?.type === "node.updated"); + if (requestNode?.type === "node.updated") + assert.equal(requestNode.node.parentNodeId, "node-mcp-attempt"); + assert.equal(item.nativeItemRef, null); + assert.deepEqual( + item.options?.map((option) => option.decision), + ["cancel", "decline", "acceptForSession", "accept"], + ); + assert.equal( + item.options?.find((option) => option.decision === "acceptForSession")?.label, + "Allow Safari this session", + ); + const rejected = yield* Effect.exit( + harness.runtime.respondToRuntimeRequest({ + requestId: item.requestId, + decision: "acceptAlways", + }), + ); + assert.equal(rejected._tag, "Failure"); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: item.requestId, + decision: "acceptForSession", + }); + yield* harness.awaitTerminal; + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("projects async questions without holding the provider turn open", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 575995494..5c22ad571 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,7 @@ +import { + describeMcpElicitation, + toMcpElicitationResponse, +} from "../../provider/codexMcpElicitation.ts"; import { classifyV2AgentKind, CodexSettings, @@ -26,6 +30,7 @@ import type { OrchestrationV2TurnItem, ProviderUserInputAnswers, ProviderApprovalDecision, + ProviderApprovalOption, ProviderRequestKind, ProviderTurnId, ProviderInstanceId, @@ -977,6 +982,7 @@ type PendingCodexRuntimeRequest = readonly requestId: RuntimeRequestId; readonly requestKind: ProviderRequestKind; readonly decision: Deferred.Deferred; + readonly allowedDecisions?: ReadonlyArray; } | { readonly type: "user_input"; @@ -1541,6 +1547,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi now, }); const events = yield* Queue.unbounded(); + const mcpRequestSequence = yield* Ref.make(0); const activeTurns = yield* Ref.make(new Map()); const pendingRootTurns = yield* Ref.make(new Map()); const turnWaiters = yield* Ref.make(new Map>()); @@ -3197,13 +3204,18 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi readonly nativeRequestId: string; readonly requestKind: ProviderRequestKind; readonly prompt?: string | null; + readonly title?: string; + readonly options?: ReadonlyArray; }) => Effect.gen(function* () { const createdAt = yield* DateTime.now; - const parentNodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); + const parentNodeId = + input.requestKind === "mcp-elicitation" + ? input.context.rootNodeId + : idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: input.nativeItemId, + }); const ordinal = yield* resolveItemOrdinal( input.context, `${input.nativeItemId}:approval:${input.nativeRequestId}`, @@ -3231,7 +3243,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi countsForRun: false, providerThreadId: input.context.providerThread.id, providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), + nativeItemRef: + input.requestKind === "mcp-elicitation" + ? null + : codexNativeItemRef(input.nativeItemId), runtimeRequestId: requestId, checkpointScopeId: null, startedAt: createdAt, @@ -3262,17 +3277,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi nodeId, providerThreadId: input.context.providerThread.id, providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), + nativeItemRef: + input.requestKind === "mcp-elicitation" + ? null + : codexNativeItemRef(input.nativeItemId), parentItemId: null, ordinal, status: "waiting", - title: null, + title: input.title ?? null, startedAt: createdAt, completedAt: null, updatedAt: createdAt, type: "approval_request", requestId, requestKind: input.requestKind, + ...(input.options === undefined ? {} : { options: input.options }), ...(input.prompt === null || input.prompt === undefined ? {} : { prompt: input.prompt }), @@ -4126,6 +4145,68 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe(Effect.orDie), ); + yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) => + Effect.gen(function* () { + if (toMcpElicitationResponse(payload, "accept").action !== "accept") + return { action: "decline" } as const; + const context = payload.turnId + ? yield* awaitActiveTurn(payload.turnId) + : yield* findActiveTurnByNativeThreadId(payload.threadId); + if ( + context === undefined || + context.providerThread.nativeThreadRef?.nativeId !== payload.threadId + ) + return { action: "decline" } as const; + const nativeRequestId = `mcp-elicitation:${yield* Ref.modify(mcpRequestSequence, (current) => [current, current + 1])}`; + const description = describeMcpElicitation(payload); + const artifacts = yield* buildApprovalRequestArtifacts({ + context, + nativeItemId: nativeRequestId, + nativeRequestId, + requestKind: "mcp-elicitation", + prompt: payload.message, + title: description.appName, + options: description.options, + }); + const decision = yield* Deferred.make(); + yield* Ref.update(pendingRuntimeRequests, (current) => + new Map(current).set(String(artifacts.request.id), { + type: "approval", + requestId: artifacts.request.id, + requestKind: "mcp-elicitation", + decision, + allowedDecisions: description.options.map((option) => option.decision), + }), + ); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: artifacts.node, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: CODEX_PROVIDER, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(artifacts.request.id)); + return updated; + }), + ), + ); + return toMcpElicitationResponse(payload, resolved); + }).pipe(Effect.orDie), + ); + yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => Effect.gen(function* () { const context = yield* awaitActiveTurn(payload.turnId); @@ -5328,6 +5409,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), }); } + if ( + pending.allowedDecisions && + !pending.allowedDecisions.includes(requestInput.decision) + ) { + return yield* new ProviderAdapterRuntimeRequestResponseError({ + driver: CODEX_PROVIDER, + requestId: requestInput.requestId, + cause: toProtocolError("This approval decision was not offered by the provider."), + }); + } yield* Deferred.succeed(pending.decision, requestInput.decision); }).pipe( Effect.mapError((cause) => diff --git a/apps/server/src/provider/codexMcpElicitation.test.ts b/apps/server/src/provider/codexMcpElicitation.test.ts new file mode 100644 index 000000000..7dcb6a434 --- /dev/null +++ b/apps/server/src/provider/codexMcpElicitation.test.ts @@ -0,0 +1,206 @@ +import * as NodeAssert from "node:assert/strict"; +import { describe, it } from "@effect/vitest"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { describeMcpElicitation, toMcpElicitationResponse } from "./codexMcpElicitation.ts"; + +describe("Codex MCP elicitation approvals", () => { + const request = { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { + app_name: "Safari", + persist: ["session", "always"], + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Allow once" }, + { const: "session", title: "Allow for this session" }, + { const: "always", title: "Always allow Safari" }, + ], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + it("preserves the app name and advertised persistence choices", () => { + NodeAssert.deepStrictEqual(describeMcpElicitation(request), { + appName: "Safari", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Allow for this session" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("extracts the app name from a Computer Use request without metadata", () => { + const { _meta, ...requestWithoutMetadata } = request; + + NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); + }); + + it("returns the accepted form option to Codex", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { + action: "accept", + content: { approval: "once" }, + }); + }); + + it("returns session-scoped approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }); + }); + + it("returns persistent approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("returns rejection without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { + action: "decline", + }); + }); + + it("returns cancellation without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { + action: "cancel", + }); + }); + + it("supports boolean permanent-approval fields", () => { + const booleanRequest = { + ...request, + _meta: { app_name: "Safari" }, + requestedSchema: { + type: "object", + properties: { + always: { type: "boolean", title: "Always allow Safari" }, + }, + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.ok( + describeMcpElicitation(booleanRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { always: true }, + }); + }); + + it("preserves valid nullable MCP form fields and persistence choices", () => { + const nullableRequest = { + ...request, + _meta: { + app_name: null, + appName: "Safari", + connector_name: null, + persist: null, + target: null, + tool_params: null, + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + title: null, + description: null, + default: null, + enum: ["once", "always"], + enumNames: null, + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); + NodeAssert.ok( + describeMcpElicitation(nullableRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("declines required form fields that an approval prompt cannot collect", () => { + const inputRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { + action: "decline", + }); + }); + + it("does not approve URL elicitations without opening their requested URL", () => { + const urlRequest = { + mode: "url", + message: "Finish signing in to continue.", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + elicitationId: "sign-in-1", + url: "https://example.com/authorize", + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { + action: "decline", + }); + }); + + it("omits persistence choices that cannot satisfy required form fields", () => { + const onceOnlyRequest = { + ...request, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once"], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ]); + }); +}); diff --git a/apps/server/src/provider/codexMcpElicitation.ts b/apps/server/src/provider/codexMcpElicitation.ts new file mode 100644 index 000000000..3ac2fdca0 --- /dev/null +++ b/apps/server/src/provider/codexMcpElicitation.ts @@ -0,0 +1,222 @@ +import type { ProviderApprovalDecision, ProviderApprovalOption } from "@t3tools/contracts"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; +import * as Schema from "effect/Schema"; + +const NullableMcpElicitationString = Schema.NullOr(Schema.String); +const McpElicitationMetadata = Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + appName: Schema.optionalKey(NullableMcpElicitationString), + connector_name: Schema.optionalKey(NullableMcpElicitationString), + connectorName: Schema.optionalKey(NullableMcpElicitationString), + allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + persist: Schema.optionalKey( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), + target: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + tool_params: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), +}); +const McpElicitationFormField = Schema.Struct({ + type: Schema.optionalKey(NullableMcpElicitationString), + title: Schema.optionalKey(NullableMcpElicitationString), + description: Schema.optionalKey(NullableMcpElicitationString), + default: Schema.optionalKey(Schema.Unknown), + enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + oneOf: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + const: Schema.String, + title: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + ), +}); +const McpElicitationForm = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), + required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), +}); +const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); +const isMcpElicitationForm = Schema.is(McpElicitationForm); + +type McpElicitationPersistenceDecision = Extract< + ProviderApprovalDecision, + "acceptForSession" | "acceptAlways" +>; + +function mcpElicitationPersistenceDecision( + value: string, +): McpElicitationPersistenceDecision | null { + const normalized = value.toLowerCase(); + if (normalized.includes("session")) return "acceptForSession"; + if ( + normalized.includes("always") || + normalized.includes("permanent") || + normalized.includes("forever") || + normalized.includes("persistent") + ) { + return "acceptAlways"; + } + return null; +} + +function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { + if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { + return undefined; + } + return payload.requestedSchema; +} + +function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { + if (field.oneOf) { + return field.oneOf.map((option) => ({ value: option.const, label: option.title })); + } + return (field.enum ?? []).map((value, index) => ({ + value, + label: field.enumNames?.[index], + })); +} + +function isMcpElicitationPersistenceField( + key: string, + field: typeof McpElicitationFormField.Type, +): boolean { + return ( + mcpElicitationPersistenceDecision(key) !== null || + key.toLowerCase() === "persist" || + mcpElicitationPersistenceDecision(field.title ?? "") !== null || + mcpElicitationPersistenceDecision(field.description ?? "") !== null + ); +} + +/** Returns the app and approval choices advertised by an MCP elicitation. */ +export function describeMcpElicitation( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): { readonly appName: string; readonly options: ReadonlyArray } { + const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; + const appName = + metadata?.app_name ?? + metadata?.appName ?? + metadata?.app ?? + metadata?.target?.app ?? + metadata?.target?.name ?? + metadata?.tool_params?.app_name ?? + metadata?.tool_params?.app ?? + payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? + metadata?.connector_name ?? + metadata?.connectorName ?? + payload.serverName; + const persistenceOptions = new Map(); + const persist = metadata?.persist; + for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { + const decision = mcpElicitationPersistenceDecision(value); + if (decision) persistenceOptions.set(decision, ""); + } + if (metadata?.allowPersistentApproval) { + persistenceOptions.set("acceptAlways", ""); + } + + const form = mcpElicitationFormFields(payload); + for (const [key, field] of Object.entries(form?.properties ?? {})) { + for (const option of mcpElicitationFieldOptions(field)) { + const decision = mcpElicitationPersistenceDecision(option.value); + if (decision) persistenceOptions.set(decision, option.label ?? ""); + } + if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + persistenceOptions.set("acceptAlways", field.title ?? ""); + } + } + + return { + appName, + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(persistenceOptions.has("acceptForSession") && + toMcpElicitationResponse(payload, "acceptForSession").action === "accept" + ? [ + { + decision: "acceptForSession" as const, + label: persistenceOptions.get("acceptForSession") || "Always allow this session", + }, + ] + : []), + ...(persistenceOptions.has("acceptAlways") && + toMcpElicitationResponse(payload, "acceptAlways").action === "accept" + ? [ + { + decision: "acceptAlways" as const, + label: persistenceOptions.get("acceptAlways") || "Always allow", + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }; +} + +/** Converts a T3 approval decision into the MCP elicitation wire response. */ +export function toMcpElicitationResponse( + payload: EffectCodexSchema.McpServerElicitationRequestParams, + decision: ProviderApprovalDecision, +): EffectCodexSchema.McpServerElicitationRequestResponse { + if (decision === "decline" || decision === "cancel") { + return { action: decision }; + } + + if (payload.mode === "url") { + return { action: "decline" }; + } + + const persist = + decision === "acceptForSession" + ? "session" + : decision === "acceptAlways" + ? "always" + : undefined; + const form = mcpElicitationFormFields(payload); + const content: Record = {}; + + for (const [key, field] of Object.entries(form?.properties ?? {})) { + const options = mcpElicitationFieldOptions(field); + const chosenOption = options.find((option) => + persist + ? mcpElicitationPersistenceDecision(option.value) === decision + : /once|accept|approve|allow/i.test(option.value) && + mcpElicitationPersistenceDecision(option.value) === null, + ); + if (chosenOption) { + content[key] = chosenOption.value; + } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + content[key] = decision === "acceptAlways"; + } else if (field.default !== undefined && field.default !== null) { + content[key] = field.default; + } + } + + if (form?.required?.some((key) => !Object.hasOwn(content, key))) { + return { action: "decline" }; + } + + return { + action: "accept", + ...(persist ? { _meta: { persist } } : {}), + ...(form ? { content } : {}), + }; +} diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 8823a26f2..d6ded05b5 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -1705,6 +1705,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, let wireDecision = switch decision { case .allowOnce: "accept" case .allowForSession: "acceptForSession" + case .allowAlways: "acceptAlways" + case .cancel: "cancel" case .deny: "decline" } _ = try await route.client.respondToApproval( @@ -4220,7 +4222,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } switch item.payload { - case let .approvalRequest(requestID, requestKind, prompt): + case let .approvalRequest(requestID, requestKind, prompt, options): // The item's own status is the authority on whether the request // is still open; V1 had to pair requested/resolved activities. guard !item.status.isTerminal else { break } @@ -4239,7 +4241,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, threadID: threadID, kind: mapApprovalKind(requestKind), title: item.base.title ?? approvalTitle(for: requestKind), - detail: prompt ?? "" + detail: prompt ?? "", + options: options?.compactMap { option in + FeatureApprovalDecision(providerDecision: option.decision).map { FeatureApprovalOption(decision: $0, label: option.label) } + } ) ) @@ -5928,7 +5933,7 @@ private struct ProjectionItemSupportIndex { // An approval or user-input row names its own request; every other row // reaches it through the execution node that raised it. let requestID: String? = switch item.payload { - case let .approvalRequest(requestID, _, _): requestID + case let .approvalRequest(requestID, _, _, _): requestID case let .userInputRequest(requestID, _): requestID default: node?.runtimeRequestId } diff --git a/apps/swift-ios/Core/OrchestrationV2Models.swift b/apps/swift-ios/Core/OrchestrationV2Models.swift index 5a66ce322..8c58d4e2c 100644 --- a/apps/swift-ios/Core/OrchestrationV2Models.swift +++ b/apps/swift-ios/Core/OrchestrationV2Models.swift @@ -523,7 +523,7 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case commandExecution(input: String, output: String?, exitCode: Int?, liveness: OrchestrationV2CommandLiveness) case fileSearch(pattern: String?, results: [OrchestrationV2FileSearchResult]?) case webSearch(patterns: [String]?, results: [OrchestrationV2WebSearchResult]?) - case approvalRequest(requestID: String, requestKind: String, prompt: String?) + case approvalRequest(requestID: String, requestKind: String, prompt: String?, options: [ProviderApprovalOption]?) case checkpoint(checkpointID: String, scopeID: String, files: [OrchestrationV2CheckpointFileSummary]) case checkpointRollback(checkpointID: String, scopeID: String, restoredFileCount: Int, rolledBackRunCount: Int) case runInterruptRequest(message: String) @@ -548,7 +548,7 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case type case messageId, inputIntent, text, attachments case streaming, planId, markdown, steps, explanation - case requestId, questions, requestKind, prompt + case requestId, questions, requestKind, prompt, options case fileName, additions, deletions, diffStr, oldStr, newStr case input, output, exitCode case pattern, results, patterns @@ -635,7 +635,8 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl payload = .approvalRequest( requestID: try container.decode(String.self, forKey: .requestId), requestKind: try container.decode(String.self, forKey: .requestKind), - prompt: try container.decodeIfPresent(String.self, forKey: .prompt) + prompt: try container.decodeIfPresent(String.self, forKey: .prompt), + options: try container.decodeIfPresent([ProviderApprovalOption].self, forKey: .options) ) case "checkpoint": payload = .checkpoint( @@ -763,10 +764,11 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case let .webSearch(patterns, results): try container.encodeIfPresent(patterns, forKey: .patterns) try container.encodeIfPresent(results, forKey: .results) - case let .approvalRequest(requestID, requestKind, prompt): + case let .approvalRequest(requestID, requestKind, prompt, options): try container.encode(requestID, forKey: .requestId) try container.encode(requestKind, forKey: .requestKind) try container.encodeIfPresent(prompt, forKey: .prompt) + try container.encodeIfPresent(options, forKey: .options) case let .checkpoint(checkpointID, scopeID, files): try container.encode(checkpointID, forKey: .checkpointId) try container.encode(scopeID, forKey: .scopeId) @@ -1515,3 +1517,8 @@ public extension OrchestrationV2ThreadProjection { } } } + +public struct ProviderApprovalOption: Codable, Equatable, Hashable, Sendable { + public let decision: String + public let label: String +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift index a75e4643f..fdc996bfe 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -66,6 +66,15 @@ struct FeatureComposerApprovalPanel: View { Divider().overlay(T3Colors.separator) VStack(spacing: 9) { + if let options = approval.options { + ForEach(options, id: \.decision) { option in + approvalButton(option.label, + background: option.decision == .allowOnce ? T3Colors.accent : Color.clear, + border: T3Colors.border, + foreground: option.decision == .deny ? T3Colors.danger : T3Colors.textPrimary, + action: { onDecision(option.decision) }) + } + } else { HStack(spacing: 7) { approvalButton( "Approve once", @@ -74,7 +83,7 @@ struct FeatureComposerApprovalPanel: View { ) approvalButton( - "Always allow", + "Allow this session", background: Color.clear, border: T3Colors.border, foreground: T3Colors.textPrimary, @@ -94,6 +103,7 @@ struct FeatureComposerApprovalPanel: View { .font(T3Typography.supportingStrong) .buttonStyle(.plain) .frame(maxWidth: .infinity) + } } .padding(.horizontal, 10) .padding(.top, 10) diff --git a/apps/swift-ios/Features/Chat/ThreadActivityInspector.swift b/apps/swift-ios/Features/Chat/ThreadActivityInspector.swift index 7d3405e48..e0212876f 100644 --- a/apps/swift-ios/Features/Chat/ThreadActivityInspector.swift +++ b/apps/swift-ios/Features/Chat/ThreadActivityInspector.swift @@ -384,7 +384,7 @@ public enum ThreadActivityInspector { addBlock(&blocks, "Input", input) addBlock(&blocks, "Output", output) - case let .approvalRequest(_, _, prompt): + case let .approvalRequest(_, _, prompt, _): addBlock(&blocks, "Prompt", prompt, monospaced: false) case let .userInputRequest(_, questions): diff --git a/apps/swift-ios/Features/Chat/ThreadWorkLog.swift b/apps/swift-ios/Features/Chat/ThreadWorkLog.swift index 362fce32c..b55c97187 100644 --- a/apps/swift-ios/Features/Chat/ThreadWorkLog.swift +++ b/apps/swift-ios/Features/Chat/ThreadWorkLog.swift @@ -280,7 +280,7 @@ public enum ThreadWorkLogPresentation { case let .webSearch(patterns, _): guard let patterns, !patterns.isEmpty else { return nil } return patterns.joined(separator: ", ") - case let .approvalRequest(_, _, prompt): + case let .approvalRequest(_, _, prompt, _): return prompt case let .userInputRequest(_, questions): let joined = questions.map(\.question).joined(separator: " · ") diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 8fe12dcd1..e9e5997b7 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -538,6 +538,7 @@ public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Coda public var kind: FeatureApprovalKind public var title: String public var detail: String + public var options: [FeatureApprovalOption]? public init( id: String, @@ -545,7 +546,8 @@ public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Coda threadID: String, kind: FeatureApprovalKind, title: String, - detail: String + detail: String, + options: [FeatureApprovalOption]? = nil ) { self.id = id self.wireID = wireID @@ -553,6 +555,7 @@ public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Coda self.kind = kind self.title = title self.detail = detail + self.options = options } } @@ -1317,7 +1320,25 @@ public struct FeatureSnapshot: Sendable, Equatable, Codable { public enum FeatureApprovalDecision: String, Sendable, Codable { case allowOnce case allowForSession + case allowAlways + case cancel case deny + + public init?(providerDecision: String) { + switch providerDecision { + case "accept": self = .allowOnce + case "acceptForSession": self = .allowForSession + case "acceptAlways": self = .allowAlways + case "decline": self = .deny + case "cancel": self = .cancel + default: return nil + } + } +} + +public struct FeatureApprovalOption: Codable, Equatable, Hashable, Sendable { + public let decision: FeatureApprovalDecision + public let label: String } public enum FeatureEvent: Sendable { diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json b/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json index b751807c3..957dd4719 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json @@ -383,7 +383,21 @@ "type": "approval_request", "requestId": "request-approval", "requestKind": "command", - "prompt": "Run ls?" + "prompt": "Run ls?", + "options": [ + { + "decision": "accept", + "label": "Allow once" + }, + { + "decision": "acceptForSession", + "label": "Allow this session" + }, + { + "decision": "decline", + "label": "Decline" + } + ] }, { "id": "item-checkpoint", @@ -1029,7 +1043,21 @@ "type": "approval_request", "requestId": "request-approval", "requestKind": "command", - "prompt": "Run ls?" + "prompt": "Run ls?", + "options": [ + { + "decision": "accept", + "label": "Allow once" + }, + { + "decision": "acceptForSession", + "label": "Allow this session" + }, + { + "decision": "decline", + "label": "Decline" + } + ] } }, { diff --git a/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift b/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift index b46dfca4b..434751ce8 100644 --- a/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift +++ b/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift @@ -52,6 +52,18 @@ final class OrchestrationV2ContractTests: XCTestCase { XCTAssertNil(dismiss["answers"]) } + func testApprovalOptionsRoundTripAndUnknownDecisionsStayUnavailable() throws { + let projection = try projection() + let item = try XCTUnwrap(projection.turnItems.first { $0.type == "approval_request" }) + guard case let .approvalRequest(_, _, _, options) = item.payload else { return XCTFail("Missing approval") } + XCTAssertEqual(options?.map(\.label), ["Allow once", "Allow this session", "Decline"]) + let decoded = try JSONDecoder().decode(OrchestrationV2TurnItem.self, from: JSONEncoder().encode(item)) + XCTAssertEqual(decoded, item) + XCTAssertEqual(FeatureApprovalDecision(providerDecision: "acceptAlways"), .allowAlways) + XCTAssertEqual(FeatureApprovalDecision(providerDecision: "cancel"), .cancel) + XCTAssertNil(FeatureApprovalDecision(providerDecision: "future-grant")) + } + func testContractGeneratedProjectionDecodes() throws { let projection = try projection() XCTAssertEqual(projection.thread.id, "thread-v2") diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 821ae05de..4601c24a5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3281,6 +3281,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) />
- - - - + {choices.map((option) => ( + + ))} ); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index f56b559ff..a01dd958b 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -12,7 +12,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova }: ComposerPendingApprovalPanelProps) { const approvalSummary = approval.requestKind === "mcp-elicitation" - ? "App access approval requested" + ? `App access approval requested${approval.title ? ` · ${approval.title}` : ""}` : approval.requestKind === "command" ? "Command approval requested" : approval.requestKind === "file-read" diff --git a/docs/user/composer.md b/docs/user/composer.md index 56e39f83e..2078995f6 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -167,3 +167,8 @@ collapse the stack. On web and desktop, the timeline minimap includes **Previous turn** and **Next turn** buttons. They follow the turn at your reading position; keyboard focus on the rail starts there too. iPhone and iPad offer the same navigation in the thread menu. + +Codex app-access requests show the choices supplied by the app, including session or +permanent permission when offered. The label states how long access lasts. Canceling +an app-access request answers that request; **Cancel turn** on ordinary approvals +still stops the turn. diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 4d84e85ba..1dd43e73b 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -185,6 +185,7 @@ describe("V2 client presentation", () => { requestId, requestKind: "command" as const, prompt: "Allow command?", + options: [{ decision: "accept" as const, label: "Allow once" }], }; const projection = { ...v2Projection, @@ -214,6 +215,7 @@ describe("V2 client presentation", () => { requestKind: "command", createdAt: "2026-06-20T01:00:00.000Z", detail: "Allow command?", + options: [{ decision: "accept", label: "Allow once" }], responseCapability: "not_resumable", }, ]); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index ad5e801ef..abf860816 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -1,6 +1,7 @@ import type { OrchestrationV2ThreadProjection, ProviderRequestKind, + ProviderApprovalOption, RuntimeRequestId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -10,6 +11,8 @@ export interface ThreadPendingApproval { readonly requestKind: ProviderRequestKind; readonly createdAt: string; readonly detail?: string; + readonly title?: string; + readonly options?: ReadonlyArray; readonly responseCapability: "live" | "not_resumable"; } @@ -70,6 +73,8 @@ export function derivePendingThreadRequests( requestKind: request.kind, createdAt: DateTime.formatIso(request.createdAt), ...(item?.type === "approval_request" && item.prompt ? { detail: item.prompt } : {}), + ...(item?.type === "approval_request" && item.title ? { title: item.title } : {}), + ...(item?.type === "approval_request" && item.options ? { options: item.options } : {}), responseCapability, }); } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a89a207f2..d0b2320d3 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -37,6 +37,7 @@ import { import { ModelSelection } from "./modelSelection.ts"; import { ProviderApprovalDecision, + ProviderApprovalOption, ProviderInteractionMode, ProviderRequestKind, ProviderUserInputAnswers, @@ -1208,6 +1209,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ requestId: RuntimeRequestId, requestKind: ProviderRequestKind, prompt: Schema.optional(Schema.String), + options: Schema.optional(Schema.Array(ProviderApprovalOption)), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2018,6 +2020,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ requestId: RuntimeRequestId, requestKind: ProviderRequestKind, prompt: Schema.optional(Schema.String), + options: Schema.optional(Schema.Array(ProviderApprovalOption)), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, diff --git a/scripts/generate-swift-contract-fixtures.ts b/scripts/generate-swift-contract-fixtures.ts index d281a7935..9e156a6a8 100644 --- a/scripts/generate-swift-contract-fixtures.ts +++ b/scripts/generate-swift-contract-fixtures.ts @@ -198,6 +198,11 @@ const turnItems: OrchestrationV2TurnItem[] = [ requestId: RuntimeRequestId.make("request-approval"), requestKind: "command", prompt: "Run ls?", + options: [ + { decision: "accept", label: "Allow once" }, + { decision: "acceptForSession", label: "Allow this session" }, + { decision: "decline", label: "Decline" }, + ], }, { ...base("item-checkpoint"), From 85c1d93862c4f43eba6b186214d96d319a8ea08d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:27:00 +0200 Subject: [PATCH 12/93] feat: share environment machine icons across web and Swift --- PATCH.md | 5 + .../src/environment/ServerEnvironment.ts | 4 + .../ServerEnvironmentMachine.test.ts | 245 ++++++++++++++++++ .../environment/ServerEnvironmentMachine.ts | 174 +++++++++++++ apps/swift-ios/App/NativeFeatureClient.swift | 8 +- apps/swift-ios/Core/Models.swift | 4 + apps/swift-ios/Core/ServerConfigModels.swift | 10 + .../Features/Chat/ThreadDetailsSheet.swift | 2 +- .../SettingsEnvironmentIconsView.swift | 91 +++++++ .../Features/Settings/SettingsView.swift | 14 +- .../Features/Shared/FeatureModels.swift | 21 +- .../Features/Workspace/NewThreadView.swift | 2 +- .../Workspace/NewWorkConversationView.swift | 2 +- .../Workspace/ProjectAndArchiveViews.swift | 2 +- .../Features/Workspace/WorkspaceView.swift | 4 +- .../CoreTests/EnvironmentMachineTests.swift | 21 ++ .../Fixtures/environmentMachine.json | 14 + .../web/src/components/BranchToolbar.logic.ts | 3 +- .../BranchToolbarEnvironmentSelector.tsx | 42 +-- apps/web/src/components/ChatView.tsx | 5 + apps/web/src/components/CommandPalette.tsx | 10 +- .../src/components/EnvironmentMachineIcon.tsx | 102 ++++++++ apps/web/src/components/Icons.tsx | 6 + apps/web/src/components/Sidebar.tsx | 23 +- .../src/components/ThreadStatusIndicators.tsx | 8 +- .../pullRequest/PullRequestDetailPanel.tsx | 7 +- .../settings/ConnectionsSettings.tsx | 28 ++ .../settings/EnvironmentIconPicker.tsx | 162 ++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 9 +- docs/user/environment-theme.md | 8 + packages/contracts/src/baseSchemas.ts | 39 +++ packages/contracts/src/environment.test.ts | 48 ++++ packages/contracts/src/environment.ts | 28 +- packages/contracts/src/server.ts | 13 +- packages/contracts/src/settings.ts | 8 +- scripts/generate-swift-contract-fixtures.ts | 24 ++ 36 files changed, 1134 insertions(+), 62 deletions(-) create mode 100644 apps/server/src/environment/ServerEnvironmentMachine.test.ts create mode 100644 apps/server/src/environment/ServerEnvironmentMachine.ts create mode 100644 apps/swift-ios/Features/Settings/SettingsEnvironmentIconsView.swift create mode 100644 apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift create mode 100644 apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json create mode 100644 apps/web/src/components/EnvironmentMachineIcon.tsx create mode 100644 apps/web/src/components/settings/EnvironmentIconPicker.tsx diff --git a/PATCH.md b/PATCH.md index 0a184ed52..23ffca113 100644 --- a/PATCH.md +++ b/PATCH.md @@ -632,3 +632,8 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera - Web minimap previous/next-turn navigation uses the V2 timeline row positions. Keep the fork’s transform/opacity strip animations and skip unchanged scroll attributes; upstream width/background animations would regress scroll performance. + +- Environment machine icons use upstream's best-effort hardware detection and nullable + server setting, with a capability gate for older servers. Web/desktop and Swift show the + resolved glyph in environment selectors and thread context. Swift has a per-environment + settings screen; Automatic deletes the override. V2 thread state and migrations are untouched. diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index f7b65ba5d..51ef0bfed 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,3 +1,4 @@ +import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -139,12 +140,14 @@ export const make = Effect.gen(function* () { launcherManaged: launcher.managed, }); + const machine = yield* detectServerEnvironmentMachineKind(); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, label, platform: { os: platformOs(hostPlatform), arch: platformArch(hostArchitecture), + ...(machine === null ? {} : { machine }), }, serverVersion: packageJson.version, capabilities: { @@ -157,6 +160,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, threadVisitedTracking: true, environmentThemes: true, + environmentIcon: true, threadPinning: true, threadActiveOrderV2: true, threadQuestionActionsV2: true, diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts new file mode 100644 index 000000000..2ae098922 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { vi } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + detectServerEnvironmentMachineKind, + machineKindFromAppleProductName, + machineKindFromDmi, +} from "./ServerEnvironmentMachine.ts"; + +const runMock = vi.fn(); + +const ProcessRunnerTest = Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }), +); + +const processOutput = (stdout: string, code = 0) => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + +const dmiFileSystem = (files: Readonly>) => + FileSystem.layerNoop({ + readFileString: (path) => { + const name = path.slice(path.lastIndexOf("/") + 1); + return name in files + ? Effect.succeed(files[name]!) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + cause: new Error("ENOENT"), + }), + ); + }, + }); + +const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) => + Layer.mergeAll(ProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform)); + +afterEach(() => { + runMock.mockReset(); +}); + +describe("machineKindFromAppleProductName", () => { + it("maps marketing names and model identifiers", () => { + expect(machineKindFromAppleProductName("Mac mini (2024)")).toBe("mac-mini"); + expect(machineKindFromAppleProductName("Macmini8,1")).toBe("mac-mini"); + expect(machineKindFromAppleProductName("Mac Studio (2023)")).toBe("mac-studio"); + expect(machineKindFromAppleProductName("MacBook Pro (14-inch, 2024)")).toBe("laptop"); + expect(machineKindFromAppleProductName("MacBookAir10,1")).toBe("laptop"); + expect(machineKindFromAppleProductName("iMac (24-inch, 2024)")).toBe("desktop"); + expect(machineKindFromAppleProductName("Mac Pro (2023)")).toBe("desktop"); + }); + + it("returns null for Apple silicon model identifiers, which carry no product family", () => { + expect(machineKindFromAppleProductName("Mac16,10")).toBeNull(); + }); +}); + +describe("machineKindFromDmi", () => { + it("prefers virtualization markers over chassis type", () => { + expect( + machineKindFromDmi({ chassisType: "1", sysVendor: "QEMU", productName: "Standard PC" }), + ).toBe("cloud"); + expect( + machineKindFromDmi({ + chassisType: "3", + sysVendor: "Microsoft Corporation", + productName: "Virtual Machine", + }), + ).toBe("cloud"); + expect( + machineKindFromDmi({ chassisType: "1", sysVendor: "Amazon EC2", productName: "t3.large" }), + ).toBe("cloud"); + }); + + it("does not treat Microsoft hardware as a VM", () => { + expect( + machineKindFromDmi({ + chassisType: "9", + sysVendor: "Microsoft Corporation", + productName: "Surface Laptop 5", + }), + ).toBe("laptop"); + }); + + it("maps SMBIOS chassis codes", () => { + expect( + machineKindFromDmi({ chassisType: "3", sysVendor: "GMKtec", productName: "NucBox K8 Plus" }), + ).toBe("desktop"); + expect( + machineKindFromDmi({ chassisType: "10", sysVendor: "LENOVO", productName: "ThinkPad X1" }), + ).toBe("laptop"); + expect( + machineKindFromDmi({ chassisType: "23", sysVendor: "Supermicro", productName: "X11" }), + ).toBe("server"); + expect(machineKindFromDmi({ chassisType: "1", sysVendor: null, productName: null })).toBeNull(); + expect( + machineKindFromDmi({ chassisType: null, sysVendor: null, productName: null }), + ).toBeNull(); + }); + + it("recognizes Apple hardware running Linux", () => { + expect( + machineKindFromDmi({ chassisType: "3", sysVendor: "Apple", productName: "Mac Studio" }), + ).toBe("mac-studio"); + }); +}); + +describe("detectServerEnvironmentMachineKind", () => { + it.effect("reads the IOKit product name on macOS", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + processOutput( + '+-o product \n {\n "product-name" = <"Mac mini (2024)">\n }\n', + ), + ); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBe("mac-mini"); + expect(runMock).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ command: "ioreg", args: ["-rd1", "-n", "product"] }), + ); + }), + ); + + it.effect("falls back to hw.model when IOKit has no product node", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce(processOutput("", 1)); + runMock.mockReturnValueOnce(processOutput("MacBookPro16,1\n")); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBe("laptop"); + expect(runMock).toHaveBeenLastCalledWith( + expect.objectContaining({ command: "sysctl", args: ["-n", "hw.model"] }), + ); + }), + ); + + it.effect("returns null when both macOS probes fail", () => + Effect.gen(function* () { + runMock.mockImplementation((input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: new Error("ENOENT"), + }), + ), + ); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBeNull(); + expect(runMock).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("reads DMI on Linux", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + chassis_type: "3\n", + sys_vendor: "GMKtec\n", + product_name: "NucBox K8 Plus\n", + }), + ), + ), + ); + + expect(result).toBe("desktop"); + expect(runMock).not.toHaveBeenCalled(); + }), + ); + + it.effect("recognizes WSL before its Hyper-V DMI identity", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + osrelease: "5.15.153.1-microsoft-standard-WSL2\n", + chassis_type: "3\n", + sys_vendor: "Microsoft Corporation\n", + product_name: "Virtual Machine\n", + }), + ), + ), + ); + + expect(result).toBe("linux"); + }), + ); + + it.effect("returns null on Linux without DMI (containers, ARM boards)", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("linux", dmiFileSystem({}))), + ); + + expect(result).toBeNull(); + }), + ); + + it.effect("skips detection on other platforms", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("win32")), + ); + + expect(result).toBeNull(); + expect(runMock).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts new file mode 100644 index 000000000..9d11a1ef5 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -0,0 +1,174 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * Best-effort hardware detection for the environment icon. Every probe is + * allowed to fail: a null result means "no signal", and the client draws a + * generic server until the user picks something in Settings → Connections. + */ + +const DMI_ROOT = "/sys/class/dmi/id"; +const KERNEL_RELEASE_PATH = "/proc/sys/kernel/osrelease"; + +// SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape +// rather than a machine (docking stations, blades enclosures, IoT gateways) +// fall through to null on purpose. +const DMI_CHASSIS_KINDS: Readonly> = { + "3": "desktop", // Desktop + "4": "desktop", // Low Profile Desktop + "5": "desktop", // Pizza Box + "6": "desktop", // Mini Tower + "7": "desktop", // Tower + "8": "laptop", // Portable + "9": "laptop", // Laptop + "10": "laptop", // Notebook + "13": "desktop", // All in One + "14": "laptop", // Sub Notebook + "15": "desktop", // Space-saving + "16": "desktop", // Lunch Box + "17": "server", // Main Server Chassis + "18": "server", // Expansion Chassis + "19": "server", // SubChassis + "20": "server", // Bus Expansion Chassis + "21": "server", // Peripheral Chassis + "22": "server", // RAID Chassis + "23": "server", // Rack Mount Chassis + "24": "server", // Sealed-case PC + "28": "server", // Blade + "31": "laptop", // Convertible + "32": "laptop", // Detachable + "35": "desktop", // Mini PC +}; + +// Hypervisors and cloud providers write themselves into the DMI vendor or +// product strings; any hit means the box is a VM, and a VM reads as "cloud" +// regardless of the chassis type the hypervisor fakes. Hyper-V is matched on +// its "Virtual Machine" product, not the "Microsoft Corporation" vendor that +// physical Surface devices share. +const VIRTUALIZATION_MARKERS = [ + "qemu", + "kvm", + "bochs", + "vmware", + "virtualbox", + "innotek", + "xen", + "parallels", + "amazon ec2", + "google compute engine", + "digitalocean", + "hetzner", + "linode", + "vultr", + "scaleway", + "openstack", + "cloud", + "virtual machine", +]; + +function normalize(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +/** Marketing names and Intel-era model identifiers share these prefixes. */ +export function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null { + const normalized = name.trim().toLowerCase().replaceAll(/\s+/g, ""); + if (normalized.startsWith("macmini")) return "mac-mini"; + if (normalized.startsWith("macstudio")) return "mac-studio"; + if (normalized.startsWith("macbook")) return "laptop"; + if (normalized.startsWith("imac") || normalized.startsWith("macpro")) return "desktop"; + return null; +} + +export function machineKindFromDmi(input: { + readonly chassisType: string | null; + readonly sysVendor: string | null; + readonly productName: string | null; +}): EnvironmentMachineKind | null { + const productName = input.productName ?? ""; + const vendorAndProduct = `${input.sysVendor ?? ""} ${productName}`.toLowerCase(); + if (VIRTUALIZATION_MARKERS.some((marker) => vendorAndProduct.includes(marker))) { + return "cloud"; + } + // Apple hardware booting Linux (Asahi) still reports the Apple product name. + const appleKind = machineKindFromAppleProductName(productName); + if (appleKind !== null) { + return appleKind; + } + return input.chassisType === null ? null : (DMI_CHASSIS_KINDS[input.chassisType] ?? null); +} + +const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readFileString(path).pipe( + Effect.map(normalize), + Effect.catch(() => Effect.succeed(null)), + ); +}); + +const runProbe = Effect.fn("runMachineProbe")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner + .run({ + command: input.command, + args: input.args, + timeout: "5 seconds", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)), + Effect.catch(() => Effect.succeed(null)), + ); +}); + +// IOKit's `product` node carries the marketing name ("Mac mini (2024)") on +// Apple silicon; Intel Macs lack it, so `hw.model` ("Macmini8,1") is the +// fallback. Both are single-digit-millisecond calls. +const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* () { + const ioreg = yield* runProbe({ command: "ioreg", args: ["-rd1", "-n", "product"] }); + const productName = ioreg?.match(/"product-name"\s*=\s*<"([^"]+)">/)?.[1] ?? null; + const fromProductName = + productName === null ? null : machineKindFromAppleProductName(productName); + if (fromProductName !== null) { + return fromProductName; + } + const model = yield* runProbe({ command: "sysctl", args: ["-n", "hw.model"] }); + return model === null ? null : machineKindFromAppleProductName(model); +}); + +const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () { + const [kernelRelease, chassisType, sysVendor, productName] = yield* Effect.all([ + readOptionalFile(KERNEL_RELEASE_PATH), + readOptionalFile(`${DMI_ROOT}/chassis_type`), + readOptionalFile(`${DMI_ROOT}/sys_vendor`), + readOptionalFile(`${DMI_ROOT}/product_name`), + ]); + // WSL exposes Microsoft in its kernel release on both WSL 1 and WSL 2. + // Check it before DMI because WSL 2 presents as a Hyper-V VM. + if (kernelRelease?.toLowerCase().includes("microsoft")) { + return "linux"; + } + return machineKindFromDmi({ chassisType, sysVendor, productName }); +}); + +export const detectServerEnvironmentMachineKind = Effect.fn("detectServerEnvironmentMachineKind")( + function* () { + const platform = yield* HostProcessPlatform; + switch (platform) { + case "darwin": + return yield* detectDarwinMachineKind(); + case "linux": + return yield* detectLinuxMachineKind(); + default: + return null; + } + }, +); diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index d6ded05b5..205429127 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -4177,7 +4177,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, isActive: environment.id == activeID, connectionState: environmentConnectionStates[environment.id], connectionDetail: environmentConnectionDetails[environment.id], - supportsPullRequests: environment.descriptor?.capabilities.pullRequests + supportsPullRequests: environment.descriptor?.capabilities.pullRequests, + machineKind: serverConfigsByEnvironmentID[environment.id]?.settings?.environmentIcon.flatMap(EnvironmentMachineKind.init(rawValue:))?.rawValue ?? environment.descriptor?.platform.machine, + supportsEnvironmentIcon: environment.descriptor?.capabilities.environmentIcon ) } @@ -6314,6 +6316,10 @@ extension NativeFeatureClient: FeatureServerSettingsManaging { environmentID: String, patch: ServerSettingsPatchInput ) async throws -> FeatureEnvironmentPreferences { + if patch.environmentIcon != nil, + (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.environmentIcon != true { + throw FeatureCapabilityUnavailable("Environment icons") + } if patch.usagePriceOverrides != nil, (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.usagePriceOverrides != true { throw FeatureCapabilityUnavailable("Custom model pricing") diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index abc168f34..b66d9e75f 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -35,6 +35,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public struct Platform: Codable, Equatable, Sendable { public let os: String public let arch: String + public var machine: String? = nil } public struct Capabilities: Codable, Equatable, Sendable { @@ -54,6 +55,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public struct FileAttachments: Codable, Equatable, Sendable { public let maxUploadBytes: Int } public let attachmentUploads: Bool? public let fileAttachments: FileAttachments? + public let environmentIcon: Bool? public let usagePriceOverrides: Bool? public let pullRequestStackActions: Bool? public let pullRequests: Bool? @@ -70,6 +72,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadPullRequestLinking case threadPullRequestsV2 case attachmentUploads, fileAttachments + case environmentIcon case usagePriceOverrides case pullRequestStackActions case pullRequests @@ -94,6 +97,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) fileAttachments = try container.decodeIfPresent(FileAttachments.self, forKey: .fileAttachments) + environmentIcon = try container.decodeIfPresent(Bool.self, forKey: .environmentIcon) usagePriceOverrides = try container.decodeIfPresent(Bool.self, forKey: .usagePriceOverrides) pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) threadPullRequestLinking = try container.decodeIfPresent( diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift index afefda66a..5d1277db9 100644 --- a/apps/swift-ios/Core/ServerConfigModels.swift +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -261,6 +261,7 @@ public struct UsageModelPriceOverride: Codable, Equatable, Sendable { } public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { + public let environmentIcon: String? public let usagePriceOverrides: [String: UsageModelPriceOverride]? /// The default window matching `DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS` in /// `packages/contracts`, applied when a server predates the setting. @@ -300,6 +301,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public let defaultThemeSetAt: String public init( + environmentIcon: String? = nil, usagePriceOverrides: [String: UsageModelPriceOverride]? = nil, defaultThreadEnvMode: ServerThreadEnvironmentMode = .local, newWorktreesStartFromOrigin: Bool = true, @@ -314,6 +316,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { defaultTheme: String = "", defaultThemeSetAt: String = "" ) { + self.environmentIcon = environmentIcon self.usagePriceOverrides = usagePriceOverrides self.defaultThreadEnvMode = defaultThreadEnvMode self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin @@ -327,6 +330,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { } private enum CodingKeys: String, CodingKey { + case environmentIcon case usagePriceOverrides case defaultThreadEnvMode case newWorktreesStartFromOrigin @@ -352,6 +356,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + environmentIcon = try container.decodeIfPresent(String.self, forKey: .environmentIcon) usagePriceOverrides = try container.decodeIfPresent([String: UsageModelPriceOverride].self, forKey: .usagePriceOverrides) defaultThreadEnvMode = try container.decode( ServerThreadEnvironmentMode.self, @@ -389,6 +394,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(environmentIcon, forKey: .environmentIcon) try container.encodeIfPresent(usagePriceOverrides, forKey: .usagePriceOverrides) try container.encode(defaultThreadEnvMode, forKey: .defaultThreadEnvMode) try container.encode(newWorktreesStartFromOrigin, forKey: .newWorktreesStartFromOrigin) @@ -418,6 +424,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { /// to `json` — as each new server setting reaches this client. public struct ServerSettingsPatchInput: Equatable, Sendable { /// A present nil entry resets one model. Omitted models are unchanged. + public var environmentIcon: String?? public var usagePriceOverrides: [String: UsageModelPriceOverride?]? public var enableAgentBrowserAccess: Bool? /// Claude's auto-compaction threshold, as the string the server validates: @@ -427,11 +434,13 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { public var hiddenModelsByProvider: [String: [String]]? public init( + environmentIcon: String?? = nil, usagePriceOverrides: [String: UsageModelPriceOverride?]? = nil, enableAgentBrowserAccess: Bool? = nil, claudeAutoCompactWindow: String? = nil, hiddenModelsByProvider: [String: [String]]? = nil ) { + self.environmentIcon = environmentIcon self.usagePriceOverrides = usagePriceOverrides self.enableAgentBrowserAccess = enableAgentBrowserAccess self.claudeAutoCompactWindow = claudeAutoCompactWindow @@ -440,6 +449,7 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { public var json: JSONValue { var fields: [String: JSONValue] = [:] + if let environmentIcon { fields["environmentIcon"] = environmentIcon.map(JSONValue.string) ?? .null } if let enableAgentBrowserAccess { fields["enableAgentBrowserAccess"] = .bool(enableAgentBrowserAccess) } diff --git a/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift b/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift index bf4003226..0dfc9d727 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailsSheet.swift @@ -235,7 +235,7 @@ struct ThreadDetailsSheet: View { private var workspaceSection: some View { ThreadDetailsSection(title: "Workspace") { ThreadDetailsRow( - systemImage: "server.rack", + systemImage: environment?.machineSymbol ?? "server.rack", title: environment?.name ?? thread.environmentName ?? "This environment", subtitle: ThreadDetailsConnection.label(connectionState) ) { diff --git a/apps/swift-ios/Features/Settings/SettingsEnvironmentIconsView.swift b/apps/swift-ios/Features/Settings/SettingsEnvironmentIconsView.swift new file mode 100644 index 000000000..7d01e259f --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsEnvironmentIconsView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct SettingsEnvironmentIconsView: View { + @Bindable var model: FeatureRootModel + @State private var environmentID = "" + @State private var selected = "automatic" + @State private var loading = false + @State private var pending = false + @State private var errorMessage: String? + + private var manager: any FeatureServerSettingsManaging { + (model.client as? any FeatureServerSettingsManaging) ?? EmptyFeatureServerSettingsManager.shared + } + private var environment: FeatureEnvironment? { model.snapshot.environments.first { $0.id == environmentID } } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Picker("Environment", selection: $environmentID) { + ForEach(model.snapshot.environments) { environment in + Label(environment.name, systemImage: environment.machineSymbol).tag(environment.id) + } + }.disabled(pending) + Text("The icon is saved on this environment and appears on every connected client. Automatic uses the detected machine type.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + if let errorMessage { SettingsErrorBanner(message: errorMessage) } + if loading { ProgressView().frame(maxWidth: .infinity) } + if environment?.supportsEnvironmentIcon != true { + Text("Connect a current server to change its icon.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + ThreadDetailsSection(title: "Machine icon") { + iconRow(value: "automatic", label: "Automatic", symbol: "sparkles") + ForEach(EnvironmentMachineKind.allCases, id: \.rawValue) { kind in + iconRow(value: kind.rawValue, label: kind.label, symbol: kind.symbol) + } + } + .disabled(loading || pending || environment?.supportsEnvironmentIcon != true) + if pending { ProgressView("Saving icon…").font(T3Typography.supporting) } + }.padding(18) + } + .background(T3Colors.background) + .navigationTitle("Environment icons") + .navigationBarTitleDisplayMode(.inline) + .onAppear { if environmentID.isEmpty { environmentID = model.snapshot.environments.first(where: \.isActive)?.id ?? model.snapshot.environments.first?.id ?? "" } } + .task(id: environmentID) { await load() } + } + + private func iconRow(value: String, label: String, symbol: String) -> some View { + Button { Task { await save(value) } } label: { + HStack(spacing: 12) { + Image(systemName: symbol).frame(width: 24) + Text(label) + Spacer() + if selected == value { Image(systemName: "checkmark").foregroundStyle(T3Colors.accent) } + } + .font(T3Typography.supportingStrong).foregroundStyle(T3Colors.textPrimary) + .padding(14).frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityAddTraits(selected == value ? .isSelected : []) + } + + private func load() async { + let requestedID = environmentID + guard !requestedID.isEmpty else { return } + loading = true + errorMessage = nil + do { + let config = try await manager.providerModelConfiguration(environmentID: requestedID) + guard !Task.isCancelled, environmentID == requestedID else { return } + selected = config.settings?.environmentIcon.flatMap(EnvironmentMachineKind.init(rawValue:))?.rawValue ?? "automatic" + } catch { + guard !Task.isCancelled, environmentID == requestedID else { return } + errorMessage = error.localizedDescription + } + loading = false + } + + private func save(_ value: String) async { + guard !pending, value != selected else { return } + let requestedID = environmentID + pending = true + errorMessage = nil + do { + _ = try await manager.updateServerSettings(environmentID: requestedID, + patch: ServerSettingsPatchInput(environmentIcon: .some(value == "automatic" ? nil : value))) + if environmentID == requestedID { selected = value } + } catch { errorMessage = error.localizedDescription } + pending = false + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index 5d9aef13f..ab1bc37f2 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -8,6 +8,7 @@ public struct SettingsView: View { @State private var showingDisconnect = false @State private var showingAddEnvironment = false @State private var showingDevices = false + @State private var showingEnvironmentIcons = false @State private var showingT3Connect = false @State private var showingIntegrations = false @State private var showingAgents = false @@ -116,6 +117,12 @@ public struct SettingsView: View { } ) } + .sheet(isPresented: $showingEnvironmentIcons) { + NavigationStack { + SettingsEnvironmentIconsView(model: model) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingEnvironmentIcons = false } } } + } + } .sheet(isPresented: $showingDevices) { NavigationStack { DevicesView(manager: deviceManager) @@ -313,6 +320,11 @@ public struct SettingsView: View { settingsDivider + Button { showingEnvironmentIcons = true } label: { + SettingsNavigationRow(title: "Environment icons", systemImage: "server.rack") + }.buttonStyle(.plain) + settingsDivider + Button { showingDevices = true } label: { @@ -572,7 +584,7 @@ public struct SettingsView: View { let activeIsConnected = environment.isActive && model.snapshot.connection.state == .connected SettingsRowIcon( - systemName: activeIsConnected ? "checkmark.circle.fill" : "desktopcomputer", + systemName: environment.machineSymbol, color: activeIsConnected ? T3Colors.success : T3Colors.textTertiary ) diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index e9e5997b7..1235cc1c9 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -39,6 +39,9 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C /// older cached descriptor that never reported the capability, which reads /// as unsupported: the sheet has nothing to ask such a server for. public var supportsPullRequests: Bool? + public var machineKind: String? + public var supportsEnvironmentIcon: Bool? + public var machineSymbol: String { EnvironmentMachineKind(rawValue: machineKind ?? "")?.symbol ?? "server.rack" } public init( id: String, @@ -47,7 +50,9 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C isActive: Bool = false, connectionState: FeatureConnection.State? = nil, connectionDetail: String? = nil, - supportsPullRequests: Bool? = nil + supportsPullRequests: Bool? = nil, + machineKind: String? = nil, + supportsEnvironmentIcon: Bool? = nil ) { self.id = id self.name = name @@ -56,6 +61,8 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C self.connectionState = connectionState self.connectionDetail = connectionDetail self.supportsPullRequests = supportsPullRequests + self.machineKind = machineKind + self.supportsEnvironmentIcon = supportsEnvironmentIcon } } @@ -1350,3 +1357,15 @@ public enum FeatureEvent: Sendable { case detailDelta(FeatureThreadDetail, FeatureDetailDelta) case failure(String) } + +public enum EnvironmentMachineKind: String, CaseIterable, Sendable { + case server, cloud, linux, desktop, laptop + case macMini = "mac-mini" + case macStudio = "mac-studio" + public var label: String { + switch self { case .server: "Server"; case .cloud: "Cloud VM"; case .linux: "Linux/WSL"; case .desktop: "Desktop"; case .laptop: "Laptop"; case .macMini: "Mini PC"; case .macStudio: "Workstation" } + } + public var symbol: String { + switch self { case .server, .linux: "server.rack"; case .cloud: "cloud"; case .desktop: "desktopcomputer"; case .laptop: "laptopcomputer"; case .macMini: "macmini"; case .macStudio: "macstudio" } + } +} diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index c66838c56..73760ef17 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -244,7 +244,7 @@ public struct NewThreadView: View { } } label: { HStack(spacing: 6) { - Image(systemName: "server.rack") + Image(systemName: model.snapshot.environments.first { $0.id == selectedProject?.environmentID }?.machineSymbol ?? "server.rack") .font(.system(size: 11, weight: .medium)) Text("on \(environmentName)") if creationEnvironments.count > 1 { diff --git a/apps/swift-ios/Features/Workspace/NewWorkConversationView.swift b/apps/swift-ios/Features/Workspace/NewWorkConversationView.swift index def54d7ea..cee138368 100644 --- a/apps/swift-ios/Features/Workspace/NewWorkConversationView.swift +++ b/apps/swift-ios/Features/Workspace/NewWorkConversationView.swift @@ -144,7 +144,7 @@ public struct NewWorkConversationView: View { } } label: { HStack(spacing: 6) { - Image(systemName: "server.rack") + Image(systemName: model.snapshot.environments.first { $0.id == activeTarget?.environmentID }?.machineSymbol ?? "server.rack") .font(.system(size: 11, weight: .medium)) Text("on \(activeTarget.map { environmentName($0.environmentID) } ?? "…")") if availableTargets.count > 1 { diff --git a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift index 4f80c3af2..425b8b455 100644 --- a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift +++ b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift @@ -211,7 +211,7 @@ public struct AddProjectView: View { } } label: { HStack(spacing: 10) { - Image(systemName: "server.rack") + Image(systemName: environment.machineSymbol) VStack(alignment: .leading, spacing: 2) { Text(environment.name) .font(T3Typography.control) diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 09cb8c07a..f8eb4d9aa 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -1284,6 +1284,7 @@ struct HomeThreadRowContext: Equatable { /// a cache-key hint so icon changes reach existing rows. let projectFaviconPath: String? let environmentLabel: String? + var machineSymbol: String = "server.rack" let providerID: String let providerDriver: String let providerName: String @@ -1358,6 +1359,7 @@ struct HomeThreadRowContext: Equatable { projectWorkspaceRoot: project?.path, projectFaviconPath: project?.faviconPath, environmentLabel: environmentLabel?.isEmpty == false ? environmentLabel : nil, + machineSymbol: environment?.machineSymbol ?? "server.rack", providerID: providerID, providerDriver: providerDriver, providerName: providerName, @@ -1723,7 +1725,7 @@ struct FeatureThreadRow: View, Equatable { case .disconnected: "wifi.slash" case .connected, nil: - "server.rack" + context.machineSymbol } } diff --git a/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift b/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift new file mode 100644 index 000000000..8f276a745 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import T3Code + +struct EnvironmentMachineTests { + @Test func decodesServerMachineFixture() throws { + let fixture = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Fixtures/environmentMachine.json") + let descriptor = try JSONDecoder().decode(EnvironmentDescriptor.self, from: Data(contentsOf: fixture)) + #expect(descriptor.platform.machine == "mac-studio") + #expect(descriptor.capabilities.environmentIcon == true) + #expect(EnvironmentMachineKind(rawValue: descriptor.platform.machine!)?.symbol == "macstudio") + } + + @Test func automaticClearsOnlyTheOverrideAndOmissionDoesNotWrite() { + #expect(ServerSettingsPatchInput(environmentIcon: .some(nil)).json == .object(["environmentIcon": .null])) + #expect(ServerSettingsPatchInput(environmentIcon: .some("laptop")).json == .object(["environmentIcon": .string("laptop")])) + #expect(ServerSettingsPatchInput().json == .object([:])) + #expect(EnvironmentMachineKind(rawValue: "future-device") == nil) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json b/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json new file mode 100644 index 000000000..f53ec04dc --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json @@ -0,0 +1,14 @@ +{ + "environmentId": "machine-environment", + "label": "Studio", + "platform": { + "os": "darwin", + "arch": "arm64", + "machine": "mac-studio" + }, + "serverVersion": "0.0.38", + "capabilities": { + "repositoryIdentity": true, + "environmentIcon": true + } +} diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 092f95e3d..c22e4477c 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentMachineKind, EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -11,6 +11,7 @@ export interface EnvironmentOption { projectId: ProjectId; label: string; isPrimary: boolean; + machineKind?: EnvironmentMachineKind; } export const EnvMode = Schema.Literals(["local", "worktree"]); diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 1d9cd5918..3186d471c 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { CloudIcon, MonitorIcon } from "lucide-react"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; @@ -62,19 +62,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} data-composer-context-control > - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + ( - {env.isPrimary ? ( - - ) : ( - - )} + {env.label} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 594f6413a..da336fc28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { resolveEnvironmentMachineKind, type EnvironmentMachineKind } from "@t3tools/contracts"; import { ComposerTasksContent } from "./chat/ComposerTasksBadge"; import { appendCodexArtifactTemplateUsePrompt, @@ -2127,6 +2128,7 @@ function ChatViewContent(props: ChatViewProps) { projectId: ProjectId; label: string; isPrimary: boolean; + machineKind: EnvironmentMachineKind; }> = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; @@ -2138,6 +2140,9 @@ function ChatViewContent(props: ChatViewProps) { projectId: p.id, label, isPrimary, + machineKind: resolveEnvironmentMachineKind( + environmentById.get(p.environmentId)?.serverConfig ?? null, + ), }); } // Sort: primary first, then alphabetical diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1bed1d66c..16dbaa04b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,4 +1,5 @@ -"use client"; +import { ConnectedEnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +("use client"); import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -46,7 +47,6 @@ import { MessageSquareIcon, MessagesSquareIcon, PaletteIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -1092,7 +1092,11 @@ function OpenCommandPaletteDialog(props: { {location.kind === "remote" ? ( - + ) : null} {location.label} diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx new file mode 100644 index 000000000..a7cb95849 --- /dev/null +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -0,0 +1,102 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { serverEnvironment } from "../state/server"; +import { + resolveEnvironmentMachineKind, + type EnvironmentId, + type EnvironmentMachineKind, +} from "@t3tools/contracts"; +import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react"; +import type { FunctionComponent, SVGProps } from "react"; +import { LinuxIcon } from "./Icons"; + +// Lucide has no Apple desktops, so these two are drawn to its grammar (24 +// unit grid, 2 unit stroke, round joins) and share its prop surface so callers +// can swap freely. +function LucideLike(props: SVGProps) { + return ( + + ); +} + +/** A Mac mini: squat rounded slab with a front-edge LED. */ +function MacMiniIcon(props: SVGProps) { + return ( + + + + + ); +} + +/** A Mac Studio: the same slab twice as tall, ports along the front foot. */ +function MacStudioIcon(props: SVGProps) { + return ( + + + + + ); +} + +const ICON_BY_KIND: Record> = { + server: ServerIcon, + cloud: CloudIcon, + linux: LinuxIcon, + desktop: MonitorIcon, + laptop: LaptopIcon, + "mac-mini": MacMiniIcon, + "mac-studio": MacStudioIcon, +}; + +export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { + server: "Server", + cloud: "Cloud VM", + linux: "Linux/WSL", + desktop: "Desktop", + laptop: "Laptop", + "mac-mini": "Mini PC", + "mac-studio": "Workstation", +}; + +export function environmentMachineIcon( + kind: EnvironmentMachineKind, +): FunctionComponent { + return ICON_BY_KIND[kind]; +} + +export function EnvironmentMachineIcon({ + kind, + ...props +}: LucideProps & { readonly kind: EnvironmentMachineKind }) { + const Icon = ICON_BY_KIND[kind]; + return ; +} + +const environmentMachineAtom = Atom.family((environmentId: EnvironmentId | null) => + Atom.make((get) => + resolveEnvironmentMachineKind( + environmentId === null ? null : get(serverEnvironment.configValueAtom(environmentId)), + ), + ), +); + +/** Subscribe only to the resolved glyph, so quota/config updates do not repaint thread rows. */ +export function ConnectedEnvironmentMachineIcon({ + environmentId, + ...props +}: LucideProps & { environmentId: EnvironmentId | null }) { + const kind = useAtomValue(environmentMachineAtom(environmentId)); + return ; +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index d3866a82b..e99f31b0b 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -759,3 +759,9 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); + +export const LinuxIcon: Icon = ({ className, ...props }) => ( + + + +); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 73bdba9de..81faba897 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { ConnectedEnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useReducer, type SyntheticEvent } from "react"; import { filterSidebarProjectScopeItems, @@ -69,7 +70,6 @@ import { PinOffIcon, PlusIcon, SearchIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -449,7 +449,10 @@ function SidebarThreadTooltip({ ) : null} {environmentLabel ? (
- +
{environmentLabel}
) : null} @@ -1752,7 +1755,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { > {isRemote ? ( - + ) : null} {props.isPinned ? ( @@ -4381,7 +4388,10 @@ export default function Sidebar() { /> } > - + {(workEnvironmentScopeId !== null ? environmentLabelById.get(workEnvironmentScopeId) @@ -4403,7 +4413,10 @@ export default function Sidebar() { closeOnClick className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" > - + {environment.label} ))} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 9f71325c0..0677b2ebf 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,3 +1,4 @@ +import { ConnectedEnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { scopeProjectRef, scopedThreadKey, @@ -6,7 +7,7 @@ import { import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; @@ -646,7 +647,10 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {threadEnvironmentLabel} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 977beaf25..f7c6b0a57 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,4 @@ +import { ConnectedEnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { PullRequestStackControl } from "./PullRequestStackControl"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -35,7 +36,6 @@ import { PanelRightIcon, PencilIcon, RefreshCwIcon, - ServerIcon, TriangleAlertIcon, } from "lucide-react"; import { @@ -257,7 +257,10 @@ function ActOnEnvironmentPicker({ {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} - + {environment.label} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index fbcbbc429..376c34869 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,3 +1,6 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { @@ -1458,6 +1461,10 @@ function SavedBackendListRow({ : null } /> +

{environment.label}

@@ -1465,6 +1472,15 @@ function SavedBackendListRow({ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

) : null} + {isConnected && ( +
+ +
+ )} {serverUpdateState.status !== "idle" ? (
@@ -3124,6 +3140,18 @@ export function ConnectionsSettings() { } /> ) : null} + {primaryEnvironmentId && ( + + } + /> + )} {desktopBridge ? ( <> {renderNetworkAccessRow()} diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx new file mode 100644 index 000000000..99bb1b944 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -0,0 +1,162 @@ +import { + ENVIRONMENT_MACHINE_KINDS, + isEnvironmentMachineKind, + resolveEnvironmentMachineKind, + type EnvironmentId, + type ServerConfig, +} from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironmentSessionState } from "../../state/session"; +import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, +} from "./ProviderSettingsPanel.logic"; + +const AUTOMATIC_VALUE = "automatic"; + +/** + * Why the picker is inert, in the order the user can do something about it. + * Null means it can be changed. + */ +export function resolveEnvironmentIconPickerLock(input: { + readonly serverConfig: ServerConfig | null; + readonly operateAccess: "granted" | "denied" | "pending"; +}): string | null { + if (input.serverConfig === null) { + return "Connect to this environment to change its icon."; + } + if (input.serverConfig.environment.capabilities.environmentIcon !== true) { + return "This environment's server is too old to keep an icon. Update it to choose one."; + } + if (input.operateAccess === "pending") return "Checking this session’s permissions."; + if (input.operateAccess === "denied") { + return "Your session on this environment cannot change its settings."; + } + return null; +} + +// Same split the provider settings use: the desktop app owns its primary +// server outright, a browser session on the primary checks its cookie +// session's scopes, and a remote checks the scopes its own server reports. +function useEnvironmentOperateAccess(environmentId: EnvironmentId) { + const isPrimary = usePrimaryEnvironmentId() === environmentId; + const primarySession = usePrimarySessionState(); + const remoteSession = useEnvironmentSessionState(environmentId); + if (isPrimary) { + return isElectron + ? "granted" + : resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: primarySession.data, + isPending: primarySession.isPending, + hasError: primarySession.error !== null, + }); + } + return resolveRemoteOperateAccess({ + session: remoteSession.data, + isPending: remoteSession.isPending, + hasError: remoteSession.hasError, + }); +} + +/** + * Picks the machine glyph an environment wears everywhere it is listed. + * "Automatic" clears the override so the server's own detection shows + * through; the label says what that currently resolves to so the user can + * tell whether detection got it right before overriding. The control stays + * visible while locked so the current icon still reads, the same way + * server-scoped rows go inert instead of disappearing. + */ +export function EnvironmentIconPicker({ + environmentId, + serverConfig, + size = "sm", +}: { + readonly environmentId: EnvironmentId; + readonly serverConfig: ServerConfig | null; + readonly size?: "xs" | "sm"; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const operateAccess = useEnvironmentOperateAccess(environmentId); + const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); + const override = serverConfig?.settings.environmentIcon ?? null; + const detected = serverConfig?.environment.platform.machine ?? null; + const resolved = resolveEnvironmentMachineKind(serverConfig); + const value = override ?? AUTOMATIC_VALUE; + const automaticLabel = + detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; + + const handleValueChange = useCallback( + (next: string | null) => { + if (next === null) return; + if (next === AUTOMATIC_VALUE) { + updateSettings({ environmentIcon: null }); + } else if (isEnvironmentMachineKind(next)) { + updateSettings({ environmentIcon: next }); + } + }, + [updateSettings], + ); + + const select = ( + + ); + + if (lock === null) { + return select; + } + return ( + + + } + > + {select} + + + {lock} + + + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 68daae130..2b98706df 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,5 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestHostOf, ThreadId } from "@t3tools/contracts"; import type { @@ -19,8 +21,6 @@ import { ChevronDownIcon, ClockIcon, EyeIcon, - MonitorIcon, - ServerIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon, @@ -1698,14 +1698,13 @@ function PullRequestsRouteView() { }; }), ]; - // The same shape the host pills take, so the two groups read as one control. A local - // connection wears the screen it is on; every other server wears a server. + // Keep the same detected or selected machine identity as other environment controls. const serverMenuOptions: ReadonlyArray> = [ { value: "", label: "All servers", Icon: LayersIcon }, ...capableEnvironments.map((environment) => ({ value: environment.environmentId, label: environment.label, - Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon, + Icon: environmentMachineIcon(resolveEnvironmentMachineKind(environment.serverConfig)), })), ]; const sortMenu = ( diff --git a/docs/user/environment-theme.md b/docs/user/environment-theme.md index adddb48b8..c4e12ab32 100644 --- a/docs/user/environment-theme.md +++ b/docs/user/environment-theme.md @@ -91,3 +91,11 @@ A theme with no usable colors is not listed on that client. Write each file atomically — write a temporary file beside it and rename — so T3 Code never reads a half-written theme. An unreadable or invalid file is simply not published; the machine's other themes are unaffected. + +## Environment icons + +Each server detects its machine type when it starts. Its icon appears beside environment +names in thread lists, new-thread controls, and pull-request controls. In Settings → Connections, +choose a different icon or select Automatic to restore detection. On iOS, open Settings → +Environment icons and choose the server first. The choice is saved on that server and follows +it across connected clients. Changing it requires permission to change the server's settings. diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 318a494f1..3e647b282 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -43,6 +43,45 @@ export type IsoDateTime = typeof IsoDateTime.Type; * rejecting the payload would take down the connection over data the client * couldn't act on anyway. Encoding is the plain array encoding. */ +/** + * Same idea for one optional value whose literal set grows over time: a + * member this build does not know decodes as absent rather than failing the + * enclosing struct. Encoding is the plain encoding. + */ +export const ForwardCompatibleOptional = (value: Value) => { + const decodeValue = Schema.decodeUnknownOption(value as never); + return Schema.optionalKey( + Schema.Unknown.pipe( + Schema.decodeTo( + Schema.UndefinedOr(value), + SchemaTransformation.transform({ + decode: (raw) => + Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : undefined, + encode: (raw) => raw, + }), + ), + ), + ); +}; + +/** + * The nullable form, for a persisted setting whose literal set grows over + * time: a member this build does not know (or a missing key) decodes as null + * rather than failing the enclosing struct. Encoding is the plain encoding. + */ +export const ForwardCompatibleNullable = (value: Value) => { + const decodeValue = Schema.decodeUnknownOption(value as never); + return Schema.Unknown.pipe( + Schema.decodeTo( + Schema.NullOr(value), + SchemaTransformation.transform({ + decode: (raw) => (Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : null), + encode: (raw) => raw, + }), + ), + ); +}; + export const ForwardCompatibleArray = (element: Element) => { const decodeElement = Schema.decodeUnknownOption(element as never); return Schema.Array(Schema.Unknown).pipe( diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 55633835b..4aae65c9f 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -1,8 +1,12 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; +import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { resolveEnvironmentMachineKind } from "./server.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +const decodeSettings = Schema.decodeUnknownSync(ServerSettings); +const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch); const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); const descriptor = { @@ -52,3 +56,47 @@ describe("ExecutionEnvironmentDescriptor", () => { ).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); }); }); + +describe("environment machine identity", () => { + it("keeps known detection and ignores future kinds", () => { + expect( + decodeDescriptor({ ...descriptor, platform: { ...descriptor.platform, machine: "mac-mini" } }) + .platform.machine, + ).toBe("mac-mini"); + expect( + decodeDescriptor({ ...descriptor, platform: { ...descriptor.platform, machine: "quantum" } }) + .platform.machine, + ).toBeUndefined(); + expect(decodeDescriptor(descriptor).capabilities.environmentIcon).toBeUndefined(); + }); + it("resolves overrides before detection, with safe old-server fallbacks", () => { + const environment = decodeDescriptor({ + ...descriptor, + platform: { ...descriptor.platform, machine: "laptop" }, + }); + expect(resolveEnvironmentMachineKind({ environment, settings: DEFAULT_SERVER_SETTINGS })).toBe( + "laptop", + ); + expect( + resolveEnvironmentMachineKind({ + environment, + settings: { ...DEFAULT_SERVER_SETTINGS, environmentIcon: "cloud" }, + }), + ).toBe("cloud"); + expect( + resolveEnvironmentMachineKind({ + environment: decodeDescriptor(descriptor), + settings: DEFAULT_SERVER_SETTINGS, + }), + ).toBe("server"); + expect(resolveEnvironmentMachineKind(null)).toBe("server"); + }); + it("drops a future persisted override but rejects unsupported writes", () => { + expect(decodeSettings({ environmentIcon: "future" }).environmentIcon).toBeNull(); + expect(() => decodePatch({ environmentIcon: "future" })).toThrow(); + expect(decodePatch({ environmentIcon: null })).toEqual({ + environmentIcon: null, + }); + expect(decodePatch({})).not.toHaveProperty("environmentIcon"); + }); +}); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4b71c957c..822c2c4d4 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,7 +1,13 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + ForwardCompatibleOptional, + EnvironmentId, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", @@ -14,9 +20,28 @@ export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatform export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; +/** + * The curated set of machine shapes and OS identities an environment can wear as its icon. + * Servers detect one from the hardware they run on (`platform.machine`), and + * the `environmentIcon` server setting lets a user pick one instead. + */ +export const ENVIRONMENT_MACHINE_KINDS = [ + "server", + "cloud", + "linux", + "desktop", + "laptop", + "mac-mini", + "mac-studio", +] as const; +export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS); +export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type; +export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind); + export const ExecutionEnvironmentPlatform = Schema.Struct({ os: ExecutionEnvironmentPlatformOs, arch: ExecutionEnvironmentPlatformArch, + machine: ForwardCompatibleOptional(EnvironmentMachineKind), }); /** @@ -76,6 +101,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ client reconnecting to one must drop published themes rather than keep showing a set nothing will ever update. */ environmentThemes: Schema.optionalKey(Schema.Boolean), + environmentIcon: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index dfd1bda3e..e0f8efceb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,7 +1,11 @@ import { ServerProviderUsageLimits } from "./providerUsageLimits.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; +import { + type EnvironmentMachineKind, + ExecutionEnvironmentDescriptor, + ServerSelfUpdateMethod, +} from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { ForwardCompatibleArray, @@ -787,3 +791,10 @@ export class ServerSelfUpdateError extends Schema.TaggedErrorClass | null, +): EnvironmentMachineKind { + return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server"; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1aa697520..081ceea38 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,8 +2,8 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; -import { ThreadEnvMode } from "./environment.ts"; +import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, @@ -1073,6 +1073,9 @@ export const ServerSettings = Schema.Struct({ defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( Schema.withDecodingDefault(Effect.succeed("")), ), + environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), @@ -1327,6 +1330,7 @@ export const ServerSettingsPatch = Schema.Struct({ automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), + environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeRetention: Schema.optionalKey( diff --git a/scripts/generate-swift-contract-fixtures.ts b/scripts/generate-swift-contract-fixtures.ts index 9e156a6a8..50be53e47 100644 --- a/scripts/generate-swift-contract-fixtures.ts +++ b/scripts/generate-swift-contract-fixtures.ts @@ -15,6 +15,8 @@ * node scripts/generate-swift-contract-fixtures.ts --check # CI: fail if stale */ import { + ExecutionEnvironmentDescriptor, + EnvironmentId, ServerProviderUsageLimits, PullRequestStack, PullRequestLabelCandidateList, @@ -493,3 +495,25 @@ if (process.argv.includes("--check")) { process.exit(1); } } else NodeFS.writeFileSync(pricePath, priceSerialized); + +const machinePath = NodePath.join(NodePath.dirname(outputPath), "environmentMachine.json"); +const machineSerialized = `${JSON.stringify( + Schema.encodeSync(ExecutionEnvironmentDescriptor)({ + environmentId: EnvironmentId.make("machine-environment"), + label: "Studio", + platform: { os: "darwin", arch: "arm64", machine: "mac-studio" }, + serverVersion: "0.0.38", + capabilities: { repositoryIdentity: true, environmentIcon: true }, + }), + null, + 2, +)}\n`; +if (process.argv.includes("--check")) { + if ( + !NodeFS.existsSync(machinePath) || + NodeFS.readFileSync(machinePath, "utf8") !== machineSerialized + ) { + console.error("[swift-fixtures] environmentMachine.json is stale; regenerate fixtures."); + process.exit(1); + } +} else NodeFS.writeFileSync(machinePath, machineSerialized); From 56f30b2abe14a78d4118e53a106bd692643940c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:36:37 +0200 Subject: [PATCH 13/93] fix: preserve terminal replay and pause hidden rendering --- PATCH.md | 7 + apps/mobile/src/state/use-terminal-session.ts | 11 +- apps/swift-ios/App/NativeFeatureClient.swift | 9 +- .../Features/Shared/FeatureToolModels.swift | 11 + .../Terminal/FeatureTerminalView.swift | 1 + .../Terminal/TerminalOutputReplay.swift | 25 ++ .../Terminal/TerminalSurfaceView.swift | 38 ++- .../CoreTests/TerminalOutputReplayTests.swift | 32 ++ .../src/components/ThreadTerminalDrawer.tsx | 113 ++++--- apps/web/src/terminal/ghostty/core.test.ts | 236 ++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 55 ++- .../ghostty/surface.visibility.test.ts | 218 ++++++++++++ docs/user/terminal.md | 6 + packages/client-runtime/src/state/terminal.ts | 8 +- .../src/state/terminalOutput.ts | 320 ++++++++++++++++++ .../src/state/terminalSession.test.ts | 313 ++++++++++++++++- .../src/state/terminalSession.ts | 87 +++-- 17 files changed, 1382 insertions(+), 108 deletions(-) create mode 100644 apps/swift-ios/Features/Terminal/TerminalOutputReplay.swift create mode 100644 apps/swift-ios/Tests/CoreTests/TerminalOutputReplayTests.swift create mode 100644 apps/web/src/terminal/ghostty/surface.visibility.test.ts create mode 100644 packages/client-runtime/src/state/terminalOutput.ts diff --git a/PATCH.md b/PATCH.md index 23ffca113..aab14054d 100644 --- a/PATCH.md +++ b/PATCH.md @@ -637,3 +637,10 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera server setting, with a capability gate for older servers. Web/desktop and Swift show the resolved glyph in environment selectors and thread context. Swift has a per-environment settings screen; Automatic deletes the override. V2 thread state and migrations are untouched. + +- Terminal replay uses upstream's bounded chunk buffer and per-attach cursors in + client-runtime/web; Swift carries local byte cursors through its existing capped + buffer. Repeated output and history rollover append without resetting live VT state. + Hidden web surfaces continue parsing/replying but stop canvas work and cursor timers. + Preserve the fork's narrow-pane font fitting, middle-click selection, and V2 terminal + routing. Expo materializes text only at its existing native-surface boundary. diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 328557a20..1bc269ee4 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -1,5 +1,6 @@ import { combineTerminalSessionState, + terminalOutputText, EMPTY_TERMINAL_BUFFER_STATE, EMPTY_TERMINAL_SESSION_STATE, type KnownTerminalSession, @@ -14,7 +15,7 @@ import { terminalEnvironment } from "./terminal"; export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null; -}): TerminalSessionState { +}): TerminalSessionState & { readonly buffer: string } { const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null ? terminalEnvironment.attach({ @@ -34,7 +35,7 @@ export function useAttachedTerminalSession(input: { return useMemo(() => { if (input.environmentId === null || input.terminal === null) { - return EMPTY_TERMINAL_SESSION_STATE; + return { ...EMPTY_TERMINAL_SESSION_STATE, buffer: "" }; } const summary = metadata.data?.find( @@ -42,7 +43,11 @@ export function useAttachedTerminalSession(input: { terminal.threadId === input.terminal?.threadId && terminal.terminalId === input.terminal?.terminalId, ) ?? null; - const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + const session = combineTerminalSessionState( + summary, + attach.data ?? EMPTY_TERMINAL_BUFFER_STATE, + ); + const state = { ...session, buffer: terminalOutputText(session.output) }; return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); } diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 205429127..d4d9132fb 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -2753,6 +2753,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, if let coreSnapshot = event.snapshot { var snapshot = NativeWorkspaceMapper.terminal(coreSnapshot) snapshot.threadID = threadID + snapshot.outputCursor = FeatureTerminalOutputCursor(byteOffset: snapshot.buffer.utf8.count) snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) terminalSnapshots[key] = snapshot return snapshot @@ -2762,7 +2763,11 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ?? FeatureTerminalSnapshot(threadID: threadID, terminalID: terminalID) switch event.type { case "output": - snapshot.buffer.append(event.data ?? "") + let data = event.data ?? "" + var cursor = snapshot.outputCursor ?? FeatureTerminalOutputCursor(byteOffset: snapshot.buffer.utf8.count) + cursor.byteOffset += data.utf8.count + snapshot.outputCursor = cursor + snapshot.buffer.append(data) snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) case "exited": snapshot.state = .exited @@ -2774,6 +2779,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snapshot.error = event.message case "cleared": snapshot.buffer = "" + snapshot.outputCursor = FeatureTerminalOutputCursor() case "activity": snapshot.title = event.label ?? snapshot.title snapshot.hasRunningSubprocess = event.hasRunningSubprocess @@ -2794,6 +2800,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snapshot.threadID = threadID if let cached = terminalSnapshots[key] { snapshot.buffer = cached.buffer + snapshot.outputCursor = cached.outputCursor snapshot.error = cached.error } terminalSnapshots[key] = snapshot diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift index 817ce7508..7b05317e8 100644 --- a/apps/swift-ios/Features/Shared/FeatureToolModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -1027,7 +1027,18 @@ public enum FeatureTerminalState: String, Sendable, Codable { case failed } +/// Local renderer position; never sent to the server. A new attach or clear creates a generation. +public struct FeatureTerminalOutputCursor: Sendable, Equatable, Codable { + public var generation: UUID + public var byteOffset: Int + public init(generation: UUID = UUID(), byteOffset: Int = 0) { + self.generation = generation + self.byteOffset = byteOffset + } +} + public struct FeatureTerminalSnapshot: Sendable, Equatable, Codable { + public var outputCursor: FeatureTerminalOutputCursor? = nil public var threadID: String public var terminalID: String public var state: FeatureTerminalState diff --git a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift index d8d240b5b..d44fdbca1 100644 --- a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -101,6 +101,7 @@ public struct FeatureTerminalView: View { GhosttyTerminalSurface( terminalKey: "\(threadID):\(activeTerminalID)", buffer: terminal?.buffer ?? "", + outputCursor: terminal?.outputCursor, fontSize: CGFloat(fontSize), isRunning: isRunning, focusRequest: focusRequest, diff --git a/apps/swift-ios/Features/Terminal/TerminalOutputReplay.swift b/apps/swift-ios/Features/Terminal/TerminalOutputReplay.swift new file mode 100644 index 000000000..de214e290 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/TerminalOutputReplay.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Append by stream position even when retained history rolls past the byte cap. +/// A renderer that missed the retained tail, or a new attach/clear, replays once. +enum TerminalOutputReplay { + enum Update: Equatable { + case none + case append(Data) + case reset + } + + static func update(buffer: String, cursor: FeatureTerminalOutputCursor?, + previousBuffer: String, previousCursor: FeatureTerminalOutputCursor?) -> Update { + guard let cursor else { + if buffer == previousBuffer { return .none } + return buffer.hasPrefix(previousBuffer) + ? .append(Data(buffer.dropFirst(previousBuffer.count).utf8)) : .reset + } + guard let previousCursor, previousCursor.generation == cursor.generation else { return .reset } + let unread = cursor.byteOffset - previousCursor.byteOffset + guard unread >= 0, unread <= buffer.utf8.count else { return .reset } + if unread == 0 { return .none } + return .append(Data(buffer.utf8.suffix(unread))) + } +} diff --git a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift index cb10ee054..6f3d5826e 100644 --- a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift +++ b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift @@ -7,6 +7,7 @@ struct GhosttyTerminalSurface: UIViewRepresentable { @SwiftUI.Environment(\.colorScheme) private var colorScheme let terminalKey: String let buffer: String + let outputCursor: FeatureTerminalOutputCursor? let fontSize: CGFloat let isRunning: Bool let focusRequest: Int @@ -34,7 +35,7 @@ struct GhosttyTerminalSurface: UIViewRepresentable { view.terminalKey = terminalKey view.fontSize = fontSize view.isRunning = isRunning - view.buffer = buffer + view.updateOutput(buffer, cursor: outputCursor) view.focusRequest = focusRequest } } @@ -511,11 +512,15 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter } } - var buffer = "" { - didSet { - guard oldValue != buffer else { return } - applyRemoteBuffer(buffer) - } + private(set) var buffer = "" + private var outputCursor: FeatureTerminalOutputCursor? + private var appliedCursor: FeatureTerminalOutputCursor? + + func updateOutput(_ value: String, cursor: FeatureTerminalOutputCursor?) { + guard value != buffer || cursor != outputCursor else { return } + buffer = value + outputCursor = cursor + applyRemoteBuffer(value) } var fontSize: CGFloat = 10.5 { @@ -780,6 +785,7 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter private func resetSurface() { destroySurface() lastAppliedBuffer = "" + appliedCursor = nil lastViewportSize = .zero lastContentScale = 0 lastReportedGrid = nil @@ -832,18 +838,17 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter createSurfaceIfPossible() return } - guard newBuffer != lastAppliedBuffer else { return } - - if newBuffer.isEmpty { - feedData(Data("\u{1B}[2J\u{1B}[H".utf8)) - lastAppliedBuffer = "" + switch TerminalOutputReplay.update(buffer: newBuffer, cursor: outputCursor, + previousBuffer: lastAppliedBuffer, previousCursor: appliedCursor) { + case .none: return - } - - if newBuffer.hasPrefix(lastAppliedBuffer) { - feedData(Data(newBuffer.dropFirst(lastAppliedBuffer.count).utf8)) + case .append(let data): + feedData(data) lastAppliedBuffer = newBuffer + appliedCursor = outputCursor return + case .reset: + break } resetSurface() @@ -851,7 +856,8 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter } private func feedBuffer(_ value: String) { - guard !value.isEmpty else { return } + appliedCursor = outputCursor + guard !value.isEmpty else { lastAppliedBuffer = value; return } isReplayingBuffer = true defer { isReplayingBuffer = false } feedData(Data(value.utf8)) diff --git a/apps/swift-ios/Tests/CoreTests/TerminalOutputReplayTests.swift b/apps/swift-ios/Tests/CoreTests/TerminalOutputReplayTests.swift new file mode 100644 index 000000000..ede3fea29 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/TerminalOutputReplayTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import T3Code + +struct TerminalOutputReplayTests { + @Test func appendsAfterRetainedHistoryRollsOver() { + let previous = FeatureTerminalOutputCursor(byteOffset: 5) + let next = FeatureTerminalOutputCursor(generation: previous.generation, byteOffset: 11) + #expect(TerminalOutputReplay.update(buffer: "lo world", cursor: next, previousBuffer: "hello", previousCursor: previous) == .append(Data(" world".utf8))) + } + + @Test func identicalRetainedTextStillDeliversNewOutput() { + let previous = FeatureTerminalOutputCursor(byteOffset: 10) + let next = FeatureTerminalOutputCursor(generation: previous.generation, byteOffset: 13) + #expect(TerminalOutputReplay.update(buffer: "xxxxx", cursor: next, previousBuffer: "xxxxx", previousCursor: previous) == .append(Data("xxx".utf8))) + } + + @Test func newAttachOrClearResetsEvenWithIdenticalText() { + let previous = FeatureTerminalOutputCursor(byteOffset: 5) + let next = FeatureTerminalOutputCursor(byteOffset: 5) + #expect(TerminalOutputReplay.update(buffer: "hello", cursor: next, previousBuffer: "hello", previousCursor: previous) == .reset) + } + + @Test func staleCursorReplaysOnceThenUnicodeAppendsByBytes() { + let previous = FeatureTerminalOutputCursor(byteOffset: 0) + let recovered = FeatureTerminalOutputCursor(generation: previous.generation, byteOffset: 100) + #expect(TerminalOutputReplay.update(buffer: "tail", cursor: recovered, previousBuffer: "", previousCursor: previous) == .reset) + let next = FeatureTerminalOutputCursor(generation: previous.generation, byteOffset: 107) + #expect(TerminalOutputReplay.update(buffer: "tail界🙂", cursor: next, previousBuffer: "tail", previousCursor: recovered) == .append(Data("界🙂".utf8))) + #expect(TerminalOutputReplay.update(buffer: "tail界🙂", cursor: next, previousBuffer: "tail界🙂", previousCursor: next) == .none) + } +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index abd9bf9ed..6145fb471 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,7 +3,13 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; +import { + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + type TerminalOutputCursor, + type TerminalOutputUpdate, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; import { Plus, Square, @@ -27,6 +33,7 @@ import { useCallback, useEffect, useEffectEvent, + useLayoutEffect, useMemo, useRef, useState, @@ -95,8 +102,12 @@ function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { - terminal.resetAndWrite(buffer); +export function writeTerminalOutputUpdate( + terminal: Pick, + update: TerminalOutputUpdate, +): void { + if (update.type === "reset") terminal.resetAndWrite(update.data); + else if (update.type === "append") terminal.write(update.data); } function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { @@ -154,16 +165,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); + const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -172,8 +190,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -324,6 +350,7 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; + visible: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; @@ -348,12 +375,18 @@ export function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, + visible, resizeEpoch, drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); + const visibleRef = useRef(visible); + useLayoutEffect(() => { + visibleRef.current = visible; + terminalRef.current?.setVisible(visible); + }, [visible]); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -425,7 +458,8 @@ export function TerminalViewport({ input: { threadId, terminalId, cols, rows }, }), ); - const terminalBuffer = terminalSession.buffer; + const terminalOutput = terminalSession.output; + const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; const synchronizedStatusRef = useRef("closed"); @@ -448,14 +482,14 @@ export function TerminalViewport({ ); const terminalVersion = terminalSession.version; const previousSessionRef = useRef({ - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, }); const latestSessionRef = useRef(previousSessionRef.current); latestSessionRef.current = { - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, @@ -485,6 +519,9 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { + get visible() { + return visibleRef.current; + }, theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), @@ -506,6 +543,7 @@ export function TerminalViewport({ } // The theme observer is not installed yet, so re-read the theme in case // the app toggled light/dark while the WASM surface was loading. + terminal.setVisible(visibleRef.current); terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; @@ -518,7 +556,13 @@ export function TerminalViewport({ } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; - if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); + const initialOutput = readTerminalOutputUpdate( + latestSession.output, + INITIAL_TERMINAL_OUTPUT_CURSOR, + ); + if (initialOutput.type === "reset" && initialOutput.data.length > 0) + writeTerminalOutputUpdate(terminal, initialOutput); + outputCursorRef.current = initialOutput.cursor; if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); // Attaching to a session that already exited must still run exit handling // once, so mount synchronization starts from the empty "closed" state. @@ -526,7 +570,8 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + // WASM may finish after the user has returned to the composer. + if (visibleRef.current && mount.contains(document.activeElement)) terminal.focus(); const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -901,7 +946,9 @@ export function TerminalViewport({ return () => { cancelled = true; + const hadFocus = mount.contains(document.activeElement); teardown?.(); + if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. @@ -910,7 +957,7 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; const current = { - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, @@ -922,43 +969,26 @@ export function TerminalViewport({ const previous = previousSessionRef.current; synchronizeTerminalStatus(terminal, current.status); - if (current.version === previous.version) { + if (current.version === previous.version && current.output === previous.output) { return; } - if ( - current.buffer.length >= previous.buffer.length && - current.buffer.startsWith(previous.buffer) - ) { - terminal.write(current.buffer.slice(previous.buffer.length)); - } else { - writeTerminalBuffer(terminal, current.buffer); - } + const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current); + writeTerminalOutputUpdate(terminal, outputUpdate); + outputCursorRef.current = outputUpdate.cursor; terminal.clearSelection(); if (current.error !== null && current.error !== previous.error) { writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus) { - window.requestAnimationFrame(() => { - terminal.focus(); - }); - } previousSessionRef.current = current; - }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { - if (!autoFocus) return; - const terminal = terminalRef.current; - if (!terminal) return; - const frame = window.requestAnimationFrame(() => { - terminal.focus(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [autoFocus, focusRequestId]); + if (!autoFocus || !visible) return; + (terminalRef.current ?? containerRef.current)?.focus(); + }, [autoFocus, focusRequestId, visible]); useEffect(() => { const terminal = terminalRef.current; @@ -979,6 +1009,7 @@ export function TerminalViewport({ return (
); @@ -1541,6 +1572,7 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} + visible={visible} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} @@ -1570,6 +1602,7 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} + visible={visible} autoFocus resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 6f3254359..618475abe 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -1,3 +1,14 @@ +import { + applyTerminalAttachStreamEvent, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + INITIAL_TERMINAL_OUTPUT_CURSOR, + nextTerminalAttachSeedState, + readTerminalOutputUpdate, + terminalOutputText, + type TerminalBufferState, +} from "@t3tools/client-runtime/state/terminal"; + +import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; @@ -50,7 +61,7 @@ describe("ghosttyCellText", () => { describe("GhosttyTerminalCore snapshots", () => { const cores = new Set(); - async function createCore() { + async function createCore(onData: (data: string) => void = () => {}) { const core = await GhosttyTerminalCore.create( 12, 3, @@ -61,12 +72,40 @@ describe("GhosttyTerminalCore snapshots", () => { background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 }, }, - () => {}, + onData, ); cores.add(core); return core; } + function createSession(history: string) { + return applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: { + threadId: "terminal-stream-test", + terminalId: "term-1", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history, + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-04T00:00:00.000Z", + }, + }); + } + + function append(state: TerminalBufferState, data: string) { + return applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: "terminal-stream-test", + terminalId: "term-1", + data, + }); + } + afterEach(() => { for (const core of cores) core.dispose(); cores.clear(); @@ -145,4 +184,197 @@ describe("GhosttyTerminalCore snapshots", () => { core.dispose(); expect(free).toHaveBeenCalledWith(buffer, capacity); }); + it.each(["varied", "identical"] as const)( + "preserves Ghostty state through a full MiB of %s output without rollover resets", + async (kind) => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + const initial = "\x1b[31m"; + let state = createSession(initial); + const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, first); + reference.resetAndWrite(initial); + let cursor = first.cursor; + const reset = vi.spyOn(core, "resetAndWrite"); + const inputs: string[] = []; + let receivedCharacters = 0; + + for (let index = 0; index < 128; index += 1) { + const data = + kind === "identical" + ? "x".repeat(8192) + : `${index.toString().padStart(4, "0")}\r\n${"x".repeat(8186)}`; + inputs.push(data); + state = append(state, data); + const update = readTerminalOutputUpdate(state.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + receivedCharacters += update.data.length; + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + } + + reference.write(inputs.join("")); + expect(receivedCharacters).toBe(1024 * 1024); + expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); + expect(reset).not.toHaveBeenCalled(); + expect(core.snapshot()).toEqual(reference.snapshot()); + }, + ); + + it("preserves Unicode and split ANSI parser state across batched renderer reads", async () => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + let state = createSession(""); + const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, first); + let cursor = first.cursor; + const reset = vi.spyOn(core, "resetAndWrite"); + const inputs = [ + `${"a".repeat(16_383)}🙂`, + "\x1b[3", + "1m", + "e", + "\u0301界🙂", + "\x1b[0", + "m\r\n", + "\x1b]8;;https://t3.codes\x1b", + "\\link", + "\x1b]8;;\x1b", + "\\\x1b[?1049h", + "alternate", + "\x1b[?1049l", + "\r\nend", + ]; + let received = ""; + for (const [index, data] of inputs.entries()) { + state = append(state, data); + if (index % 3 !== 0 && index !== inputs.length - 1) continue; + const update = readTerminalOutputUpdate(state.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + received += update.data; + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + } + + reference.write(inputs.join("")); + expect(received).toBe(inputs.join("")); + expect(reset).not.toHaveBeenCalled(); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); + + it("answers a live VT query when batched reads cross a chunk compaction", async () => { + const replies: string[] = []; + const core = await createCore((data) => replies.push(data)); + core.write("\x1b[5n"); + expect(replies).toEqual(["\x1b[0n"]); + replies.length = 0; + + let state = createSession(""); + const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, initial); + let cursor = initial.cursor; + for (let index = 0; index < 1000; index += 1) { + state = append(state, "x"); + const update = readTerminalOutputUpdate(state.output, cursor); + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + } + for (let index = 0; index < 24; index += 1) state = append(state, "x"); + state = append(state, "\x1b[5n"); + const update = readTerminalOutputUpdate(state.output, cursor); + writeTerminalOutputUpdate(core, update); + + expect({ type: update.type, replies }).toEqual({ type: "append", replies: ["\x1b[0n"] }); + }); + + it("recovers a lagging renderer once from bounded output and resumes appending", async () => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + let state = createSession("\x1b[31mold"); + const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, initial); + const reset = vi.spyOn(core, "resetAndWrite"); + const data = "line\r\n".repeat(8192); + for (let index = 0; index < 16; index += 1) state = append(state, data); + + const recovery = readTerminalOutputUpdate(state.output, initial.cursor); + if (recovery.type !== "reset") throw new Error(`Expected reset, received ${recovery.type}`); + expect(new TextEncoder().encode(recovery.data).byteLength).toBe( + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + ); + writeTerminalOutputUpdate(core, recovery); + reference.resetAndWrite(recovery.data); + expect(core.snapshot()).toEqual(reference.snapshot()); + + state = append(state, "\r\nlatest"); + const next = readTerminalOutputUpdate(state.output, recovery.cursor); + expect(next.type).toBe("append"); + writeTerminalOutputUpdate(core, next); + reference.write("\r\nlatest"); + expect(reset).toHaveBeenCalledTimes(1); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); + + it("replays the latest retained output when WASM arrives after several events", async () => { + const pendingCore = createCore(); + let state = createSession("before"); + state = append(state, "\r\nduring "); + state = append(state, "🙂 load"); + const core = await pendingCore; + const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, first); + const reference = await createCore(); + reference.resetAndWrite(terminalOutputText(state.output)); + const reset = vi.spyOn(core, "resetAndWrite"); + + state = append(state, "\r\nafter"); + const next = readTerminalOutputUpdate(state.output, first.cursor); + expect(next).toMatchObject({ type: "append", data: "\r\nafter" }); + writeTerminalOutputUpdate(core, next); + reference.write("\r\nafter"); + expect(reset).not.toHaveBeenCalled(); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); + + it("resets real Ghostty for a repeated snapshot, clear, restart, and a fresh attach", async () => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + let state = createSession("hello"); + const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, initial); + let cursor = initial.cursor; + + const snapshot = { + threadId: "terminal-stream-test", + terminalId: "term-1", + cwd: "/repo", + worktreePath: null, + status: "running" as const, + pid: 456, + history: "hello", + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-04T00:00:01.000Z", + }; + const events = [ + { type: "snapshot", snapshot }, + { type: "cleared", threadId: snapshot.threadId, terminalId: snapshot.terminalId }, + { type: "restarted", threadId: snapshot.threadId, terminalId: snapshot.terminalId, snapshot }, + ] as const; + for (const event of events) { + core.write("\r\nstale local text"); + state = applyTerminalAttachStreamEvent(state, event); + const update = readTerminalOutputUpdate(state.output, cursor); + expect(update.type).toBe("reset"); + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + reference.resetAndWrite(event.type === "cleared" ? "" : "hello"); + expect(core.snapshot()).toEqual(reference.snapshot()); + } + + core.write("\r\nstale local text"); + state = createSession("hello"); + const reattached = readTerminalOutputUpdate(state.output, cursor); + expect(reattached.type).toBe("reset"); + writeTerminalOutputUpdate(core, reattached); + reference.resetAndWrite("hello"); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); }); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 6fbc420fd..091a18d2c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -584,6 +584,7 @@ export interface GhosttySelectionPosition { } export interface GhosttyTerminalSurfaceOptions { + readonly visible?: boolean; readonly theme: GhosttyTheme; readonly font?: GhosttyTerminalFont; readonly onData: (data: string) => void; @@ -632,6 +633,8 @@ export class GhosttyTerminalSurface { private scrollbarPointerId: number | null = null; private scrollbarPointerOffset = 0; private disposed = false; + private visible = true; + private hasSize = false; private resizeNotifyTimer: number | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; @@ -699,6 +702,7 @@ export class GhosttyTerminalSurface { this.metrics = metrics; this.options = options; this.theme = options.theme; + this.visible = options.visible ?? true; this.fontFamily = fontFamily; this.requestedFontFamily = options.font?.family; this.fontSize = terminalFontSize(options.font?.size); @@ -785,6 +789,21 @@ export class GhosttyTerminalSurface { return surface; } + /** Keep parsing and answering VT requests while a retained drawer is hidden. */ + setVisible(visible: boolean): void { + if (this.disposed || this.visible === visible) return; + this.visible = visible; + this.cursorOn = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + if (!visible) { + this.cancelRender(); + this.setSelectionAutoscroll(0); + return; + } + this.fit(); + } + write(data: string): void { if (this.disposed) return; this.core.write(data); @@ -881,10 +900,16 @@ export class GhosttyTerminalSurface { }; fit(): boolean { - if (this.disposed) return false; + if (this.disposed || !this.visible) return false; const width = this.mount.clientWidth; const height = this.mount.clientHeight; - if (width <= 0 || height <= 0) return false; + if (width <= 0 || height <= 0) { + this.hasSize = false; + this.cancelRender(); + return false; + } + const wasSized = this.hasSize; + this.hasSize = true; const fitted = fittedTerminalFontSize( (size) => measureGhosttyCell(this.context, size, this.fontFamily).width, this.requestedFontSize, @@ -904,7 +929,7 @@ export class GhosttyTerminalSurface { const ratio = window.devicePixelRatio || 1; const pixelWidth = Math.max(1, Math.round(width * ratio)); const pixelHeight = Math.max(1, Math.round(height * ratio)); - let shouldRender = false; + let shouldRender = !wasSized || this.forceFullRender; // The DPR transform must be installed even when the target size happens to // equal the canvas default 300x150 backing store, so the first fit always // schedules a canvas configuration. @@ -956,6 +981,7 @@ export class GhosttyTerminalSurface { } focus(): void { + if (this.disposed || !this.visible) return; this.input.focus({ preventScroll: true }); } @@ -1818,19 +1844,36 @@ export class GhosttyTerminalSurface { } private requestRender(): void { - if (this.disposed || this.frame !== 0) return; + if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; this.frame = window.requestAnimationFrame(() => { this.frame = 0; this.renderFrame(); }); } + private cancelRender(): void { + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + if (this.cursorTimer !== null) { + window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + } + } + private renderFrame(): void { - if (this.disposed) return; + if (this.disposed || !this.visible) return; if (this.frame !== 0) { window.cancelAnimationFrame(this.frame); this.frame = 0; } + if (this.mount.clientWidth === 0 || this.mount.clientHeight === 0) { + this.hasSize = false; + this.forceFullRender = true; + this.cancelRender(); + return; + } this.snapshot = this.core.snapshot(); // A cursor that is not blinking right now must be drawn, never caught in an // off phase left behind by a blink that has since been turned off. @@ -1896,7 +1939,7 @@ export class GhosttyTerminalSurface { private blinkEnabled(): boolean { const snapshot = this.snapshot; - if (!snapshot) return false; + if (!snapshot || !this.visible || !this.hasSize) return false; return shouldBlinkTerminalCursor({ focused: this.focused, cursorBlinking: snapshot.cursorBlinking, diff --git a/apps/web/src/terminal/ghostty/surface.visibility.test.ts b/apps/web/src/terminal/ghostty/surface.visibility.test.ts new file mode 100644 index 000000000..721446407 --- /dev/null +++ b/apps/web/src/terminal/ghostty/surface.visibility.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { GhosttyTerminalCore } from "./core"; +import { GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions } from "./surface"; +vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ + default: (await import("./vendor/ghostty-vt.wasm?inline")).default, +})); +vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ + default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, +})); + +describe("GhosttyTerminalSurface visibility", () => { + const surfaces = new Set(); + + // Keep the real surface, renderer, and WASM core. Only browser layout and + // scheduling are replaced so tests can count work while the terminal is hidden. + function createHarness() { + vi.useFakeTimers(); + const frames = new Map(); + const resizeCallbacks = new Set<() => void>(); + const paint = vi.fn((_operation: string, _args: ReadonlyArray) => {}); + let frameId = 0; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + + class TerminalTestElement extends EventTarget { + style: Record = {}; + parentElement: TerminalTestElement | null = null; + clientWidth = 168; + clientHeight = 104; + width = 300; + height = 150; + value = ""; + private readonly captures = new Set(); + + setAttribute() {} + append(...children: TerminalTestElement[]) { + for (const child of children) child.parentElement = this; + } + replaceChildren(...children: TerminalTestElement[]) { + this.append(...children); + } + remove() { + this.parentElement = null; + } + getContext() { + return context; + } + focus() { + this.dispatchEvent(new Event("focus")); + } + setPointerCapture(pointerId: number) { + this.captures.add(pointerId); + } + hasPointerCapture(pointerId: number) { + return this.captures.has(pointerId); + } + releasePointerCapture(pointerId: number) { + this.captures.delete(pointerId); + } + getBoundingClientRect() { + return { left: 0, top: 0, right: 168, bottom: 104, width: 168, height: 104 }; + } + } + + const canvas = new TerminalTestElement(); + const mount = new TerminalTestElement(); + const context = { + canvas, + beginPath() {}, + clip() {}, + rect() {}, + resetTransform() {}, + restore() {}, + save() {}, + setTransform() {}, + fillRect: (...args: number[]) => paint("fillRect", args), + strokeRect: (...args: number[]) => paint("strokeRect", args), + fillText: (...args: [string, number, number, number?]) => paint("fillText", args), + measureText: (text: string) => ({ + width: text.length * 8, + actualBoundingBoxAscent: 9, + actualBoundingBoxDescent: 3, + }), + }; + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()), + fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }), + }); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + devicePixelRatio: 1, + requestAnimationFrame: requestFrame, + cancelAnimationFrame: (id: number) => frames.delete(id), + setTimeout, + clearTimeout, + setInterval, + clearInterval, + matchMedia: () => Object.assign(new EventTarget(), { matches: false }), + }), + ); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(private readonly callback: () => void) { + resizeCallbacks.add(callback); + } + observe() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + }, + ); + const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); + const onData = vi.fn<(data: string) => void>(); + + return { + mount, + frames, + paint, + requestFrame, + snapshot, + onData, + get renderedSnapshot() { + const result = snapshot.mock.results.at(-1); + if (result?.type !== "return") throw new Error("No terminal snapshot was rendered"); + return result.value; + }, + flushFrame() { + const queued = [...frames.values()]; + frames.clear(); + for (const callback of queued) callback(0); + }, + resize() { + for (const callback of resizeCallbacks) callback(); + }, + pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) { + canvas.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { + clientX, + clientY: 5, + pointerId: 1, + button, + buttons, + shiftKey, + }), + ); + }, + async create(options: Partial = {}) { + const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { + theme: { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + onData, + onResize() {}, + onSelectionChange() {}, + beforeKey: () => false, + onLinkActivate() {}, + ...options, + get visible() { + return options.visible ?? true; + }, + }); + surfaces.add(surface); + return surface; + }, + }; + } + + afterEach(() => { + for (const surface of surfaces) surface.dispose(); + surfaces.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.focus(); + surface.write("ready\x1b[1 q"); + harness.flushFrame(); + vi.advanceTimersByTime(500); + harness.flushFrame(); + surface.write("queued"); + expect(harness.frames.size).toBe(1); + surface.setVisible(false); + expect(harness.frames.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + harness.snapshot.mockClear(); + harness.paint.mockClear(); + harness.requestFrame.mockClear(); + + surface.write("\x1b[2J\x1b[H\x1b[3"); + surface.write("1mhidden"); + surface.write("界🙂\x1b[0m\x1b[5n\x1b[6n"); + surface.fit(); + vi.advanceTimersByTime(2_000); + harness.flushFrame(); + + expect(harness.onData.mock.calls).toEqual([["\x1b[0n"], ["\x1b[1;11R"]]); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + expect(harness.requestFrame).not.toHaveBeenCalled(); + + surface.setVisible(true); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + expect(harness.renderedSnapshot).toMatchObject({ cursorX: 10, cursorY: 0 }); + expect(harness.renderedSnapshot.rowData[0]?.text).toContain("hidden"); + expect(harness.paint.mock.calls).toContainEqual(["fillRect", [84, 4, 8, 12]]); + expect(harness.frames.size).toBe(0); + }); +}); diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 105607424..c3fe06a0c 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -12,3 +12,9 @@ On Windows and Linux, **Ctrl+Insert** copies the current terminal selection. On Linux and BSD, middle-click pastes the selection from that terminal. With no terminal selection, it does nothing; it does not paste the system clipboard. Applications that capture mouse input still receive the click themselves. + +Web, desktop, and iOS keep a bounded local replay tail. A busy terminal keeps its +live screen and ANSI state as older replay history is discarded. Reconnecting +or falling behind the retained tail restores the current tail once. Hidden web +terminal drawers pause canvas rendering while continuing to receive output and +answer terminal queries; revealing a drawer redraws its current screen. diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 2cad8d61b..349291b81 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -13,7 +13,7 @@ import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { applyTerminalAttachStreamEvent, applyTerminalMetadataStreamEvent, - EMPTY_TERMINAL_BUFFER_STATE, + nextTerminalAttachSeedState, } from "./terminalSession.ts"; export function createTerminalEnvironmentAtoms( @@ -40,8 +40,10 @@ export function createTerminalEnvironmentAtoms( attach: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:terminal:attach", subscribe: (input: EnvironmentRpcInput) => - subscribe(WS_METHODS.terminalAttach, input).pipe( - Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent), + Stream.suspend(() => + subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), + ), ), }), events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts new file mode 100644 index 000000000..fcb0389c4 --- /dev/null +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -0,0 +1,320 @@ +export interface TerminalOutputChunk { + /** UTF-16 string offset within this generation and reset. */ + readonly startOffset: number; + readonly data: string; + readonly byteLength: number; +} + +export interface TerminalOutputState { + readonly generation: number; + readonly chunks: ReadonlyArray; + readonly retainedBytes: number; + readonly resetVersion: number; + readonly nextOffset: number; +} + +export interface TerminalOutputCursor { + readonly generation: number; + readonly resetVersion: number; + readonly offset: number; +} + +/** Forces the first `readTerminalOutputUpdate` to resynchronize from a reset. */ +export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze({ + generation: -1, + resetVersion: -1, + offset: 0, +}); + +export type TerminalOutputUpdate = + | { + readonly type: "none"; + readonly cursor: TerminalOutputCursor; + } + | { + readonly type: "reset"; + readonly data: string; + readonly cursor: TerminalOutputCursor; + } + | { + readonly type: "append"; + readonly cursor: TerminalOutputCursor; + readonly data: string; + }; + +export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; +const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024; +const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024; +const textEncoder = new TextEncoder(); +// A BOM at a retained chunk boundary is terminal data, not an encoding marker. +const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); + +export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze({ + generation: 0, + chunks: Object.freeze([]), + retainedBytes: 0, + resetVersion: 0, + nextOffset: 0, +}); + +interface Utf8Chunk { + readonly data: string; + readonly byteLength: number; +} + +/** + * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting + * a code point in half. The retained-output budget always supplies a positive + * size. Only new output is encoded on live updates. + * + * A chunk that fits whole is returned as the original string, so the common + * small-write path pays one encode and no decode. + */ +function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { + if (data.length === 0) return []; + + const encoded = textEncoder.encode(data); + if (encoded.byteLength <= maxBytes) { + return [{ data, byteLength: encoded.byteLength }]; + } + + const chunks: Utf8Chunk[] = []; + let offset = 0; + while (offset < encoded.byteLength) { + let end = Math.min(offset + maxBytes, encoded.byteLength); + while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { + end -= 1; + } + // A degenerate budget smaller than one code point still has to advance: + // include the whole code point rather than looping forever. + if (end === offset) { + end = Math.min(offset + maxBytes, encoded.byteLength); + while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { + end += 1; + } + } + const bytes = encoded.subarray(offset, end); + chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength }); + offset = end; + } + + return chunks; +} + +function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { + if (maxBufferBytes <= 0) { + return ""; + } + + const encoded = textEncoder.encode(buffer); + if (encoded.byteLength <= maxBufferBytes) { + return buffer; + } + + let start = encoded.byteLength - maxBufferBytes; + while (start < encoded.length) { + const byte = encoded[start]; + if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { + break; + } + start += 1; + } + + return textDecoder.decode(encoded.subarray(start)); +} + +function splitOutputChunks( + data: string, + firstOffset: number, + maxChunkBytes = DEFAULT_TERMINAL_CHUNK_BYTES, +): { + readonly chunks: ReadonlyArray; + readonly nextOffset: number; + readonly byteLength: number; +} { + const split = splitStringByUtf8Bytes(data, maxChunkBytes); + let byteLength = 0; + let nextOffset = firstOffset; + const chunks = split.map((chunk) => { + byteLength += chunk.byteLength; + const startOffset = nextOffset; + nextOffset += chunk.data.length; + return { + startOffset, + data: chunk.data, + byteLength: chunk.byteLength, + }; + }); + + return { + chunks, + nextOffset, + byteLength, + }; +} + +/** + * Merge adjacent chunks without changing their string positions. A reader can + * still append the unread suffix when its cursor falls inside a merged chunk. + */ +function compactRetainedChunks(chunks: ReadonlyArray) { + const compacted: TerminalOutputChunk[] = []; + for (const chunk of chunks) { + const previous = compacted.at(-1); + if ( + previous !== undefined && + previous.startOffset + previous.data.length === chunk.startOffset && + previous.byteLength + chunk.byteLength <= DEFAULT_TERMINAL_CHUNK_BYTES + ) { + compacted[compacted.length - 1] = { + startOffset: previous.startOffset, + data: `${previous.data}${chunk.data}`, + byteLength: previous.byteLength + chunk.byteLength, + }; + } else { + compacted.push(chunk); + } + } + return compacted; +} + +// Scan only the removed prefix instead of encoding retained output again. +function trimOutputChunkStart( + chunk: TerminalOutputChunk, + bytesToDrop: number, +): TerminalOutputChunk { + let offset = 0; + let droppedBytes = 0; + while (droppedBytes < bytesToDrop && offset < chunk.data.length) { + const codepoint = chunk.data.codePointAt(offset)!; + droppedBytes += codepoint <= 0x7f ? 1 : codepoint <= 0x7ff ? 2 : codepoint <= 0xffff ? 3 : 4; + offset += codepoint <= 0xffff ? 1 : 2; + } + return { + ...chunk, + startOffset: chunk.startOffset + offset, + data: chunk.data.slice(offset), + byteLength: chunk.byteLength - droppedBytes, + }; +} + +function appendOutput( + current: TerminalOutputState, + data: string, + maxBufferBytes: number, +): TerminalOutputState { + if (data.length === 0) return current; + if (maxBufferBytes <= 0) { + return { + generation: current.generation, + chunks: [], + retainedBytes: 0, + resetVersion: current.resetVersion + 1, + nextOffset: current.nextOffset + data.length, + }; + } + const appended = splitOutputChunks( + data, + current.nextOffset, + Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), + ); + + const chunks = [...current.chunks, ...appended.chunks]; + let retainedBytes = current.retainedBytes + appended.byteLength; + let firstRetainedIndex = 0; + while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) { + const first = chunks[firstRetainedIndex]!; + const bytesToDrop = retainedBytes - maxBufferBytes; + if (bytesToDrop < first.byteLength) { + const trimmed = trimOutputChunkStart(first, bytesToDrop); + retainedBytes -= first.byteLength - trimmed.byteLength; + if (trimmed.byteLength > 0) { + chunks[firstRetainedIndex] = trimmed; + } else { + firstRetainedIndex += 1; + } + break; + } + retainedBytes -= first.byteLength; + firstRetainedIndex += 1; + } + + let retainedChunks = firstRetainedIndex === 0 ? chunks : chunks.slice(firstRetainedIndex); + if (retainedChunks.length > MAX_TERMINAL_OUTPUT_CHUNKS) { + retainedChunks = compactRetainedChunks(retainedChunks); + const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS; + if (excessChunks > 0) { + for (const chunk of retainedChunks.slice(0, excessChunks)) { + retainedBytes -= chunk.byteLength; + } + retainedChunks = retainedChunks.slice(excessChunks); + } + } + + return { + generation: current.generation, + chunks: retainedChunks, + retainedBytes, + resetVersion: current.resetVersion, + nextOffset: appended.nextOffset, + }; +} + +function resetOutput( + current: TerminalOutputState, + data: string, + maxBufferBytes: number, +): TerminalOutputState { + const retained = trimBufferToBytes(data, maxBufferBytes); + const reset = splitOutputChunks( + retained, + 0, + Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), + ); + return { + generation: current.generation, + chunks: reset.chunks, + retainedBytes: reset.byteLength, + resetVersion: current.resetVersion + 1, + nextOffset: reset.nextOffset, + }; +} + +export function terminalOutputText(output: TerminalOutputState): string { + return output.chunks.map((chunk) => chunk.data).join(""); +} + +export function readTerminalOutputUpdate( + output: TerminalOutputState, + cursor: TerminalOutputCursor, +): TerminalOutputUpdate { + const nextCursor = { + generation: output.generation, + resetVersion: output.resetVersion, + offset: output.nextOffset, + }; + const firstChunk = output.chunks[0]; + if ( + cursor.generation !== output.generation || + cursor.resetVersion !== output.resetVersion || + cursor.offset < (firstChunk?.startOffset ?? output.nextOffset) + ) { + return { type: "reset", data: terminalOutputText(output), cursor: nextCursor }; + } + + const appended = output.chunks.filter( + (chunk) => chunk.startOffset + chunk.data.length > cursor.offset, + ); + if (appended.length === 0) { + return { type: "none", cursor: nextCursor }; + } + return { + type: "append", + data: appended + .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset))) + .join(""), + cursor: nextCursor, + }; +} + +export { appendOutput, resetOutput }; diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 85c57592d..3c9486b28 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; @@ -6,8 +6,13 @@ import { applyTerminalAttachStreamEvent, applyTerminalMetadataStreamEvent, combineTerminalSessionState, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, EMPTY_TERMINAL_BUFFER_STATE, + INITIAL_TERMINAL_OUTPUT_CURSOR, + nextTerminalAttachSeedState, + readTerminalOutputUpdate, selectRunningSubprocessTerminalIds, + terminalOutputText, } from "./terminalSession.ts"; const TARGET = { @@ -126,11 +131,49 @@ describe("terminal session reducers", () => { ); expect(output).toMatchObject({ - buffer: "lo world", status: "running", error: null, version: 2, }); + expect(terminalOutputText(output.output)).toBe("lo world"); + }); + + it("does not advance the lifecycle for the initial attach snapshot", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + }); + + it("advances the lifecycle for a live started snapshot", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const started = applyTerminalAttachStreamEvent(initial, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + + it("advances the lifecycle when a running terminal restarts in place", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const restarted = applyTerminalAttachStreamEvent(snapshot, { + type: "restarted", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 }); }); it("reduces terminal metadata snapshots, upserts, and removals", () => { @@ -182,6 +225,270 @@ describe("terminal session reducers", () => { 4, ); - expect(state.buffer).toBe("🙂"); + expect(terminalOutputText(state.output)).toBe("🙂"); + }); + + it("preserves a BOM code point at a new output chunk boundary", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const data = `${"x".repeat(16_384)}\uFEFF${"y".repeat(20)}`; + const state = applyTerminalAttachStreamEvent(initial, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + + expect(terminalOutputText(state.output)).toBe(data); + expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data }); + expect(state.output.retainedBytes).toBe(new TextEncoder().encode(data).byteLength); + }); + + it("preserves a leading BOM in the retained snapshot tail", () => { + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "discard\uFEFFtail" } }, + 7, + ); + + expect(terminalOutputText(state.output)).toBe("\uFEFFtail"); + expect(state.output.retainedBytes).toBe(7); + }); + + it("trims whole Unicode code points from a partially retained chunk", () => { + let state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "é界🙂end" } }, + 12, + ); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + for (const [data, expected, retainedBytes] of [ + ["x", "界🙂endx", 11], + ["yz", "🙂endxyz", 10], + ["abc", "endxyzabc", 9], + ] as const) { + state = applyTerminalAttachStreamEvent( + state, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data }, + 12, + ); + const update = readTerminalOutputUpdate(state.output, cursor); + expect(update).toMatchObject({ type: "append", data }); + expect(terminalOutputText(state.output)).toBe(expected); + expect(state.output.retainedBytes).toBe(retainedBytes); + cursor = update.cursor; + } + }); + + it("delivers all output when several events arrive before a renderer reads", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + let state = initial; + for (const data of [" one", " two", " three"]) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + } + const update = readTerminalOutputUpdate(state.output, cursor); + expect(update).toMatchObject({ type: "append", data: " one two three" }); + expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none"); + }); + + it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + const staleCursor = readTerminalOutputUpdate( + initial.output, + INITIAL_TERMINAL_OUTPUT_CURSOR, + ).cursor; + const first = applyTerminalAttachStreamEvent(initial, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "hello", + }); + const caughtUpCursor = readTerminalOutputUpdate(first.output, staleCursor).cursor; + const state = applyTerminalAttachStreamEvent( + first, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world" }, + 8, + ); + + expect(readTerminalOutputUpdate(state.output, caughtUpCursor)).toMatchObject({ + type: "append", + data: " world", + }); + expect(readTerminalOutputUpdate(state.output, staleCursor)).toMatchObject({ + type: "reset", + data: "lo world", + }); + expect(state.output.retainedBytes).toBe(8); + }); + + it("does not encode retained history again when appending at the byte limit", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "x".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES) }, + }); + const data = "y".repeat(8192); + const encode = vi.spyOn(TextEncoder.prototype, "encode"); + try { + for (let index = 0; index < 100; index += 1) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + } + expect(encode.mock.calls.reduce((total, [text]) => total + (text?.length ?? 0), 0)).toBe( + data.length * 100, + ); + expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); + } finally { + encode.mockRestore(); + } + }); + + it.each([0, -1])("discards output without encoding when the byte budget is %s", (maxBytes) => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const data = "x".repeat(65_536); + const encode = vi.spyOn(TextEncoder.prototype, "encode"); + try { + const discarded = applyTerminalAttachStreamEvent( + initial, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data }, + maxBytes, + ); + expect(encode).not.toHaveBeenCalled(); + expect(discarded.output.nextOffset).toBe(initial.output.nextOffset + data.length); + expect(discarded.output.retainedBytes).toBe(0); + expect(readTerminalOutputUpdate(discarded.output, cursor)).toMatchObject({ + type: "reset", + data: "", + }); + + const empty = applyTerminalAttachStreamEvent( + initial, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "" }, + maxBytes, + ); + expect(empty.output).toBe(initial.output); + expect(encode).not.toHaveBeenCalled(); + } finally { + encode.mockRestore(); + } + }); + + it("resets for repeated snapshots, clear, and restart even when output text repeats", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT }); + const repeated = readTerminalOutputUpdate(state.output, cursor); + expect(repeated).toMatchObject({ type: "reset", data: "hello" }); + expect(state.version).toBe(2); + cursor = repeated.cursor; + + state = applyTerminalAttachStreamEvent(state, { + type: "cleared", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + const cleared = readTerminalOutputUpdate(state.output, cursor); + expect(cleared).toMatchObject({ type: "reset", data: "" }); + state = applyTerminalAttachStreamEvent(state, { + type: "restarted", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + snapshot: BASE_SNAPSHOT, + }); + expect(readTerminalOutputUpdate(state.output, cleared.cursor)).toMatchObject({ + type: "reset", + data: "hello", + }); + expect(state.lifecycleVersion).toBe(2); + }); + + it("does not reuse a renderer cursor when a fresh attach has matching counters", () => { + const first = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = readTerminalOutputUpdate(first.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const next = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "other" }, + }); + expect(next.output.resetVersion).toBe(first.output.resetVersion); + expect(next.output.nextOffset).toBe(first.output.nextOffset); + expect(readTerminalOutputUpdate(next.output, cursor)).toMatchObject({ + type: "reset", + data: "other", + }); + expect(next.lifecycleVersion).toBe(0); + }); + + it("keeps appending while compacting metadata for many small writes", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + let received = ""; + for (let index = 0; index < 2500; index += 1) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "x", + }); + const update = readTerminalOutputUpdate(state.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + received += update.data; + cursor = update.cursor; + } + expect(received).toBe("x".repeat(2500)); + expect(state.output.chunks.length).toBeLessThan(1024); + expect(terminalOutputText(state.output)).toBe(received); + }); + + it("appends every unread character across a compaction boundary", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const writes = Array.from({ length: 1200 }, (_, index) => ["x", "é", "界", "🙂"][index % 4]!); + for (const [index, data] of writes.entries()) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + if (index === 99) cursor = readTerminalOutputUpdate(state.output, cursor).cursor; + } + expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ + type: "append", + data: writes.slice(100).join(""), + }); }); }); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index ee444e36d..b1ef6500d 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -6,23 +6,42 @@ import type { TerminalSummary, ThreadId, } from "@t3tools/contracts"; +import { + appendOutput, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + EMPTY_TERMINAL_OUTPUT_STATE, + resetOutput, + type TerminalOutputState, +} from "./terminalOutput.ts"; + +export { + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + terminalOutputText, + type TerminalOutputCursor, + type TerminalOutputState, + type TerminalOutputUpdate, +} from "./terminalOutput.ts"; export interface TerminalSessionState { readonly summary: TerminalSummary | null; - readonly buffer: string; + readonly output: TerminalOutputState; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly hasRunningSubprocess: boolean; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface TerminalBufferState { - readonly buffer: string; + readonly output: TerminalOutputState; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface KnownTerminalSessionTarget { @@ -45,59 +64,50 @@ export function selectRunningSubprocessTerminalIds( } export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ - buffer: "", + output: EMPTY_TERMINAL_OUTPUT_STATE, status: "closed", error: null, updatedAt: null, version: 0, + lifecycleVersion: 0, }); export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ summary: null, - buffer: "", + output: EMPTY_TERMINAL_OUTPUT_STATE, status: "closed", error: null, hasRunningSubprocess: false, updatedAt: null, version: 0, + lifecycleVersion: 0, }); -export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); +let terminalAttachGeneration = 0; -function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { - if (maxBufferBytes <= 0) { - return ""; - } - - const encoded = textEncoder.encode(buffer); - if (encoded.byteLength <= maxBufferBytes) { - return buffer; - } - - let start = encoded.byteLength - maxBufferBytes; - while (start < encoded.length) { - const byte = encoded[start]; - if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { - break; - } - start += 1; - } - - return textDecoder.decode(encoded.subarray(start)); +/** A reinstalled attach stream must not reuse an old renderer's output cursor. */ +export function nextTerminalAttachSeedState(): TerminalBufferState { + return { + ...EMPTY_TERMINAL_BUFFER_STATE, + output: { + ...EMPTY_TERMINAL_OUTPUT_STATE, + generation: ++terminalAttachGeneration, + }, + }; } -export function terminalBufferStateFromSnapshot( +function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, + current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, ): TerminalBufferState { return { - buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), + output: resetOutput(current.output, snapshot.history, maxBufferBytes), status: snapshot.status, error: null, updatedAt: snapshot.updatedAt, - version: 1, + version: current.version + 1, + lifecycleVersion: current.lifecycleVersion, }; } @@ -113,12 +123,13 @@ export function combineTerminalSessionState( ): TerminalSessionState { return { summary, - buffer: buffer.buffer, + output: buffer.output, status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), error: buffer.error, hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), version: buffer.version, + lifecycleVersion: buffer.lifecycleVersion, }; } @@ -129,12 +140,20 @@ export function applyTerminalAttachStreamEvent( ): TerminalBufferState { switch (event.type) { case "snapshot": + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), + lifecycleVersion: + current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, + }; case "restarted": - return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), + lifecycleVersion: current.lifecycleVersion + 1, + }; case "output": return { ...current, - buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), + output: appendOutput(current.output, event.data, maxBufferBytes), status: current.status === "closed" ? "running" : current.status, error: null, version: current.version + 1, @@ -142,7 +161,7 @@ export function applyTerminalAttachStreamEvent( case "cleared": return { ...current, - buffer: "", + output: resetOutput(current.output, "", maxBufferBytes), error: null, version: current.version + 1, }; From 5702677efd1e1e94d17e9d3ab048b2cef5130af1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:45:14 +0200 Subject: [PATCH 14/93] feat: separate provider accounts from their settings editors --- PATCH.md | 10 +- .../Settings/SettingsAgentsView.swift | 104 ++-- .../Features/Settings/SettingsView.swift | 3 +- .../settings/AddProviderInstanceDialog.tsx | 14 +- .../settings/EnvironmentIconPicker.tsx | 2 +- .../settings/ProviderInstanceCard.tsx | 87 +++- .../components/settings/SettingsPanels.tsx | 479 +++++++++++------- docs/user/provider-accounts.md | 12 + 8 files changed, 465 insertions(+), 246 deletions(-) create mode 100644 docs/user/provider-accounts.md diff --git a/PATCH.md b/PATCH.md index aab14054d..1786a87c5 100644 --- a/PATCH.md +++ b/PATCH.md @@ -68,9 +68,13 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera files into the native file viewer, and offers template prompts through the composer. Invalid directives remain literal and code blocks are not interpreted as directives. -- Does not carry upstream's provider-settings list/editor split (`e2d4d12a81`, `f276e632c5`, - `5e63aea2df`) or its `ProviderInstanceCard` `mode: "list" | "editor"` restructure. The fork keeps - `EnvironmentProviderSettings` inline in `SettingsPanels.tsx` with the card's own expand/collapse. +- Ports upstream's provider-settings list/editor split onto the fork's existing + `SettingsPanels.tsx` and `ProviderInstanceCard`, preserving dedicated provider environment + fields and the Hermes rollout gate. Environment tabs scope reads, writes, additions and + updates; read-only sessions retain account navigation. Native Agents uses account navigation + and per-environment model visibility editing. Custom descriptors, setup terminals and the + remaining native provider configuration controls are separate ports. + - Does not carry upstream's pinned-block drag-to-reorder in the web sidebar (the `optimisticPinnedOrder` / `handlePinnedDragEnd` block) because the fork keeps its client-local whole-list manual order. The searchable project-filter combobox is now ported, including keyboard project settings and query reset on close. Upstream's toggleable unpin diff --git a/apps/swift-ios/Features/Settings/SettingsAgentsView.swift b/apps/swift-ios/Features/Settings/SettingsAgentsView.swift index 466c715d4..4f05a37bc 100644 --- a/apps/swift-ios/Features/Settings/SettingsAgentsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsAgentsView.swift @@ -14,11 +14,15 @@ import SwiftUI /// that cannot save — the same rule the Browser section in Integrations follows. public struct SettingsAgentsView: View { private let serverSettings: any FeatureServerSettingsManaging - private let environmentID: String? + private let initialEnvironmentID: String? + private let environments: [FeatureEnvironment] + @State private var selectedEnvironmentID: String? + private var environmentID: String? { selectedEnvironmentID ?? initialEnvironmentID } /// The server's own answer, republished whenever the config subscription /// reports it changing. private let preferences: FeatureEnvironmentPreferences? + @State private var savedAutoCompact: [String: String] = [:] @State private var isEditingAutoCompact = false @State private var modelConfiguration: ServerConfigSnapshot? @State private var modelError: String? @@ -27,47 +31,49 @@ public struct SettingsAgentsView: View { public init( serverSettings: any FeatureServerSettingsManaging, environmentID: String?, - preferences: FeatureEnvironmentPreferences? + preferences: FeatureEnvironmentPreferences?, + environments: [FeatureEnvironment] = [] ) { self.serverSettings = serverSettings - self.environmentID = environmentID + self.initialEnvironmentID = environmentID + self.environments = environments self.preferences = preferences } private var storedAutoCompactWindow: String { - preferences?.claudeAutoCompactWindow ?? "" + savedAutoCompact[environmentID ?? ""] ?? modelConfiguration?.settings?.claudeAutoCompactWindow ?? (environmentID == initialEnvironmentID ? preferences?.claudeAutoCompactWindow : nil) ?? "" } public var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 18) { if let modelError { SettingsErrorBanner(message: modelError) } - if let config = modelConfiguration, let environmentID { - ForEach(config.providers.filter(\.enabled), id: \.instanceId) { provider in - let models = provider.models.filter { !$0.isCustom } - let hidden = Set(config.settings?.providerModelPreferences[provider.instanceId]?.hiddenModels ?? []) - SettingsSection(title: "\(provider.displayName ?? provider.driver) models") { - if !models.isEmpty { - Button(models.allSatisfy { hidden.contains($0.slug) } ? "Enable all" : "Disable all") { - let slugs = Set(models.map(\.slug)) - saveModels(provider.instanceId, hidden: models.allSatisfy { hidden.contains($0.slug) } - ? hidden.subtracting(slugs) : hidden.union(slugs), environmentID: environmentID) - }.frame(minHeight: T3Metrics.minimumTapTarget) - ForEach(models) { model in - Toggle(model.name, isOn: Binding( - get: { !hidden.contains(model.slug) }, - set: { enabled in - var next = hidden - if enabled { next.remove(model.slug) } else { next.insert(model.slug) } - saveModels(provider.instanceId, hidden: next, environmentID: environmentID) - } - )).padding(.horizontal, SettingsMetrics.rowPadding) - } - } - }.disabled(isSavingModels) + if environments.count > 1 { + Picker("Environment", selection: Binding(get: { environmentID ?? "" }, set: { selectedEnvironmentID = $0 })) { + ForEach(environments) { environment in Label(environment.name, systemImage: environment.machineSymbol).tag(environment.id) } + }.padding(.horizontal, SettingsMetrics.rowPadding).disabled(isSavingModels) + } + if let config = modelConfiguration { + SettingsSection(title: "Accounts") { + ForEach(config.providers) { provider in + NavigationLink { + providerEditor(provider.instanceId) + } label: { + HStack(spacing: 12) { + ProviderIcon(driver: provider.driver, providerID: provider.instanceId, fallbackName: provider.displayName ?? provider.driver, size: 22) + VStack(alignment: .leading, spacing: 3) { + Text(provider.displayName ?? provider.driver).font(T3Typography.supportingStrong).foregroundStyle(T3Colors.textPrimary) + Text(provider.enabled ? provider.status : "Disabled").font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + Spacer() + if let version = provider.version { Text(version).font(T3Typography.supporting).foregroundStyle(T3Colors.textTertiary).lineLimit(1) } + Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(T3Colors.textTertiary) + }.padding(SettingsMetrics.rowPadding).frame(minHeight: T3Metrics.minimumTapTarget) + }.buttonStyle(.plain) + } } } - if let environmentID, preferences != nil { + if let environmentID, modelConfiguration?.settings != nil || (environmentID == initialEnvironmentID && preferences != nil) { SettingsSection( title: "Claude", footer: """ @@ -92,12 +98,12 @@ public struct SettingsAgentsView: View { ClaudeAutoCompactWindowEditor( stored: storedAutoCompactWindow, save: { normalized in - try await serverSettings.updateServerSettings( + let result = try await serverSettings.updateServerSettings( environmentID: environmentID, - patch: ServerSettingsPatchInput( - claudeAutoCompactWindow: normalized - ) + patch: ServerSettingsPatchInput(claudeAutoCompactWindow: normalized) ) + savedAutoCompact[environmentID] = result.claudeAutoCompactWindow + return result }, onFinished: { isEditingAutoCompact = false } ) @@ -130,12 +136,46 @@ public struct SettingsAgentsView: View { let config = try await serverSettings.providerModelConfiguration(environmentID: environmentID) guard !Task.isCancelled, self.environmentID == environmentID else { return } modelConfiguration = config + savedAutoCompact[environmentID] = nil } catch { if !Task.isCancelled { modelError = error.localizedDescription } } } .navigationTitle("Agents") .navigationBarTitleDisplayMode(.inline) } + @ViewBuilder + private func providerEditor(_ providerID: String) -> some View { + if let config = modelConfiguration, + let provider = config.providers.first(where: { $0.instanceId == providerID }), let environmentID { + let models = provider.models.filter { !$0.isCustom } + let hidden = Set(config.settings?.providerModelPreferences[providerID]?.hiddenModels ?? []) + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let modelError { SettingsErrorBanner(message: modelError) } + Text(provider.message ?? (provider.enabled ? provider.status : "This account is disabled.")) + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + SettingsSection(title: "Models") { + if models.isEmpty { Text("This account has no available built-in models.").padding(SettingsMetrics.rowPadding) } + else { + Button(models.allSatisfy { hidden.contains($0.slug) } ? "Enable all" : "Disable all") { + let slugs = Set(models.map(\.slug)) + saveModels(providerID, hidden: models.allSatisfy { hidden.contains($0.slug) } ? hidden.subtracting(slugs) : hidden.union(slugs), environmentID: environmentID) + }.frame(minHeight: T3Metrics.minimumTapTarget) + ForEach(models) { model in + Toggle(model.name, isOn: Binding(get: { !hidden.contains(model.slug) }, set: { enabled in + var next = hidden + if enabled { next.remove(model.slug) } else { next.insert(model.slug) } + saveModels(providerID, hidden: next, environmentID: environmentID) + })).padding(.horizontal, SettingsMetrics.rowPadding) + } + } + }.disabled(isSavingModels) + if isSavingModels { ProgressView("Saving models…") } + }.padding(18) + }.background(T3Colors.background).navigationTitle(provider.displayName ?? provider.driver).navigationBarTitleDisplayMode(.inline) + } else { ContentUnavailableView("Account unavailable", systemImage: "person.crop.circle.badge.questionmark") } + } + private func saveModels(_ providerID: String, hidden: Set, environmentID: String) { guard !isSavingModels else { return } isSavingModels = true diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index ab1bc37f2..c9f8deb6d 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -155,7 +155,8 @@ public struct SettingsView: View { SettingsAgentsView( serverSettings: serverSettingsManager, environmentID: activeEnvironmentID, - preferences: activeEnvironmentPreferences + preferences: activeEnvironmentPreferences, + environments: model.snapshot.environments ) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 32291f880..993c30980 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -4,12 +4,13 @@ import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { + type EnvironmentId, ProviderInstanceId, ProviderDriverKind, type ProviderInstanceConfig, } from "@t3tools/contracts"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; @@ -110,13 +111,18 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | } interface AddProviderInstanceDialogProps { + environmentId: EnvironmentId; open: boolean; onOpenChange: (open: boolean) => void; } -export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); +export function AddProviderInstanceDialog({ + open, + onOpenChange, + environmentId, +}: AddProviderInstanceDialogProps) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx index 99bb1b944..4cf22997d 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.tsx +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -46,7 +46,7 @@ export function resolveEnvironmentIconPickerLock(input: { // Same split the provider settings use: the desktop app owns its primary // server outright, a browser session on the primary checks its cookie // session's scopes, and a remote checks the scopes its own server reports. -function useEnvironmentOperateAccess(environmentId: EnvironmentId) { +export function useEnvironmentOperateAccess(environmentId: EnvironmentId) { const isPrimary = usePrimaryEnvironmentId() === environmentId; const primarySession = usePrimarySessionState(); const remoteSession = useEnvironmentSessionState(environmentId); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 629583311..c278947ba 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -422,6 +422,10 @@ interface ProviderInstanceCardProps { readonly liveProvider: ServerProvider | undefined; /** Effective enabled state after any driver-wide rollout gate is applied. */ readonly effectiveEnabled?: boolean | undefined; + readonly mode?: "list" | "editor"; + readonly selected?: boolean; + readonly onSelect?: () => void; + readonly readOnly?: boolean; readonly isExpanded: boolean; readonly onExpandedChange: (open: boolean) => void; readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; @@ -480,6 +484,10 @@ export function ProviderInstanceCard({ effectiveEnabled, isExpanded, onExpandedChange, + mode, + selected = false, + onSelect, + readOnly = false, onUpdate, onDelete, headerAction, @@ -720,8 +728,57 @@ export function ProviderInstanceCard({ {versionLabel} ) : null; + if (mode === "list") { + return ( +
+ + updateEnabled(Boolean(checked))} + aria-label={`Enable ${displayName}`} + /> +
+ ); + } + return ( -
+
@@ -744,7 +801,7 @@ export function ProviderInstanceCard({ )} aria-label="Update available — view details" > - + } /> @@ -828,16 +885,18 @@ export function ProviderInstanceCard({ {authRowNode}
- + {mode !== "editor" ? ( + + ) : null} updateEnabled(Boolean(checked))} @@ -847,7 +906,7 @@ export function ProviderInstanceCard({
- +
@@ -935,6 +994,6 @@ export function ProviderInstanceCard({
-
+
); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 67bca9ea7..d496e23ca 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,12 @@ +import { type EnvironmentId } from "@t3tools/contracts"; +import { useEnvironments } from "../../state/environments"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { ConnectedEnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { useEnvironmentOperateAccess } from "./EnvironmentIconPicker"; +import { + buildProviderEnvironmentOptions, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; import { ArchiveIcon, ArchiveX, @@ -105,6 +114,7 @@ import { isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom, + EMPTY_SERVER_PROVIDERS, serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; @@ -2890,17 +2900,77 @@ export function GeneralSettingsPanel() { ); } -export function ProviderSettingsPanel( - props: { - readonly includeDriver?: (driver: ProviderDriverKind) => boolean; - readonly title?: string; - readonly allowAddInstance?: boolean; - } = {}, +interface ProviderSettingsPanelProps { + readonly includeDriver?: (driver: ProviderDriverKind) => boolean; + readonly title?: string; + readonly allowAddInstance?: boolean; +} + +export function ProviderSettingsPanel(props: ProviderSettingsPanelProps = {}) { + const { environments } = useEnvironments(); + const primary = usePrimaryEnvironment(); + const [selectedId, setSelectedId] = useState(null); + const options = buildProviderEnvironmentOptions(environments, primary?.environmentId ?? null); + const environmentId = resolveSelectedProviderEnvironmentId( + options, + selectedId, + primary?.environmentId ?? null, + ); + const selected = options.find((environment) => environment.environmentId === environmentId); + return ( + +
+ {options.map((environment) => ( + + ))} +
+ {selected?.serverConfig && selected.connection.phase === "connected" ? ( + + ) : ( + + + + )} +
+ ); +} + +function EnvironmentProviderSettings( + props: ProviderSettingsPanelProps & { environmentId: EnvironmentId; environmentLabel: string }, ) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); + const settings = useEnvironmentSettings(props.environmentId); + const persistSettings = useUpdateEnvironmentSettings(props.environmentId); + const operateAccess = useEnvironmentOperateAccess(props.environmentId); + const readOnly = operateAccess !== "granted"; + const updateSettings: typeof persistSettings = (patch) => { + if (!readOnly) persistSettings(patch); + }; + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; + const targetEnvironment = useMemo( + () => ({ environmentId: props.environmentId }), + [props.environmentId], + ); const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -2912,7 +2982,7 @@ export function ProviderSettingsPanel( const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); + const [selectedInstanceId, setSelectedInstanceId] = useState(null); const refreshingRef = useRef(false); const providerUpdateCandidates = useMemo( @@ -2951,17 +3021,17 @@ export function ProviderSettingsPanel( : null; const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; + if (readOnly || refreshingRef.current) return; refreshingRef.current = true; setIsRefreshingProviders(true); - if (!primaryEnvironment) { + if (!targetEnvironment) { refreshingRef.current = false; setIsRefreshingProviders(false); return; } void (async () => { const result = await refreshServerProviders({ - environmentId: primaryEnvironment.environmentId, + environmentId: targetEnvironment.environmentId, input: {}, }); refreshingRef.current = false; @@ -2969,16 +3039,16 @@ export function ProviderSettingsPanel( if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { console.warn("Failed to refresh providers", { operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, + environmentId: targetEnvironment.environmentId, ...safeErrorLogAttributes(squashAtomCommandFailure(result)), }); } })(); - }, [primaryEnvironment, refreshServerProviders]); + }, [targetEnvironment, refreshServerProviders, readOnly]); const runProviderUpdate = useCallback( async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; + if (readOnly || !targetEnvironment) return; let started = false; setUpdatingProviderDrivers((previous) => { if (previous.has(candidate.driver)) { @@ -2994,7 +3064,7 @@ export function ProviderSettingsPanel( } const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, + environmentId: targetEnvironment.environmentId, input: { provider: candidate.driver, instanceId: candidate.instanceId, @@ -3022,7 +3092,7 @@ export function ProviderSettingsPanel( return next; }); }, - [primaryEnvironment, updateProvider], + [targetEnvironment, updateProvider, readOnly], ); interface InstanceRow { @@ -3238,15 +3308,118 @@ export function ProviderSettingsPanel( }); }; + const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0]; + const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + setSelectedInstanceId(row.instanceId)} + readOnly={readOnly} + isExpanded={mode === "editor"} + onExpandedChange={() => {}} + onUpdate={(next) => { + const wasEnabled = resolveProviderInstanceEnabled(row.instance); + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) + } + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + }; + return ( - + <> - {props.allowAddInstance !== false ? ( + {!readOnly && props.allowAddInstance !== false ? ( void refreshProviders()} aria-label="Refresh provider status" > @@ -3288,178 +3461,102 @@ export function ProviderSettingsPanel(
} > - - Health check interval - - This interval is configured here, then the shared Background activity policy decides - whether provider probes may run when the timer fires. Custom intervals appear as - Advanced in General settings. - - - } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) - } - /> - ) : null - } - control={ -
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
- } - /> - - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = resolveProviderInstanceEnabled(row.instance); - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); - } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} + {readOnly ? ( + + ) : null} +
+
+ {rows.map((row) => renderProviderInstance(row, "list"))} +
+
+ {selectedRow ? ( + renderProviderInstance(selectedRow, "editor") + ) : ( +

No providers configured.

+ )} +
+
+ + +
+ {" "} + + Health check interval + + This interval is configured here, then the shared Background activity policy + decides whether provider probes may run when the timer fires. Custom intervals + appear as Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== + defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> +
- {isAddInstanceDialogOpen ? ( - + {isAddInstanceDialogOpen && !readOnly ? ( + ) : null} - + ); } diff --git a/docs/user/provider-accounts.md b/docs/user/provider-accounts.md new file mode 100644 index 000000000..20c2fb8a8 --- /dev/null +++ b/docs/user/provider-accounts.md @@ -0,0 +1,12 @@ +# Provider accounts + +Open Settings → Providers and select an environment to see its accounts. Select an +account in the list to edit its settings and models. The account's enabled switch +remains in the list, so you can compare availability without opening every editor. +Use Add provider to create an account on the selected environment. Health-check +intervals are under Advanced. Sessions with limited permissions can browse accounts +but cannot change their settings or update provider software. + +On iOS, open Settings → Agents, select an environment, then open an account to +choose its visible models. Enable all and Disable all affect that account's +built-in models. Claude's auto-compaction setting applies to the selected environment. From abb50719dfab77305e9ffeca4759c33eeb87e492 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:06:12 +0200 Subject: [PATCH 15/93] feat(models): edit custom model names and options across clients --- PATCH.md | 14 +- .../src/environment/ServerEnvironment.ts | 1 + .../src/provider/Drivers/AcpRegistryDriver.ts | 13 +- .../src/provider/Drivers/HermesDriver.ts | 18 +- .../src/provider/Layers/CodexProvider.ts | 38 +- .../src/provider/Layers/GrokProvider.ts | 3 +- .../src/provider/providerSnapshot.test.ts | 24 + apps/server/src/provider/providerSnapshot.ts | 18 +- apps/swift-ios/App/NativeFeatureClient.swift | 7 +- apps/swift-ios/Core/Models.swift | 3 + apps/swift-ios/Core/ServerConfigModels.swift | 37 +- .../Settings/CustomModelDefinition.swift | 174 ++++ .../Settings/SettingsAgentsView.swift | 6 + .../Settings/SettingsCustomModelsView.swift | 219 +++++ .../Features/Shared/FeatureModels.swift | 5 +- .../CustomModelDefinitionTests.swift | 53 ++ .../CoreTests/Fixtures/customModels.json | 30 + .../Fixtures/environmentMachine.json | 3 +- .../components/settings/CustomModelEditor.tsx | 387 +++++++++ .../settings/ProviderInstanceCard.test.ts | 2 +- .../settings/ProviderInstanceCard.tsx | 53 +- .../settings/ProviderModelsSection.tsx | 761 +++++++++++------- .../components/settings/SettingsPanels.tsx | 8 +- .../settings/customModelEditor.logic.test.ts | 259 ++++++ .../settings/customModelEditor.logic.ts | 258 ++++++ apps/web/src/components/ui/switch.tsx | 12 +- apps/web/src/modelSelection.ts | 30 +- docs/user/provider-accounts.md | 12 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/model.ts | 16 + packages/contracts/src/settings.ts | 31 +- packages/shared/src/model.test.ts | 39 + packages/shared/src/model.ts | 70 +- scripts/generate-swift-contract-fixtures.ts | 37 +- 34 files changed, 2253 insertions(+), 390 deletions(-) create mode 100644 apps/swift-ios/Features/Settings/CustomModelDefinition.swift create mode 100644 apps/swift-ios/Features/Settings/SettingsCustomModelsView.swift create mode 100644 apps/swift-ios/Tests/CoreTests/CustomModelDefinitionTests.swift create mode 100644 apps/swift-ios/Tests/CoreTests/Fixtures/customModels.json create mode 100644 apps/web/src/components/settings/CustomModelEditor.tsx create mode 100644 apps/web/src/components/settings/customModelEditor.logic.test.ts create mode 100644 apps/web/src/components/settings/customModelEditor.logic.ts diff --git a/PATCH.md b/PATCH.md index 1786a87c5..be96dfe1b 100644 --- a/PATCH.md +++ b/PATCH.md @@ -72,8 +72,13 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera `SettingsPanels.tsx` and `ProviderInstanceCard`, preserving dedicated provider environment fields and the Hermes rollout gate. Environment tabs scope reads, writes, additions and updates; read-only sessions retain account navigation. Native Agents uses account navigation - and per-environment model visibility editing. Custom descriptors, setup terminals and the - remaining native provider configuration controls are separate ports. + and per-environment model visibility editing. Custom model names and option descriptors are + ported through the existing V2 providers, including fork-only providers, without adopting + upstream's V1 Claude catalog. Built-in model IDs and capabilities remain authoritative. + Web and Swift offer local draft editors, copied options, provider presets and explicit saves; + the optional `customModelDefinitions` capability gates structured writes to older servers. + Swift preserves unknown account configuration and other accounts when editing custom models. + Setup terminals and the remaining native provider configuration controls are separate ports. - Does not carry upstream's pinned-block drag-to-reorder in the web sidebar (the `optimisticPinnedOrder` / `handlePinnedDragEnd` block) because the fork keeps its client-local whole-list manual order. The searchable project-filter @@ -457,8 +462,9 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera dedicated human-reviewed ports. Advancing the squash-sync marker does not mean these features are supported. Do not import their client flags or schemas without implementing the matching V2/server behavior and auditing the hand-maintained Swift contracts: - - Custom model names/option descriptors and imported custom-provider selection - (`5a433244d0`, `d92dca74eb`) need a V2 provider-option/adapter audit. + - Custom model names/option descriptors (`5a433244d0`, `d92dca74eb`) are now ported + through the fork's existing provider snapshots and V2 option selection, as detailed above. + Imported custom-provider selection still needs a dedicated importer audit. - Shared project defaults and scoped overrides (`9f40b2f563`) need the retained project aggregate, fork-owned migration numbers, and Swift settings parity. Connection load balancing (`420fd76f60`) needs V2 launch selection and an explicit multi-machine workspace policy. diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 51ef0bfed..9e257b06a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -161,6 +161,7 @@ export const make = Effect.gen(function* () { threadVisitedTracking: true, environmentThemes: true, environmentIcon: true, + customModelDefinitions: true, threadPinning: true, threadActiveOrderV2: true, threadQuestionActionsV2: true, diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index ad136beb8..bd32f8d66 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -1,3 +1,4 @@ +import { providerModelsFromSettings } from "../providerSnapshot.ts"; import { AcpRegistrySettings, ProviderDriverKind, @@ -51,7 +52,6 @@ const makeSnapshot = (input: { readonly continuationKey: string; readonly checkedAt: string; }): ServerProvider => { - const modelIds = Array.from(new Set(["default", ...input.settings.customModels])); return { instanceId: input.instanceId, driver: DRIVER_KIND, @@ -64,12 +64,11 @@ const makeSnapshot = (input: { status: input.enabled ? "ready" : "disabled", auth: { status: "unknown" }, checkedAt: input.checkedAt, - models: modelIds.map((model) => ({ - slug: model, - name: model, - isCustom: model !== "default", - capabilities: null, - })), + models: providerModelsFromSettings( + [{ slug: "default", name: "default", isCustom: false, capabilities: null }], + input.settings.customModels, + { optionDescriptors: [] }, + ), slashCommands: [], skills: [], }; diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts index 839643fcc..d89bc9e77 100644 --- a/apps/server/src/provider/Drivers/HermesDriver.ts +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -1,3 +1,5 @@ +import { providerModelsFromSettings } from "../providerSnapshot.ts"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { HermesSettings, ProviderDriverKind, @@ -395,11 +397,23 @@ function snapshot(input: { readonly effectiveEndpoint: string; readonly connectionOwnership?: HermesServeOwnership; }): ServerProvider { - const models = hermesProviderModels( + const customEntries = readCustomModelEntries(input.settings.customModels); + const baseModels = hermesProviderModels( input.inventory?.models, input.inventory?.reasoning, input.inventory?.fast, - ["default", ...input.settings.customModels], + ["default", ...customEntries.map((entry) => entry.slug)], + ).map((model) => { + if (!model.isCustom) return model; + const entry = customEntries.find((entry) => entry.slug === model.slug); + return entry + ? { ...model, name: entry.name, capabilities: entry.capabilities ?? model.capabilities } + : model; + }); + const models = providerModelsFromSettings( + baseModels, + input.settings.customModels, + baseModels.find((model) => model.slug === "default")?.capabilities ?? { optionDescriptors: [] }, ); const hasProfileKey = input.settings.profileKey.trim().length > 0; const connectionSecurity = input.effectiveEndpoint diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e54a4b17e..49b9e7362 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,3 +1,5 @@ +import { type CustomModelSetting } from "@t3tools/contracts"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { codexUsageLimits, unavailableUsageLimits, @@ -231,7 +233,7 @@ export function applyPreferredCodexDefaultModel( function appendCustomCodexModels( models: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, ): ReadonlyArray { if (customModels.length === 0) { return models; @@ -240,17 +242,16 @@ function appendCustomCodexModels( const seen = new Set(models.map((model) => model.slug)); const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; const customEntries: ServerProviderModel[] = []; - for (const rawModel of customModels) { - const slug = rawModel.trim(); - if (!slug || seen.has(slug)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(slug); + seen.add(entry.slug); customEntries.push({ - slug, - name: slug, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: fallbackCapabilities, + capabilities: entry.capabilities ?? fallbackCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; @@ -328,7 +329,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; + readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, @@ -426,21 +427,8 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); -const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { - const models = new Set(); - for (const model of codexSettings.customModels) { - const trimmed = model.trim(); - if (trimmed.length > 0) { - models.add(trimmed); - } - } - return Array.from(models, (model) => ({ - slug: model, - name: model, - isCustom: true, - capabilities: null, - })); -}; +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => + appendCustomCodexModels([], codexSettings.customModels); const makePendingCodexProvider = ( codexSettings: CodexSettings, @@ -518,7 +506,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 9763f1f98..909297488 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,3 +1,4 @@ +import type { CustomModelSetting } from "@t3tools/contracts"; import { type GrokSettings, type ModelCapabilities, @@ -96,7 +97,7 @@ export function buildInitialGrokProviderSnapshot( } function grokModelsFromSettings( - customModels: ReadonlyArray | undefined, + customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 011572780..e4d451dfb 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -35,6 +35,30 @@ const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabil }); describe("providerModelsFromSettings", () => { + it("publishes custom names and replaces options without overriding built-in models", () => { + const defaults = createModelCapabilities({ optionDescriptors: [] }); + const builtIn = { slug: "built-in", name: "Official", isCustom: false, capabilities: defaults }; + const models = providerModelsFromSettings( + [builtIn], + [ + { slug: "built-in", name: "Override", capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES }, + { slug: "private", name: "My model", capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES }, + "legacy", + ], + defaults, + ); + expect(models).toEqual([ + builtIn, + { + slug: "private", + name: "My model", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + { slug: "legacy", name: "legacy", isCustom: true, capabilities: defaults }, + ]); + }); + it("applies the provided capabilities to custom models", () => { const models = providerModelsFromSettings( [], diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index fbc2762ce..3f015b40a 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + CustomModelSetting, ProviderDriverKind, ModelCapabilities, ServerProvider, @@ -13,7 +14,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -141,24 +142,23 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); const customEntries: ServerProviderModel[] = []; - for (const candidate of customModels) { - const normalized = normalizeCustomModelSlug(candidate); - if (!normalized || seen.has(normalized)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(normalized); + seen.add(entry.slug); customEntries.push({ - slug: normalized, - name: normalized, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: customModelCapabilities, + capabilities: entry.capabilities ?? customModelCapabilities, }); } diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index d4d9132fb..3ebd70140 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -4186,7 +4186,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, connectionDetail: environmentConnectionDetails[environment.id], supportsPullRequests: environment.descriptor?.capabilities.pullRequests, machineKind: serverConfigsByEnvironmentID[environment.id]?.settings?.environmentIcon.flatMap(EnvironmentMachineKind.init(rawValue:))?.rawValue ?? environment.descriptor?.platform.machine, - supportsEnvironmentIcon: environment.descriptor?.capabilities.environmentIcon + supportsEnvironmentIcon: environment.descriptor?.capabilities.environmentIcon, + supportsCustomModelDefinitions: environment.descriptor?.capabilities.customModelDefinitions ) } @@ -6323,6 +6324,10 @@ extension NativeFeatureClient: FeatureServerSettingsManaging { environmentID: String, patch: ServerSettingsPatchInput ) async throws -> FeatureEnvironmentPreferences { + if patch.providerInstances != nil || patch.customModelsByDriver != nil, + (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.customModelDefinitions != true { + throw FeatureCapabilityUnavailable("Custom model definitions") + } if patch.environmentIcon != nil, (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.environmentIcon != true { throw FeatureCapabilityUnavailable("Environment icons") diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index b66d9e75f..8d7063c95 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -55,6 +55,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public struct FileAttachments: Codable, Equatable, Sendable { public let maxUploadBytes: Int } public let attachmentUploads: Bool? public let fileAttachments: FileAttachments? + public let customModelDefinitions: Bool? public let environmentIcon: Bool? public let usagePriceOverrides: Bool? public let pullRequestStackActions: Bool? @@ -72,6 +73,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadPullRequestLinking case threadPullRequestsV2 case attachmentUploads, fileAttachments + case customModelDefinitions case environmentIcon case usagePriceOverrides case pullRequestStackActions @@ -97,6 +99,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) fileAttachments = try container.decodeIfPresent(FileAttachments.self, forKey: .fileAttachments) + customModelDefinitions = try container.decodeIfPresent(Bool.self, forKey: .customModelDefinitions) environmentIcon = try container.decodeIfPresent(Bool.self, forKey: .environmentIcon) usagePriceOverrides = try container.decodeIfPresent(Bool.self, forKey: .usagePriceOverrides) pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift index 5d1277db9..fdcc9312f 100644 --- a/apps/swift-ios/Core/ServerConfigModels.swift +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -261,6 +261,9 @@ public struct UsageModelPriceOverride: Codable, Equatable, Sendable { } public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { + /// Opaque envelopes preserve unknown driver fields while editing one account's models. + public let providerInstances: [String: JSONValue] + public let providerDefinitions: [String: JSONValue] public let environmentIcon: String? public let usagePriceOverrides: [String: UsageModelPriceOverride]? /// The default window matching `DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS` in @@ -301,6 +304,8 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public let defaultThemeSetAt: String public init( + providerInstances: [String: JSONValue] = [:], + providerDefinitions: [String: JSONValue] = [:], environmentIcon: String? = nil, usagePriceOverrides: [String: UsageModelPriceOverride]? = nil, defaultThreadEnvMode: ServerThreadEnvironmentMode = .local, @@ -316,6 +321,8 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { defaultTheme: String = "", defaultThemeSetAt: String = "" ) { + self.providerInstances = providerInstances + self.providerDefinitions = providerDefinitions.isEmpty ? ["claudeAgent": .object(["autoCompactWindow": .string(claudeAutoCompactWindow)])] : providerDefinitions self.environmentIcon = environmentIcon self.usagePriceOverrides = usagePriceOverrides self.defaultThreadEnvMode = defaultThreadEnvMode @@ -330,6 +337,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { } private enum CodingKeys: String, CodingKey { + case providerInstances case environmentIcon case usagePriceOverrides case defaultThreadEnvMode @@ -356,6 +364,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + providerInstances = try container.decodeIfPresent([String: JSONValue].self, forKey: .providerInstances) ?? [:] environmentIcon = try container.decodeIfPresent(String.self, forKey: .environmentIcon) usagePriceOverrides = try container.decodeIfPresent([String: UsageModelPriceOverride].self, forKey: .usagePriceOverrides) defaultThreadEnvMode = try container.decode( @@ -385,6 +394,8 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { ProvidersContainer.self, forKey: .providers )?.claudeAgent?.autoCompactWindow ?? "" + let rawProviders = try container.decodeIfPresent([String: JSONValue].self, forKey: .providers) ?? [:] + providerDefinitions = rawProviders.isEmpty ? ["claudeAgent": .object(["autoCompactWindow": .string(claudeAutoCompactWindow)])] : rawProviders defaultTheme = try container.decodeIfPresent(String.self, forKey: .defaultTheme) ?? "" defaultThemeSetAt = try container.decodeIfPresent( String.self, @@ -394,6 +405,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(providerInstances, forKey: .providerInstances) try container.encodeIfPresent(environmentIcon, forKey: .environmentIcon) try container.encodeIfPresent(usagePriceOverrides, forKey: .usagePriceOverrides) try container.encode(defaultThreadEnvMode, forKey: .defaultThreadEnvMode) @@ -406,10 +418,9 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { try container.encode(enableAgentBrowserAccess, forKey: .enableAgentBrowserAccess) // Round-tripped under the same nested key the server sends, so an // encoded snapshot decodes back to itself. - try container.encode( - ProvidersContainer(claudeAgent: .init(autoCompactWindow: claudeAutoCompactWindow)), - forKey: .providers - ) + if providerDefinitions.isEmpty { + try container.encode(ProvidersContainer(claudeAgent: .init(autoCompactWindow: claudeAutoCompactWindow)), forKey: .providers) + } else { try container.encode(providerDefinitions, forKey: .providers) } try container.encode(defaultTheme, forKey: .defaultTheme) try container.encode(defaultThemeSetAt, forKey: .defaultThemeSetAt) } @@ -423,6 +434,8 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { /// whatever another client changed in between. Add a field here — and one line /// to `json` — as each new server setting reaches this client. public struct ServerSettingsPatchInput: Equatable, Sendable { + public var providerInstances: [String: JSONValue]? + public var customModelsByDriver: [String: [JSONValue]]? /// A present nil entry resets one model. Omitted models are unchanged. public var environmentIcon: String?? public var usagePriceOverrides: [String: UsageModelPriceOverride?]? @@ -434,12 +447,16 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { public var hiddenModelsByProvider: [String: [String]]? public init( + providerInstances: [String: JSONValue]? = nil, + customModelsByDriver: [String: [JSONValue]]? = nil, environmentIcon: String?? = nil, usagePriceOverrides: [String: UsageModelPriceOverride?]? = nil, enableAgentBrowserAccess: Bool? = nil, claudeAutoCompactWindow: String? = nil, hiddenModelsByProvider: [String: [String]]? = nil ) { + self.providerInstances = providerInstances + self.customModelsByDriver = customModelsByDriver self.environmentIcon = environmentIcon self.usagePriceOverrides = usagePriceOverrides self.enableAgentBrowserAccess = enableAgentBrowserAccess @@ -462,6 +479,18 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { ]), ]) } + if let providerInstances { fields["providerInstances"] = .object(providerInstances) } + if let customModelsByDriver { + var providers: [String: JSONValue] = [:] + if case let .object(existing) = fields["providers"] { providers = existing } + for (driver, models) in customModelsByDriver { + var config: [String: JSONValue] = [:] + if case let .object(existing) = providers[driver] { config = existing } + config["customModels"] = .array(models) + providers[driver] = .object(config) + } + fields["providers"] = .object(providers) + } if let usagePriceOverrides { fields["usagePriceOverrides"] = .object(usagePriceOverrides.mapValues { $0?.json ?? .null }) } if let hiddenModelsByProvider { fields["providerModelPreferences"] = .object(hiddenModelsByProvider.mapValues { diff --git a/apps/swift-ios/Features/Settings/CustomModelDefinition.swift b/apps/swift-ios/Features/Settings/CustomModelDefinition.swift new file mode 100644 index 000000000..1e0b41d6b --- /dev/null +++ b/apps/swift-ios/Features/Settings/CustomModelDefinition.swift @@ -0,0 +1,174 @@ +import Foundation + +struct NativeCustomModelDefinition: Identifiable, Equatable { + var id: String { slug } + var slug: String + var name: String + var capabilities: ServerModelCapabilities? + var original: JSONValue? + + static func read(_ value: JSONValue) -> Self? { + let slug = (value.stringValue ?? value["slug"]?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !slug.isEmpty else { return nil } + let label = value["name"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + return Self(slug: slug, name: label?.isEmpty == false ? label! : slug, + capabilities: try? value["capabilities"]?.decode(ServerModelCapabilities.self), original: value) + } + + static func readEntries(_ values: [JSONValue]) -> [Self] { + var seen = Set() + return values.compactMap { value in + guard let entry = read(value), seen.insert(entry.slug).inserted else { return nil } + return entry + } + } + + var json: JSONValue { + var fields: [String: JSONValue] = [:] + if case let .object(existing) = original { fields = existing } + fields["slug"] = .string(slug) + fields["name"] = name == slug ? nil : .string(name) + fields["capabilities"] = capabilities?.optionDescriptors?.isEmpty == false ? try? .encode(capabilities) : nil + return fields.count == 1 ? .string(slug) : .object(fields) + } +} + +/// Only the edited account's custom-model array changes. Unknown envelopes and entries survive. +enum NativeCustomModelSettings { + static func entries(settings: ServerSettingsSnapshot, instanceID: String, driver: String) -> [JSONValue] { + if case let .array(entries) = settings.providerInstances[instanceID]?["config"]?["customModels"] { return entries } + if instanceID == driver, case let .array(entries) = settings.providerDefinitions[driver]?["customModels"] { return entries } + return [] + } + + static func patch(settings: ServerSettingsSnapshot, instanceID: String, driver: String, + entries: [JSONValue]) throws -> ServerSettingsPatchInput { + if let instance = settings.providerInstances[instanceID] { + guard case var .object(envelope) = instance else { throw CustomModelEditError.invalidAccount } + var config: [String: JSONValue] = [:] + if let value = envelope["config"], value != .null { + guard case let .object(existing) = value else { throw CustomModelEditError.invalidAccount } + config = existing + } + config["customModels"] = .array(entries) + envelope["config"] = .object(config) + var instances = settings.providerInstances + instances[instanceID] = .object(envelope) + return ServerSettingsPatchInput(providerInstances: instances) + } + guard instanceID == driver, settings.providerDefinitions[driver] != nil else { throw CustomModelEditError.invalidAccount } + return ServerSettingsPatchInput(customModelsByDriver: [driver: entries]) + } +} + +enum CustomModelEditError: LocalizedError { + case invalidAccount, duplicate, invalidDraft(String) + var errorDescription: String? { + switch self { + case .invalidAccount: "This account's settings are unavailable. Refresh its configuration before editing." + case .duplicate: "That model ID already exists." + case .invalidDraft(let message): message + } + } +} + +struct NativeCustomModelChoice: Identifiable, Equatable { + var id = UUID() + var value = "" + var label = "" + var isDefault = false + var description: String? +} + +struct NativeCustomModelOption: Identifiable, Equatable { + var id = UUID() + var kind = "select" + var optionID = "" + var label = "" + var choices: [NativeCustomModelChoice] = [] + var currentBooleanValue: Bool? + var description: String? +} + +struct NativeCustomModelDraft: Equatable { + var slug = "" + var name = "" + var options: [NativeCustomModelOption] = [] + var original: JSONValue? + + init(definition: NativeCustomModelDefinition? = nil) { + guard let definition else { return } + slug = definition.slug + name = definition.name == definition.slug ? "" : definition.name + original = definition.original + options = Self.options(from: definition.capabilities) + } + + static func options(from capabilities: ServerModelCapabilities?, driver: String? = nil) -> [NativeCustomModelOption] { + (capabilities?.optionDescriptors ?? []).compactMap { descriptor in + switch descriptor { + case .boolean(let value): + return NativeCustomModelOption(kind: "boolean", optionID: value.id, label: value.label, + currentBooleanValue: value.currentValue, description: value.description) + case .select(let value): + if driver == "claudeAgent" && value.id == "contextWindow" { return nil } + let choices = value.options.filter { !(value.promptInjectedValues ?? []).contains($0.id) } + let selected = choices.first { $0.id == value.currentValue } ?? choices.first { $0.isDefault == true } + return NativeCustomModelOption(optionID: value.id, label: value.label, + choices: choices.map { NativeCustomModelChoice(value: $0.id, label: $0.label, isDefault: $0.id == selected?.id, description: $0.description) }, + description: value.description) + } + } + } + + static func presets(driver: String) -> [NativeCustomModelOption] { + func choices(_ values: [(String, String)], defaultValue: String) -> [NativeCustomModelChoice] { + values.map { NativeCustomModelChoice(value: $0.0, label: $0.1, isDefault: $0.0 == defaultValue) } + } + let effort = choices([("low", "Low"), ("medium", "Medium"), ("high", "High"), ("xhigh", "Extra High")], defaultValue: "medium") + let fast = NativeCustomModelOption(kind: "boolean", optionID: "fastMode", label: "Fast Mode") + let thinking = NativeCustomModelOption(kind: "boolean", optionID: "thinking", label: "Thinking") + switch driver { + case "codex": return [ + NativeCustomModelOption(optionID: "reasoningEffort", label: "Reasoning", choices: effort), + NativeCustomModelOption(optionID: "serviceTier", label: "Speed", choices: choices([("default", "Standard"), ("fast", "Fast")], defaultValue: "default")) + ] + case "claudeAgent": return [ + NativeCustomModelOption(optionID: "effort", label: "Reasoning", choices: choices([("low", "Low"), ("medium", "Medium"), ("high", "High"), ("xhigh", "Extra High"), ("max", "Max")], defaultValue: "high")), fast, thinking + ] + case "cursor": return [NativeCustomModelOption(optionID: "reasoning", label: "Reasoning", choices: effort), fast, thinking] + case "grok": return [NativeCustomModelOption(optionID: "reasoningEffort", label: "Reasoning", choices: effort)] + case "opencode": return [NativeCustomModelOption(optionID: "variant", label: "Reasoning", choices: effort), + NativeCustomModelOption(optionID: "agent", label: "Agent", choices: choices([("build", "Build"), ("plan", "Plan")], defaultValue: "build"))] + default: return [] + } + } + + func definition() throws -> NativeCustomModelDefinition { + let slug = slug.trimmingCharacters(in: .whitespacesAndNewlines) + guard !slug.isEmpty, slug.utf16.count <= 256 else { throw CustomModelEditError.invalidDraft("Enter a model ID of 1–256 characters.") } + var ids = Set() + let descriptors: [ServerProviderOptionDescriptor] = try options.enumerated().map { index, option in + let id = option.optionID.trimmingCharacters(in: .whitespacesAndNewlines) + let label = option.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty, ids.insert(id).inserted else { throw CustomModelEditError.invalidDraft("Option \(index + 1) needs a unique ID and a label.") } + if option.kind == "boolean" { + return .boolean(ServerBooleanOptionDescriptor(id: id, label: label, description: option.description, currentValue: option.currentBooleanValue)) + } + guard !option.choices.isEmpty else { throw CustomModelEditError.invalidDraft("Option \(index + 1) needs at least one choice.") } + var choiceIDs = Set() + let choices: [ServerProviderOptionChoice] = try option.choices.map { choice in + let value = choice.value.trimmingCharacters(in: .whitespacesAndNewlines) + let choiceLabel = choice.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, choiceIDs.insert(value).inserted else { throw CustomModelEditError.invalidDraft("Choice values must be non-empty and unique within each option.") } + return ServerProviderOptionChoice(id: value, label: choiceLabel.isEmpty ? value : choiceLabel, description: choice.description, isDefault: choice.isDefault ? true : nil) + } + guard choices.filter({ $0.isDefault == true }).count <= 1 else { throw CustomModelEditError.invalidDraft("Choose only one default per option.") } + return .select(ServerSelectOptionDescriptor(id: id, label: label, description: option.description, options: choices, + currentValue: choices.first { $0.isDefault == true }?.id, promptInjectedValues: nil)) + } + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + return NativeCustomModelDefinition(slug: slug, name: name.isEmpty ? slug : name, + capabilities: descriptors.isEmpty ? nil : ServerModelCapabilities(optionDescriptors: descriptors), original: original) + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsAgentsView.swift b/apps/swift-ios/Features/Settings/SettingsAgentsView.swift index 4f05a37bc..80b01115a 100644 --- a/apps/swift-ios/Features/Settings/SettingsAgentsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsAgentsView.swift @@ -170,6 +170,12 @@ public struct SettingsAgentsView: View { } } }.disabled(isSavingModels) + SettingsSection(title: "Custom models") { + NavigationLink { + SettingsCustomModelsView(manager: serverSettings, environmentID: environmentID, provider: provider, + supported: environments.first { $0.id == environmentID }?.supportsCustomModelDefinitions == true) + } label: { SettingsNavigationRow(title: "Names and options", systemImage: "slider.horizontal.3") } + } if isSavingModels { ProgressView("Saving models…") } }.padding(18) }.background(T3Colors.background).navigationTitle(provider.displayName ?? provider.driver).navigationBarTitleDisplayMode(.inline) diff --git a/apps/swift-ios/Features/Settings/SettingsCustomModelsView.swift b/apps/swift-ios/Features/Settings/SettingsCustomModelsView.swift new file mode 100644 index 000000000..ef38360cc --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsCustomModelsView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +struct SettingsCustomModelsView: View { + let manager: any FeatureServerSettingsManaging + let environmentID: String + let provider: ServerProviderSnapshot + let supported: Bool + @State private var entries: [JSONValue] = [] + @State private var builtInModels: [ServerProviderModelSnapshot] = [] + @State private var loading = true + @State private var pending = false + @State private var errorMessage: String? + @State private var editing: NativeCustomModelDefinition? + @State private var adding = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let errorMessage { SettingsErrorBanner(message: errorMessage) } + if loading { ProgressView().frame(maxWidth: .infinity) } + Text("Use a model ID your provider supports. A display name changes its label in the picker. Custom options replace the provider's defaults for this model.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + SettingsSection(title: "Custom models") { + ForEach(NativeCustomModelDefinition.readEntries(entries)) { entry in + HStack(spacing: 12) { + Button { editing = entry } label: { + VStack(alignment: .leading, spacing: 3) { + Text(entry.name).font(T3Typography.supportingStrong).foregroundStyle(T3Colors.textPrimary) + Text(entry.slug).font(.caption.monospaced()).foregroundStyle(T3Colors.textSecondary) + }.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + }.buttonStyle(.plain).disabled(!supported) + Button(role: .destructive) { Task { await remove(entry) } } label: { + Image(systemName: "trash").frame(minWidth: T3Metrics.minimumTapTarget, minHeight: T3Metrics.minimumTapTarget) + }.accessibilityLabel("Remove \(entry.name)").disabled(!supported) + }.padding(.leading, SettingsMetrics.rowPadding).frame(minHeight: T3Metrics.minimumTapTarget) + } + if entries.isEmpty && !loading { Text("No custom models.").font(T3Typography.supporting).padding(SettingsMetrics.rowPadding) } + Button { adding = true } label: { Label("Add custom model", systemImage: "plus").frame(minHeight: T3Metrics.minimumTapTarget) } + .disabled(!supported || loading) + }.disabled(pending) + if !supported { Text("Update this server to edit custom model definitions.").font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) } + if pending { ProgressView("Saving models…") } + }.padding(18) + } + .background(T3Colors.background).navigationTitle("Custom models").navigationBarTitleDisplayMode(.inline) + .task(id: environmentID) { await load() } + .sheet(item: $editing) { entry in + NavigationStack { NativeCustomModelEditor(definition: entry, driver: provider.driver, builtInModels: builtInModels) { updated in + try await save(updated, replacing: entry.slug) + } } + } + .sheet(isPresented: $adding) { + NavigationStack { NativeCustomModelEditor(definition: nil, driver: provider.driver, builtInModels: builtInModels) { updated in + try await save(updated, replacing: nil) + } } + } + } + + private func load() async { + loading = true + defer { loading = false } + do { + let config = try await manager.providerModelConfiguration(environmentID: environmentID) + guard !Task.isCancelled, let settings = config.settings else { return } + entries = NativeCustomModelSettings.entries(settings: settings, instanceID: provider.instanceId, driver: provider.driver) + builtInModels = (config.providers.first { $0.instanceId == provider.instanceId }?.models ?? []).filter { !$0.isCustom } + } catch { if !Task.isCancelled { errorMessage = error.localizedDescription } } + } + + private func save(_ entry: NativeCustomModelDefinition, replacing slug: String?) async throws { + guard supported, !pending else { throw FeatureCapabilityUnavailable("Custom model definitions") } + pending = true + defer { pending = false } + let config = try await manager.providerModelConfiguration(environmentID: environmentID) + guard let settings = config.settings else { throw CustomModelEditError.invalidAccount } + var next = NativeCustomModelSettings.entries(settings: settings, instanceID: provider.instanceId, driver: provider.driver) + if let slug { + guard let index = next.firstIndex(where: { NativeCustomModelDefinition.read($0)?.slug == slug }) else { + throw CustomModelEditError.invalidDraft("This custom model was removed. Refresh the account to continue.") + } + next[index] = entry.json + } else { + let live = config.providers.first { $0.instanceId == provider.instanceId }?.models ?? [] + guard !next.contains(where: { NativeCustomModelDefinition.read($0)?.slug == entry.slug }), + !live.contains(where: { !$0.isCustom && $0.slug == entry.slug }) else { throw CustomModelEditError.duplicate } + next.append(entry.json) + } + try await manager.updateServerSettings(environmentID: environmentID, + patch: NativeCustomModelSettings.patch(settings: settings, instanceID: provider.instanceId, driver: provider.driver, entries: next)) + entries = next + errorMessage = nil + } + + private func remove(_ entry: NativeCustomModelDefinition) async { + guard supported, !pending else { return } + pending = true + defer { pending = false } + do { + let config = try await manager.providerModelConfiguration(environmentID: environmentID) + guard let settings = config.settings else { throw CustomModelEditError.invalidAccount } + let next = NativeCustomModelSettings.entries(settings: settings, instanceID: provider.instanceId, driver: provider.driver) + .filter { NativeCustomModelDefinition.read($0)?.slug != entry.slug } + try await manager.updateServerSettings(environmentID: environmentID, + patch: NativeCustomModelSettings.patch(settings: settings, instanceID: provider.instanceId, driver: provider.driver, entries: next)) + entries = next + errorMessage = nil + } catch { errorMessage = error.localizedDescription } + } +} + +struct NativeCustomModelEditor: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let definition: NativeCustomModelDefinition? + let driver: String + let builtInModels: [ServerProviderModelSnapshot] + let save: (NativeCustomModelDefinition) async throws -> Void + @State private var draft: NativeCustomModelDraft + @State private var saving = false + @State private var errorMessage: String? + + init(definition: NativeCustomModelDefinition?, driver: String, builtInModels: [ServerProviderModelSnapshot], + save: @escaping (NativeCustomModelDefinition) async throws -> Void) { + self.definition = definition + self.driver = driver + self.builtInModels = builtInModels + self.save = save + _draft = State(initialValue: NativeCustomModelDraft(definition: definition)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let errorMessage { SettingsErrorBanner(message: errorMessage) } + VStack(alignment: .leading, spacing: 10) { + TextField("Model ID", text: $draft.slug).disabled(definition != nil).textInputAutocapitalization(.never).autocorrectionDisabled() + TextField("Display name (optional)", text: $draft.name).autocorrectionDisabled() + }.textFieldStyle(.roundedBorder) + if !builtInModels.isEmpty { + Menu("Copy options from a model") { + ForEach(builtInModels) { model in + Button(model.name) { draft.options = NativeCustomModelDraft.options(from: model.capabilities, driver: driver) } + } + }.frame(minHeight: T3Metrics.minimumTapTarget) + } + if !NativeCustomModelDraft.presets(driver: driver).isEmpty { + Menu("Add a provider option") { + ForEach(NativeCustomModelDraft.presets(driver: driver)) { preset in + Button(preset.label) { draft.options.append(preset) } + .disabled(draft.options.contains { $0.optionID == preset.optionID }) + } + }.frame(minHeight: T3Metrics.minimumTapTarget) + } + Text("Options shown in the composer").font(T3Typography.supportingStrong) + if draft.options.isEmpty { Text("No custom options. The provider's defaults apply.").font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) } + ForEach($draft.options) { $option in + NativeCustomModelOptionEditor(option: $option) { draft.options.removeAll { $0.id == option.id } } + } + Button { draft.options.append(NativeCustomModelOption()) } label: { Label("Add option", systemImage: "plus").frame(minHeight: T3Metrics.minimumTapTarget) } + Text("Use option IDs supported by this provider. Other IDs are saved but may be ignored when the model runs.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + }.padding(18).disabled(saving) + } + .background(T3Colors.background).navigationTitle(definition == nil ? "Add model" : "Edit model").navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(saving) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.disabled(saving) } + ToolbarItem(placement: .confirmationAction) { + Button(saving ? "Saving…" : "Save") { Task { await commit() } }.disabled(saving) + } + } + } + + private func commit() async { + guard !saving else { return } + do { + let entry = try draft.definition() + saving = true + defer { saving = false } + try await save(entry) + dismiss() + } catch { errorMessage = error.localizedDescription } + } +} + +private struct NativeCustomModelOptionEditor: View { + @Binding var option: NativeCustomModelOption + let remove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField("Option ID", text: $option.optionID).textInputAutocapitalization(.never).autocorrectionDisabled() + Button(role: .destructive, action: remove) { Image(systemName: "trash").frame(minWidth: T3Metrics.minimumTapTarget, minHeight: T3Metrics.minimumTapTarget) } + .accessibilityLabel("Remove option") + } + TextField("Label", text: $option.label) + Picker("Type", selection: $option.kind) { Text("Choices").tag("select"); Text("Toggle").tag("boolean") }.pickerStyle(.segmented) + if option.kind == "boolean" { + Toggle("Default on", isOn: Binding(get: { option.currentBooleanValue ?? false }, set: { option.currentBooleanValue = $0 })) + } else { + ForEach($option.choices) { $choice in + VStack(alignment: .leading, spacing: 8) { + TextField("Choice value", text: $choice.value).textInputAutocapitalization(.never).autocorrectionDisabled() + TextField("Choice label", text: $choice.label) + HStack { + Toggle("Default", isOn: Binding(get: { choice.isDefault }, set: { selected in + for index in option.choices.indices { option.choices[index].isDefault = selected && option.choices[index].id == choice.id } + })) + Button(role: .destructive) { option.choices.removeAll { $0.id == choice.id } } label: { + Image(systemName: "minus.circle").frame(minWidth: T3Metrics.minimumTapTarget, minHeight: T3Metrics.minimumTapTarget) + }.accessibilityLabel("Remove choice") + } + }.padding(10).background(T3Colors.surfaceRaised, in: RoundedRectangle(cornerRadius: 10)) + } + Button { option.choices.append(NativeCustomModelChoice()) } label: { Label("Add choice", systemImage: "plus").frame(minHeight: T3Metrics.minimumTapTarget) } + } + }.textFieldStyle(.roundedBorder).padding(14).background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 1235cc1c9..f8aecd3f1 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -41,6 +41,7 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C public var supportsPullRequests: Bool? public var machineKind: String? public var supportsEnvironmentIcon: Bool? + public var supportsCustomModelDefinitions: Bool? = nil public var machineSymbol: String { EnvironmentMachineKind(rawValue: machineKind ?? "")?.symbol ?? "server.rack" } public init( @@ -52,7 +53,8 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C connectionDetail: String? = nil, supportsPullRequests: Bool? = nil, machineKind: String? = nil, - supportsEnvironmentIcon: Bool? = nil + supportsEnvironmentIcon: Bool? = nil, + supportsCustomModelDefinitions: Bool? = nil ) { self.id = id self.name = name @@ -63,6 +65,7 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C self.supportsPullRequests = supportsPullRequests self.machineKind = machineKind self.supportsEnvironmentIcon = supportsEnvironmentIcon + self.supportsCustomModelDefinitions = supportsCustomModelDefinitions } } diff --git a/apps/swift-ios/Tests/CoreTests/CustomModelDefinitionTests.swift b/apps/swift-ios/Tests/CoreTests/CustomModelDefinitionTests.swift new file mode 100644 index 000000000..feb183777 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/CustomModelDefinitionTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import T3Code + +struct CustomModelDefinitionTests { + @Test func contractFixtureSurvivesEditing() throws { + let fixture = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Fixtures/customModels.json") + let values = try JSONDecoder().decode([JSONValue].self, from: Data(contentsOf: fixture)) + let models = NativeCustomModelDefinition.readEntries(values) + #expect(models.count == 2) + #expect(models[0].json == .string("legacy-model")) + let draft = NativeCustomModelDraft(definition: models[1]) + #expect(draft.options.count == 2) + #expect(try draft.definition().json == values[1]) + } + + @Test func accountPatchPreservesOtherAccountsAndUnknownConfiguration() throws { + let target: JSONValue = .object(["driver": .string("codex"), "name": .string("Work"), + "config": .object(["binaryPath": .string("/tools/codex"), "futureFlag": .bool(true), "customModels": .array([.string("old")])])]) + let other: JSONValue = .object(["driver": .string("claudeAgent"), "config": .object(["autoCompactWindow": .string("300000")])]) + let settings = ServerSettingsSnapshot(providerInstances: ["work": target, "other": other]) + let patch = try NativeCustomModelSettings.patch(settings: settings, instanceID: "work", driver: "codex", entries: [.string("new")]) + #expect(patch.json["providerInstances"]?["other"] == other) + #expect(patch.json["providerInstances"]?["work"]?["name"] == .string("Work")) + #expect(patch.json["providerInstances"]?["work"]?["config"]?["futureFlag"] == .bool(true)) + #expect(patch.json["providerInstances"]?["work"]?["config"]?["binaryPath"] == .string("/tools/codex")) + #expect(patch.json["providerInstances"]?["work"]?["config"]?["customModels"] == .array([.string("new")])) + let encoded = try JSONEncoder().encode(settings) + #expect(try JSONDecoder().decode(ServerSettingsSnapshot.self, from: encoded) == settings) + } + + @Test func legacyPatchOnlyNamesChangedLeaves() throws { + let settings = ServerSettingsSnapshot(providerDefinitions: ["codex": .object(["binaryPath": .string("custom")])]) + let patch = try NativeCustomModelSettings.patch(settings: settings, instanceID: "codex", driver: "codex", entries: [.string("new")]) + #expect(patch.json == .object(["providers": .object(["codex": .object(["customModels": .array([.string("new")])])])])) + #expect(throws: CustomModelEditError.self) { + try NativeCustomModelSettings.patch(settings: settings, instanceID: "missing", driver: "codex", entries: []) + } + } + + @Test func duplicateChoicesCannotBeSavedAndUnknownFieldsSurvive() throws { + let model = NativeCustomModelDefinition.read(.object(["slug": .string("model"), "futureField": .bool(true)]))! + var draft = NativeCustomModelDraft(definition: model) + draft.name = "Friendly" + #expect(try draft.definition().json["futureField"] == .bool(true)) + draft.options = [NativeCustomModelOption(optionID: "effort", label: "Reasoning", choices: [ + NativeCustomModelChoice(value: "high", label: "High"), NativeCustomModelChoice(value: " high ", label: "Duplicate") + ])] + #expect(throws: CustomModelEditError.self) { try draft.definition() } + #expect(NativeCustomModelDefinition.readEntries([.string(" model "), .object(["slug": .string("model"), "name": .string("Duplicate")])]).count == 1) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/customModels.json b/apps/swift-ios/Tests/CoreTests/Fixtures/customModels.json new file mode 100644 index 000000000..1972d20ed --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/customModels.json @@ -0,0 +1,30 @@ +[ + "legacy-model", + { + "slug": "private/model", + "name": "Private model", + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "high", + "label": "High", + "isDefault": true + } + ], + "currentValue": "high" + }, + { + "id": "thinking", + "label": "Thinking", + "type": "boolean", + "currentValue": true + } + ] + } + } +] diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json b/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json index f53ec04dc..669567432 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json @@ -9,6 +9,7 @@ "serverVersion": "0.0.38", "capabilities": { "repositoryIdentity": true, - "environmentIcon": true + "environmentIcon": true, + "customModelDefinitions": true } } diff --git a/apps/web/src/components/settings/CustomModelEditor.tsx b/apps/web/src/components/settings/CustomModelEditor.tsx new file mode 100644 index 000000000..aea975b81 --- /dev/null +++ b/apps/web/src/components/settings/CustomModelEditor.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { PlusIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import type { CustomModelDefinition } from "@t3tools/shared/model"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { + DESCRIPTOR_PRESETS_BY_KIND, + type CustomModelDraft, + type EditorChoice, + type EditorDescriptor, + choiceFromPreset, + definitionFromDraft, + descriptorFromPreset, + descriptorsFromCapabilities, + draftFromDefinition, + emptyEditorChoice, + emptyEditorDescriptor, + validateDraft, +} from "./customModelEditor.logic"; + +const CUSTOM_ID_VALUE = "__custom__"; +const START_FROM_NONE = "__none__"; + +interface CustomModelEditorProps { + readonly instanceId: string; + readonly driverKind: ProviderDriverKind | null; + readonly entry: CustomModelDefinition; + /** Built-in models whose descriptors can be copied as a starting point. */ + readonly builtInModels: ReadonlyArray; + readonly onSave: (next: CustomModelDefinition) => void; + readonly onCancel: () => void; +} + +/** + * Inline editor for one custom model: display name plus the option + * descriptors the composer should offer for it (Reasoning effort, Fast + * mode, ...). Draft state is local; nothing is persisted until Save. + */ +export function CustomModelEditor({ + instanceId, + driverKind, + entry, + builtInModels, + onSave, + onCancel, +}: CustomModelEditorProps) { + const [draft, setDraft] = useState(() => draftFromDefinition(entry)); + const [error, setError] = useState(null); + const presets = useMemo( + () => (driverKind ? (DESCRIPTOR_PRESETS_BY_KIND[driverKind] ?? []) : []), + [driverKind], + ); + const startFromCandidates = useMemo( + () => builtInModels.filter((model) => (model.capabilities?.optionDescriptors?.length ?? 0) > 0), + [builtInModels], + ); + const domId = (suffix: string) => `provider-instance-${instanceId}-custom-model-${suffix}`; + + const updateDescriptor = (key: string, patch: Partial) => { + setError(null); + setDraft((current) => ({ + ...current, + descriptors: current.descriptors.map((descriptor) => + descriptor.key === key ? { ...descriptor, ...patch } : descriptor, + ), + })); + }; + + const updateChoice = (descriptorKey: string, choiceKey: string, patch: Partial) => { + setError(null); + setDraft((current) => ({ + ...current, + descriptors: current.descriptors.map((descriptor) => { + if (descriptor.key !== descriptorKey) return descriptor; + return { + ...descriptor, + choices: descriptor.choices.map((choice) => { + if (choice.key === choiceKey) return { ...choice, ...patch }; + // Only one choice can be the default. + return patch.isDefault ? { ...choice, isDefault: false } : choice; + }), + }; + }), + })); + }; + + const removeDescriptor = (key: string) => { + setError(null); + setDraft((current) => ({ + ...current, + descriptors: current.descriptors.filter((descriptor) => descriptor.key !== key), + })); + }; + + const addDescriptor = (descriptor: EditorDescriptor) => { + setError(null); + setDraft((current) => ({ ...current, descriptors: [...current.descriptors, descriptor] })); + }; + + // Selecting a preset id replaces the descriptor's label/type/choices so the + // usual values are one click away; "Custom…" leaves the row blank to type into. + const applyPresetId = (descriptor: EditorDescriptor, value: string | null) => { + if (value === null) return; + if (value === CUSTOM_ID_VALUE) { + updateDescriptor(descriptor.key, { id: "" }); + return; + } + const preset = presets.find((candidate) => candidate.id === value); + if (!preset) return; + updateDescriptor(descriptor.key, { + id: preset.id, + label: preset.label, + type: preset.type, + choices: (preset.choices ?? []).map(choiceFromPreset), + currentBooleanValue: undefined, + description: undefined, + }); + }; + + const handleStartFrom = (slug: string | null) => { + if (slug === null || slug === START_FROM_NONE) return; + const model = startFromCandidates.find((candidate) => candidate.slug === slug); + if (!model) return; + setError(null); + setDraft((current) => ({ + ...current, + descriptors: descriptorsFromCapabilities(model.capabilities, driverKind), + })); + }; + + const handleSave = () => { + const problem = validateDraft(draft); + if (problem) { + setError(problem); + return; + } + onSave(definitionFromDraft(draft)); + }; + + const idSelectValue = (descriptor: EditorDescriptor) => + presets.some((preset) => preset.id === descriptor.id) ? descriptor.id : CUSTOM_ID_VALUE; + + const renderChoice = (descriptor: EditorDescriptor, choice: EditorChoice) => ( +
+ updateChoice(descriptor.key, choice.key, { id: event.target.value })} + placeholder="value" + className="w-20 font-mono sm:w-28" + spellCheck={false} + aria-label="Choice value" + /> + + updateChoice(descriptor.key, choice.key, { label: event.target.value }) + } + placeholder="Label" + className="min-w-0 flex-1" + aria-label="Choice label" + /> + + +
+ ); + + const renderDescriptor = (descriptor: EditorDescriptor, index: number) => ( +
+
+ Option {index + 1} + {presets.length > 0 ? ( + + ) : null} + {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? ( + updateDescriptor(descriptor.key, { id: event.target.value })} + placeholder="optionId" + className="w-36 font-mono" + spellCheck={false} + aria-label="Option id" + /> + ) : null} + updateDescriptor(descriptor.key, { label: event.target.value })} + placeholder="Label" + className="min-w-0 flex-1" + aria-label="Option label" + /> + + +
+ {descriptor.type === "select" ? ( +
+ {descriptor.choices.map((choice) => renderChoice(descriptor, choice))} + +
+ ) : null} +
+ ); + + return ( +
{ + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }} + > +
+ + setDraft((current) => ({ ...current, name: event.target.value }))} + placeholder={draft.slug} + className="sm:w-72" + spellCheck={false} + /> +
+ +
+
+ Options shown in the composer + {startFromCandidates.length > 0 ? ( + + ) : null} +
+ {draft.descriptors.length === 0 ? ( +

+ No custom options. The composer uses the provider's default options. +

+ ) : null} + {draft.descriptors.map(renderDescriptor)} +
+ {presets + .filter( + (preset) => !draft.descriptors.some((descriptor) => descriptor.id === preset.id), + ) + .map((preset) => ( + + ))} + +
+
+ + {error ?

{error}

: null} + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index ef2db0f03..5b2340206 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -34,7 +34,7 @@ describe("deriveProviderModelsForDisplay", () => { expect( deriveProviderModelsForDisplay({ liveModels, - customModels: ["kept-custom"], + customModels: [{ slug: "kept-custom", name: "kept-custom", capabilities: null }], }).map((model) => model.slug), ).toEqual(["server-model", "kept-custom"]); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index c278947ba..1e5cdbd73 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,5 +1,11 @@ "use client"; +import { + type CustomModelDefinition, + readCustomModelEntries, + toCustomModelSetting, +} from "@t3tools/shared/model"; + import { ArrowUpCircleIcon, ChevronDownIcon, @@ -78,17 +84,11 @@ function makeEnvironmentDraftRow( }; } -/** - * Read a string[] at `key` from the opaque config blob, filtering out - * non-string entries. Used for `customModels`, which is always typed as - * `string[]` by the concrete driver schemas but arrives here as - * `Schema.Unknown`. - */ -function readConfigStringArray(config: unknown, key: string): ReadonlyArray { - if (config === null || typeof config !== "object") return []; - const value = (config as Record)[key]; - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); +/** Read bare model IDs and structured definitions from opaque driver configuration. */ +function readConfigCustomModels(config: unknown): ReadonlyArray { + return config !== null && typeof config === "object" + ? readCustomModelEntries((config as Record).customModels) + : []; } /** @@ -161,7 +161,7 @@ export function nextProviderEnvironmentWithFieldValue( export function deriveProviderModelsForDisplay(input: { readonly liveModels: ReadonlyArray | undefined; - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; }): ReadonlyArray { const liveCustomModelsBySlug = new Map( Arr.filterMap(input.liveModels ?? [], (model) => @@ -169,15 +169,13 @@ export function deriveProviderModelsForDisplay(input: { ), ); const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; - const customModels = input.customModels.map( - (slug) => - liveCustomModelsBySlug.get(slug) ?? { - slug, - name: slug, - isCustom: true, - capabilities: null, - }, - ); + const customModels = input.customModels.map((entry) => ({ + slug: entry.slug, + name: entry.name, + isCustom: true, + capabilities: + entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null, + })); return [...serverModels, ...customModels]; } @@ -422,6 +420,7 @@ interface ProviderInstanceCardProps { readonly liveProvider: ServerProvider | undefined; /** Effective enabled state after any driver-wide rollout gate is applied. */ readonly effectiveEnabled?: boolean | undefined; + readonly supportsCustomModelDefinitions?: boolean; readonly mode?: "list" | "editor"; readonly selected?: boolean; readonly onSelect?: () => void; @@ -485,6 +484,7 @@ export function ProviderInstanceCard({ isExpanded, onExpandedChange, mode, + supportsCustomModelDefinitions = false, selected = false, onSelect, readOnly = false, @@ -549,7 +549,7 @@ export function ProviderInstanceCard({ ? instance.driver : null; - const customModels = readConfigStringArray(instance.config, "customModels"); + const customModels = readConfigCustomModels(instance.config); const environmentFields = driverOption?.environmentFields ?? []; const environmentFieldNames = new Set(environmentFields.map((field) => field.name)); const genericEnvironment = providerEnvironmentWithoutNames( @@ -597,8 +597,12 @@ export function ProviderInstanceCard({ ); }; - const updateCustomModels = (next: ReadonlyArray) => { - const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]); + const updateCustomModels = (next: ReadonlyArray) => { + const nextConfig = nextConfigBlobWithValue( + instance.config, + "customModels", + next.map(toCustomModelSetting), + ); const { config: _omit, ...rest } = instance; onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); }; @@ -973,6 +977,7 @@ export function ProviderInstanceCard({ driverKind={driverKind} models={modelsForDisplay} customModels={customModels} + supportsCustomModelDefinitions={supportsCustomModelDefinitions} hiddenModels={hiddenModels} favoriteModels={favoriteModels} modelOrder={modelOrder} diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index ae7270d62..a5750d437 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -1,30 +1,22 @@ "use client"; -import { - ArrowDownIcon, - ArrowUpIcon, - EyeIcon, - EyeOffIcon, - InfoIcon, - PlusIcon, - StarIcon, - XIcon, -} from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderDriverKind, type ProviderInstanceId, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { CustomModelEditor } from "./CustomModelEditor"; /** * Placeholder text for the "add a custom model" input, keyed by driver @@ -39,6 +31,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial + descriptor.id === "fastMode" || + (descriptor.id === "serviceTier" && + descriptor.type === "select" && + descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")), + ); + if (hasFastMode) labels.push("Fast mode"); + if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking"); + if ( + descriptors.some( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || + descriptor.id === "effort" || + descriptor.id === "reasoning" || + descriptor.id === "variant"), + ) + ) { + labels.push("Reasoning"); + } + return labels; +} + +/** + * Display order for the models list: favorites first (in user order), then + * visible models, then hidden ones. Hidden models sink so the list reads + * top-down as "what the picker shows"; moves only swap rows within the same + * group, and the resulting display order is what gets persisted as + * `modelOrder`. + */ +export function groupModelsForDisplay< + T extends { readonly slug: string; readonly isCustom: boolean }, +>( + models: ReadonlyArray, + options: { + readonly favoriteModels: ReadonlySet; + readonly hiddenModels: ReadonlySet; + readonly modelOrder: ReadonlyArray; + }, +): T[] { + const ordered = sortModelsForProviderInstance(models, { + favoriteModels: options.favoriteModels, + groupFavorites: true, + modelOrder: options.modelOrder, + }); + const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug); + return [ + ...ordered.filter((model) => options.favoriteModels.has(model.slug)), + ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)), + ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)), + ]; +} + export function nextHiddenModelsForBulkToggle( models: ReadonlyArray>, hiddenModels: ReadonlyArray, @@ -55,6 +113,7 @@ export function nextHiddenModelsForBulkToggle( } interface ProviderModelsSectionProps { + readonly supportsCustomModelDefinitions?: boolean; /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; /** @@ -68,11 +127,11 @@ interface ProviderModelsSectionProps { */ readonly models: ReadonlyArray; /** - * The persisted custom-model slug list for this instance. Drives dedup, - * and is the array we hand back verbatim (with the new slug appended / + * The persisted custom-model list for this instance, resolved. Drives + * dedup, and is the list we hand back (with an entry appended / replaced / * removed) via `onChange`. */ - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; /** Server-returned model slugs hidden from the model picker. */ readonly hiddenModels: ReadonlyArray; /** Model slugs favorited for this provider instance. */ @@ -84,7 +143,7 @@ interface ProviderModelsSectionProps { * write to the correct storage (legacy `settings.providers[kind]` vs. * `providerInstances[id].config`). */ - readonly onChange: (next: ReadonlyArray) => void; + readonly onChange: (next: ReadonlyArray) => void; readonly onHiddenModelsChange: (next: ReadonlyArray) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; @@ -103,6 +162,7 @@ interface ProviderModelsSectionProps { */ export function ProviderModelsSection({ instanceId, + supportsCustomModelDefinitions = false, driverKind, models, customModels, @@ -115,19 +175,58 @@ export function ProviderModelsSection({ onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [filter, setFilter] = useState(""); const [error, setError] = useState(null); - const listRef = useRef(null); + // Slug of the custom model whose inline editor is open, if any. + const [editingSlug, setEditingSlug] = useState(null); + const listRef = useRef(null); + // Slug of a just-added custom model, scrolled into view once its row exists. + const scrollToSlugRef = useRef(null); const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); - const orderedModels = useMemo(() => { - return sortModelsForProviderInstance(models, { - favoriteModels: favoriteModelSet, - groupFavorites: true, - modelOrder, - }); - }, [favoriteModelSet, modelOrder, models]); + const displayModels = useMemo( + () => + groupModelsForDisplay(models, { + favoriteModels: favoriteModelSet, + hiddenModels: hiddenModelSet, + modelOrder, + }), + [favoriteModelSet, hiddenModelSet, modelOrder, models], + ); + const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length; + const hiddenCount = displayModels.filter( + (model) => !model.isCustom && hiddenModelSet.has(model.slug), + ).length; + const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); + const allBuiltInModelsHidden = + builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug)); + const showFilter = models.length > FILTER_THRESHOLD; + const normalizedFilter = filter.trim().toLowerCase(); + const isFiltering = showFilter && normalizedFilter.length > 0; + const visibleModels = isFiltering + ? displayModels.filter( + (model) => + model.name.toLowerCase().includes(normalizedFilter) || + model.slug.toLowerCase().includes(normalizedFilter), + ) + : displayModels; + + // The parent commits the new custom model and hands back an updated + // `models` list, so the row can only be scrolled to after that render. + useEffect(() => { + const slug = scrollToSlugRef.current; + if (slug === null) return; + const row = listRef.current?.querySelector( + `[data-model-slug="${CSS.escape(slug)}"]`, + ); + if (!row) return; + scrollToSlugRef.current = null; + row.scrollIntoView({ block: "nearest" }); + }, [displayModels]); const handleAdd = () => { + if (driverKind === "antigravity") return; const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); @@ -141,44 +240,45 @@ export function ProviderModelsSection({ setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); return; } - if (customModels.includes(normalized)) { + if (customModels.some((entry) => entry.slug === normalized)) { setError("That custom model is already saved."); return; } - onChange([...customModels, normalized]); + // Clear the filter so the new row renders even when it does not match, + // which is also what lets the pending scroll target resolve and clear. + scrollToSlugRef.current = normalized; + setFilter(""); + onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]); setInput(""); setError(null); + setIsAdding(false); + }; - // Scroll the new row into view once the DOM reflects the commit. - // `MutationObserver` handles the one-frame gap between `onChange` and - // the `models` prop update; the `requestAnimationFrame` covers the - // common case where the parent updates synchronously. - const el = listRef.current; - if (!el) return; - const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - requestAnimationFrame(scrollToEnd); - const observer = new MutationObserver(() => { - scrollToEnd(); - observer.disconnect(); - }); - observer.observe(el, { childList: true, subtree: true }); - setTimeout(() => observer.disconnect(), 2_000); + const cancelAdd = () => { + setInput(""); + setError(null); + setIsAdding(false); }; const handleRemove = (slug: string) => { - onChange(customModels.filter((model) => model !== slug)); + if (editingSlug === slug) setEditingSlug(null); + onChange(customModels.filter((entry) => entry.slug !== slug)); onModelOrderChange(modelOrder.filter((model) => model !== slug)); onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); setError(null); }; - const handleToggleHidden = (slug: string) => { - if (hiddenModelSet.has(slug)) { - onHiddenModelsChange(hiddenModels.filter((model) => model !== slug)); - return; - } - onHiddenModelsChange([...hiddenModels, slug]); + const handleSaveEdit = (next: CustomModelDefinition) => { + onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); + setEditingSlug(null); + }; + + const setHidden = (slug: string, hidden: boolean) => { + if (hidden === hiddenModelSet.has(slug)) return; + onHiddenModelsChange( + hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), + ); }; const handleToggleFavorite = (slug: string) => { @@ -189,259 +289,360 @@ export function ProviderModelsSection({ onFavoriteModelsChange([...favoriteModels, slug]); }; + // Rows only trade places with a neighbour in the same group (favorites, + // visible, hidden), and the display order is persisted as the new order. + const groupOf = (model: (typeof displayModels)[number]) => + favoriteModelSet.has(model.slug) + ? "favorite" + : !model.isCustom && hiddenModelSet.has(model.slug) + ? "hidden" + : "visible"; const handleMove = (slug: string, direction: -1 | 1) => { - const slugs = orderedModels.map((model) => model.slug); - const index = slugs.indexOf(slug); + const index = displayModels.findIndex((model) => model.slug === slug); const nextIndex = index + direction; - if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) { - return; - } - const next = [...slugs]; + if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; + if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return; + const next = displayModels.map((model) => model.slug); [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; onModelOrderChange(next); }; - const builtInModels = models.filter((model) => !model.isCustom); - const allBuiltInModelsHidden = - builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug)); + type DisplayModel = (typeof displayModels)[number]; + + const starButton = (model: DisplayModel, isFavorite: boolean) => ( + + handleToggleFavorite(model.slug)} + aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ + isFavorite ? "from" : "to" + } favorites`} + /> + } + > + + + + {isFavorite ? "Remove from favorites" : "Add to favorites"} + + + ); + + // Reorder and remove stay in the row at all times (dimmed when unavailable) + // so ordering is discoverable without hovering. + const rowActions = ( + model: DisplayModel, + options: { + readonly isHidden: boolean; + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; + }, + ) => ( + + {!options.isHidden && !isFiltering ? ( + <> + + handleMove(model.slug, -1)} + aria-label={`Move ${model.name} up`} + /> + } + > + + + Move up + + + handleMove(model.slug, 1)} + aria-label={`Move ${model.name} down`} + /> + } + > + + + Move down + + + ) : null} + {model.isCustom ? ( + <> + + + setEditingSlug((current) => (current === model.slug ? null : model.slug)) + } + /> + } + > + + + + {supportsCustomModelDefinitions + ? "Edit name and options" + : "Update this server to edit custom model definitions"} + + + + handleRemove(model.slug)} + /> + } + > + + + Remove custom model + + + ) : null} + + ); + + const pickerTooltip = (model: DisplayModel, isHidden: boolean) => + model.isCustom + ? "Custom models are always shown in the picker" + : isHidden + ? "Hidden from picker" + : "Shown in picker"; + + // The trigger is a wrapper span: a disabled switch gets no pointer events, + // so it could not open the tooltip itself. + const pickerSwitch = (model: DisplayModel, isHidden: boolean) => ( + + }> + setHidden(model.slug, !checked)} + aria-label={`Show ${model.name} in the model picker`} + /> + + {pickerTooltip(model, isHidden)} + + ); + + const renderRow = (model: DisplayModel) => { + const capLabels = describeModelCapabilities(model); + const group = groupOf(model); + // Hidden is read from the preference itself: a favorited model can still be + // hidden, and its switch must say so even though it sits in the favorites group. + const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); + const isFavorite = group === "favorite"; + const index = displayModels.indexOf(model); + const previousModel = displayModels[index - 1]; + const nextModel = displayModels[index + 1]; + // Reordering a filtered view would be ambiguous, so arrows only show on + // the full list. + const canMoveUp = + !isFiltering && previousModel !== undefined && groupOf(previousModel) === group; + const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group; + const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90"); + + return ( +
+ {starButton(model, isFavorite)} + + {model.name} + {model.name !== model.slug ? ( + + {model.slug} + + ) : null} + {model.isCustom ? ( + custom + ) : null} + + {/* + Always a grid item so the columns line up across rows; the text + itself drops out on phone widths where it would starve the name. + */} + + {capLabels.length > 0 ? ( + {capLabels.join(" · ")} + ) : null} + + {rowActions(model, { isHidden, canMoveUp, canMoveDown })} + {pickerSwitch(model, isHidden)} +
+ ); + }; + + const groupLabel = (label: string, isFirst: boolean) => ( +
+ {label} +
+ ); + return (
-
-
Models
- {builtInModels.length > 0 ? ( - +
+ {showFilter ? ( + setFilter(event.target.value)} + placeholder="Filter models" + size="sm" + className="w-56" + spellCheck={false} + aria-label="Filter models" + /> ) : null} -
-
- {models.length} model{models.length === 1 ? "" : "s"} available. +
+ {builtInModels.length > 0 ? ( + + ) : null} + + {models.length} model{models.length === 1 ? "" : "s"} + {favoriteCount > 0 + ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` + : ""} + {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} + +
- {orderedModels.map((model, index) => { - const caps = model.capabilities; - const capLabels: string[] = []; - const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); - const isFavorite = favoriteModelSet.has(model.slug); - const previousModel = orderedModels[index - 1]; - const nextModel = orderedModels[index + 1]; - const canMoveUp = - previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; - const canMoveDown = - nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite; - const descriptors = caps?.optionDescriptors ?? []; - if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { - capLabels.push("Fast mode"); - } - if (descriptors.some((descriptor) => descriptor.id === "thinking")) { - capLabels.push("Thinking"); - } - if ( - descriptors.some( - (descriptor) => - descriptor.type === "select" && - (descriptor.id === "reasoningEffort" || - descriptor.id === "effort" || - descriptor.id === "reasoning" || - descriptor.id === "variant"), - ) - ) { - capLabels.push("Reasoning"); - } - const hasDetails = capLabels.length > 0 || model.name !== model.slug; - + {visibleModels.length === 0 ? ( +

+ {isFiltering ? "No models match." : "No models reported for this provider yet."} +

+ ) : null} + {visibleModels.map((model, index) => { + const group = groupOf(model); + const previous = visibleModels[index - 1]; + const startsGroup = previous === undefined || groupOf(previous) !== group; + const editingEntry = + model.isCustom && editingSlug === model.slug + ? customModels.find((entry) => entry.slug === model.slug) + : undefined; return ( -
-
- - {model.name} - - {hasDetails ? ( - - - } - > - - - -
- {model.slug} - {capLabels.length > 0 ? ( -
- {capLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
-
- ) : null} - {isHidden ? ( - hidden - ) : null} - {model.isCustom ? ( - custom - ) : null} -
-
- - handleToggleFavorite(model.slug)} - aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ - isFavorite ? "from" : "to" - } favorites`} - /> - } - > - - - - {isFavorite ? "Remove from favorites" : "Add to favorites"} - - - - handleMove(model.slug, -1)} - aria-label={`Move ${model.name} up`} - /> - } - > - - - Move up - - - handleMove(model.slug, 1)} - aria-label={`Move ${model.name} down`} - /> - } - > - - - Move down - - {!model.isCustom ? ( - - handleToggleHidden(model.slug)} - aria-label={`${isHidden ? "Show" : "Hide"} ${model.name}`} - /> - } - > - {isHidden ? ( - - ) : ( - - )} - - - {isHidden ? "Show in picker" : "Hide from picker"} - - - ) : null} - {model.isCustom ? ( - - handleRemove(model.slug)} - /> - } - > - - - Remove custom model - - ) : null} -
+
+ {startsGroup && favoriteCount > 0 && group === "favorite" + ? groupLabel("Favorites", index === 0) + : null} + {startsGroup && favoriteCount > 0 && group === "visible" + ? groupLabel("All", index === 0) + : null} + {startsGroup && group === "hidden" + ? groupLabel("Hidden from picker", index === 0) + : null} + {renderRow(model)} + {editingEntry ? ( + setEditingSlug(null)} + /> + ) : null}
); })}
-
- { - setInput(event.target.value); - if (error) setError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - handleAdd(); - }} - aria-label="Custom model slug" - placeholder={ - (driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : undefined) ?? "model-slug" - } - spellCheck={false} - /> - + +
+
+ ) : ( + -
+ )} - {error ?

{error}

: null} + {driverKind !== "antigravity" && error ? ( +

{error}

+ ) : null}
); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d496e23ca..4a39dd3b2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2964,9 +2964,8 @@ function EnvironmentProviderSettings( const updateSettings: typeof persistSettings = (patch) => { if (!readOnly) persistSettings(patch); }; - const serverProviders = - useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? - EMPTY_SERVER_PROVIDERS; + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const serverProviders = serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; const targetEnvironment = useMemo( () => ({ environmentId: props.environmentId }), [props.environmentId], @@ -3359,6 +3358,9 @@ function EnvironmentProviderSettings( : undefined } mode={mode} + supportsCustomModelDefinitions={ + serverConfig?.environment.capabilities.customModelDefinitions === true + } selected={selectedRow?.instanceId === row.instanceId} onSelect={() => setSelectedInstanceId(row.instanceId)} readOnly={readOnly} diff --git a/apps/web/src/components/settings/customModelEditor.logic.test.ts b/apps/web/src/components/settings/customModelEditor.logic.test.ts new file mode 100644 index 000000000..dba4d7af7 --- /dev/null +++ b/apps/web/src/components/settings/customModelEditor.logic.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; + +import { + DESCRIPTOR_PRESETS_BY_KIND, + descriptorFromPreset, + definitionFromDraft, + descriptorsFromCapabilities, + draftFromDefinition, + validateDraft, + type CustomModelDraft, +} from "./customModelEditor.logic"; + +const draft = (overrides: Partial): CustomModelDraft => ({ + slug: "my-model", + name: "", + descriptors: [], + ...overrides, +}); + +describe("customModelEditor.logic", () => { + it("round-trips a definition through the draft, marking the current value as default", () => { + const definition = definitionFromDraft( + draft({ + name: " My Model ", + descriptors: [ + { + key: "a", + type: "select", + id: "reasoningEffort", + label: "Reasoning", + choices: [ + { key: "a1", id: "low", label: "Low", isDefault: false }, + { key: "a2", id: "high", label: "", isDefault: true }, + ], + }, + { key: "b", type: "boolean", id: "fastMode", label: "Fast Mode", choices: [] }, + ], + }), + ); + + expect(definition).toEqual({ + slug: "my-model", + name: "My Model", + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "high", isDefault: true }, + ], + currentValue: "high", + }, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + ], + }, + }); + + const reopened = draftFromDefinition(definition); + expect(reopened.name).toBe("My Model"); + expect(reopened.descriptors.map((descriptor) => descriptor.id)).toEqual([ + "reasoningEffort", + "fastMode", + ]); + expect(reopened.descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([ + false, + true, + ]); + }); + + it("preserves the current choice when it differs from the built-in default", () => { + const descriptors = descriptorsFromCapabilities( + { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + ProviderDriverKind.make("claudeAgent"), + ); + expect(descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([false, true]); + expect( + definitionFromDraft(draft({ descriptors })).capabilities?.optionDescriptors?.[0], + ).toMatchObject({ currentValue: "high" }); + }); + + it("drops prompt-injected choices when copying a built-in's descriptors", () => { + const [copied] = descriptorsFromCapabilities( + { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + ], + }, + ProviderDriverKind.make("claudeAgent"), + ); + expect(copied!.choices.map((choice) => choice.id)).toEqual(["high"]); + }); + + it.each([true, false, undefined])( + "preserves boolean values through copy and edit: %s", + (currentValue) => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "boolean", + ...(currentValue !== undefined ? { currentValue } : {}), + }, + ], + }; + const copied = definitionFromDraft( + draft({ + descriptors: descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")), + }), + ); + expect(copied.capabilities).toEqual(capabilities); + const reopened = draftFromDefinition(copied); + expect(definitionFromDraft({ ...reopened, name: "Renamed" }).capabilities).toEqual( + capabilities, + ); + }, + ); + + it("excludes Claude context choices from presets and copies without changing other providers or authored entries", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "contextWindow", + label: "Context", + type: "select", + options: [{ id: "1m", label: "1M", isDefault: true }], + }, + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], + }; + const claude = ProviderDriverKind.make("claudeAgent"); + const copied = definitionFromDraft( + draft({ descriptors: descriptorsFromCapabilities(capabilities, claude) }), + ); + expect(copied.capabilities?.optionDescriptors).toEqual([capabilities.optionDescriptors![1]]); + const presets = definitionFromDraft( + draft({ + descriptors: (DESCRIPTOR_PRESETS_BY_KIND[claude] ?? []).map(descriptorFromPreset), + }), + ); + expect( + presets.capabilities?.optionDescriptors?.some((option) => option.id === "contextWindow"), + ).toBe(false); + const cursorCopy = descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")); + expect(cursorCopy.map((option) => option.id)).toEqual(["contextWindow", "thinking"]); + const authored = { slug: "custom", name: "Custom", capabilities }; + expect( + definitionFromDraft(draftFromDefinition(authored)).capabilities?.optionDescriptors?.[0], + ).toMatchObject(capabilities.optionDescriptors![0]!); + }); + + it("preserves choice descriptions when copying, renaming, and saving", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + description: "Choose a reasoning level.", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "ultracode", label: "Ultracode", description: "Uses additional reasoning." }, + ], + }, + ], + }; + const copied = definitionFromDraft( + draft({ + descriptors: descriptorsFromCapabilities( + capabilities, + ProviderDriverKind.make("claudeAgent"), + ), + }), + ); + const reopened = draftFromDefinition(copied); + const saved = definitionFromDraft({ ...reopened, name: "Renamed" }); + expect(saved.capabilities?.optionDescriptors?.[0]).toMatchObject( + capabilities.optionDescriptors![0]!, + ); + expect(copied.capabilities?.optionDescriptors?.[0]).toMatchObject( + capabilities.optionDescriptors![0]!, + ); + }); + + it.each(Object.entries(DESCRIPTOR_PRESETS_BY_KIND))( + "offers saveable presets for %s", + (_driver, presets) => { + expect( + validateDraft(draft({ descriptors: (presets ?? []).map(descriptorFromPreset) })), + ).toBeNull(); + }, + ); + + it("collapses a blank name and no options back to a bare definition", () => { + expect(definitionFromDraft(draft({ name: " " }))).toEqual({ + slug: "my-model", + name: "my-model", + capabilities: null, + }); + expect(draftFromDefinition({ slug: "x", name: "x", capabilities: null }).name).toBe(""); + }); + + it("rejects duplicate ids, blank ids, and selects without choices", () => { + const select = (id: string, choices: Array<{ id: string }>) => ({ + key: id, + type: "select" as const, + id, + label: "Label", + choices: choices.map((choice) => ({ + key: choice.id, + label: "", + isDefault: false, + ...choice, + })), + }); + + expect(validateDraft(draft({ descriptors: [select("", [{ id: "a" }])] }))).toBe( + "Option 1 needs an id.", + ); + expect( + validateDraft( + draft({ descriptors: [select("effort", [{ id: "a" }]), select("effort", [{ id: "b" }])] }), + ), + ).toBe('Option 2: id "effort" is used twice.'); + expect(validateDraft(draft({ descriptors: [select("effort", [])] }))).toBe( + "Option 1 needs at least one choice.", + ); + expect( + validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }, { id: "a" }])] })), + ).toBe('Option 1: choice "a" is used twice.'); + expect(validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }])] }))).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/customModelEditor.logic.ts b/apps/web/src/components/settings/customModelEditor.logic.ts new file mode 100644 index 000000000..15de96e2f --- /dev/null +++ b/apps/web/src/components/settings/customModelEditor.logic.ts @@ -0,0 +1,258 @@ +import { + type ModelCapabilities, + ProviderDriverKind, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { type CustomModelDefinition, createModelCapabilities } from "@t3tools/shared/model"; + +/** Editable mirror of a `ProviderOptionChoice`. `key` is only a React key. */ +export interface EditorChoice { + readonly key: string; + readonly id: string; + readonly label: string; + readonly isDefault: boolean; + readonly description?: string; +} + +/** Editable mirror of a `ProviderOptionDescriptor`. `key` is only a React key. */ +export interface EditorDescriptor { + readonly key: string; + readonly type: "select" | "boolean"; + readonly id: string; + readonly label: string; + readonly choices: ReadonlyArray; + readonly currentBooleanValue?: boolean | undefined; + readonly description?: string | undefined; +} + +export interface CustomModelDraft { + readonly slug: string; + readonly name: string; + readonly descriptors: ReadonlyArray; +} + +export interface DescriptorPreset { + readonly id: string; + readonly label: string; + readonly type: "select" | "boolean"; + readonly choices?: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>; +} + +const EFFORT_CHOICES = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, +] as const; + +/** + * Option ids each adapter actually reads off a turn's model selection, with + * the usual choices pre-filled. Anything else the user types is stored + * verbatim but will be ignored by the driver. + */ +export const DESCRIPTOR_PRESETS_BY_KIND: Partial< + Record> +> = { + [ProviderDriverKind.make("codex")]: [ + { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + { + id: "serviceTier", + label: "Speed", + type: "select", + choices: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "fast", label: "Fast" }, + ], + }, + ], + [ProviderDriverKind.make("claudeAgent")]: [ + { + id: "effort", + label: "Reasoning", + type: "select", + choices: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ], + }, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + { id: "thinking", label: "Thinking", type: "boolean" }, + ], + [ProviderDriverKind.make("cursor")]: [ + { id: "reasoning", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + { id: "thinking", label: "Thinking", type: "boolean" }, + ], + [ProviderDriverKind.make("grok")]: [ + { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + ], + [ProviderDriverKind.make("opencode")]: [ + { id: "variant", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + { + id: "agent", + label: "Agent", + type: "select", + choices: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + }, + ], +}; + +let nextKey = 0; +function newEditorKey(): string { + nextKey += 1; + return `k${nextKey}`; +} + +export function choiceFromPreset(choice: { + id: string; + label: string; + isDefault?: boolean; +}): EditorChoice { + return { key: newEditorKey(), id: choice.id, label: choice.label, isDefault: !!choice.isDefault }; +} + +export function descriptorFromPreset(preset: DescriptorPreset): EditorDescriptor { + return { + key: newEditorKey(), + type: preset.type, + id: preset.id, + label: preset.label, + choices: (preset.choices ?? []).map(choiceFromPreset), + }; +} + +export function emptyEditorDescriptor(): EditorDescriptor { + return { key: newEditorKey(), type: "select", id: "", label: "", choices: [] }; +} + +export function emptyEditorChoice(): EditorChoice { + return { key: newEditorKey(), id: "", label: "", isDefault: false }; +} + +/** + * Prompt-injected choices (Claude's `ultrathink`) are delivered as prompt text + * by built-in runtime profiles a custom entry does not have, so they are + * dropped rather than stored as a plain option value. + */ +function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { + const promptInjected = new Set( + descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], + ); + const choices = + descriptor.type === "select" + ? descriptor.options.filter((option) => !promptInjected.has(option.id)) + : []; + const defaultChoice = + choices.find((option) => option.id === descriptor.currentValue) ?? + choices.find((option) => option.isDefault); + return { + key: newEditorKey(), + type: descriptor.type, + id: descriptor.id, + label: descriptor.label, + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), + ...(descriptor.type === "boolean" && descriptor.currentValue !== undefined + ? { currentBooleanValue: descriptor.currentValue } + : {}), + choices: choices.map((option) => ({ + key: newEditorKey(), + id: option.id, + label: option.label, + ...(option.description !== undefined ? { description: option.description } : {}), + isDefault: option === defaultChoice, + })), + }; +} + +export function draftFromDefinition(entry: CustomModelDefinition): CustomModelDraft { + return { + slug: entry.slug, + name: entry.name === entry.slug ? "" : entry.name, + descriptors: (entry.capabilities?.optionDescriptors ?? []).map(descriptorToEditor), + }; +} + +/** Claude context choices require runtime suffix mappings that custom entries do not carry. */ +export function descriptorsFromCapabilities( + capabilities: ModelCapabilities | null | undefined, + driverKind: ProviderDriverKind | null, +): EditorDescriptor[] { + return (capabilities?.optionDescriptors ?? []) + .filter((descriptor) => driverKind !== "claudeAgent" || descriptor.id !== "contextWindow") + .map(descriptorToEditor); +} + +/** + * Validate the draft before saving. Returns the first problem in reading + * order so the message is actionable, or `null` when the draft is sound. + */ +export function validateDraft(draft: CustomModelDraft): string | null { + const seenIds = new Set(); + for (const [index, descriptor] of draft.descriptors.entries()) { + const position = `Option ${index + 1}`; + const id = descriptor.id.trim(); + if (!id) return `${position} needs an id.`; + if (seenIds.has(id)) return `${position}: id "${id}" is used twice.`; + seenIds.add(id); + if (!descriptor.label.trim()) return `${position} needs a label.`; + if (descriptor.type !== "select") continue; + if (descriptor.choices.length === 0) return `${position} needs at least one choice.`; + const seenChoices = new Set(); + for (const choice of descriptor.choices) { + const choiceId = choice.id.trim(); + if (!choiceId) return `${position} has a choice without a value.`; + if (seenChoices.has(choiceId)) { + return `${position}: choice "${choiceId}" is used twice.`; + } + seenChoices.add(choiceId); + } + } + return null; +} + +/** Convert a validated draft back into a definition. Blank name → slug. */ +export function definitionFromDraft(draft: CustomModelDraft): CustomModelDefinition { + const descriptors: ProviderOptionDescriptor[] = draft.descriptors.map((descriptor) => { + const id = descriptor.id.trim(); + const label = descriptor.label.trim(); + if (descriptor.type === "boolean") { + return { + id, + label, + type: "boolean", + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), + ...(descriptor.currentBooleanValue !== undefined + ? { currentValue: descriptor.currentBooleanValue } + : {}), + }; + } + const options = descriptor.choices.map((choice) => ({ + id: choice.id.trim(), + label: choice.label.trim() || choice.id.trim(), + ...(choice.description !== undefined ? { description: choice.description } : {}), + ...(choice.isDefault ? { isDefault: true } : {}), + })); + const currentValue = options.find((option) => option.isDefault)?.id; + return { + id, + label, + type: "select", + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), + options, + ...(currentValue ? { currentValue } : {}), + }; + }); + const name = draft.name.trim(); + return { + slug: draft.slug, + name: name || draft.slug, + capabilities: + descriptors.length > 0 ? createModelCapabilities({ optionDescriptors: descriptors }) : null, + }; +} diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index 4537040a3..267b314ca 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -4,13 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch"; import { cn } from "~/lib/utils"; -function Switch({ className, ...props }: SwitchPrimitive.Root.Props) { +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { size?: "default" | "sm" }) { return ( diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index a3d93f3c2..902d8bc4d 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -9,6 +9,8 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { + type CustomModelDefinition, + readCustomModelEntries, createModelSelection, normalizeCustomModelSlug, resolveSelectableModel, @@ -50,13 +52,13 @@ function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind, -): ReadonlyArray { +): ReadonlyArray { const instance = settings.providerInstances?.[instanceId]; const config = instance?.config; if (config !== null && typeof config === "object") { const value = (config as Record).customModels; if (Array.isArray(value)) { - return value.filter((entry): entry is string => typeof entry === "string"); + return readCustomModelEntries(value); } } const defaultInstanceId = defaultInstanceIdForDriver(driverKind); @@ -65,9 +67,9 @@ function readInstanceCustomModels( } const legacyProviders = settings.providers as Record< string, - { readonly customModels: ReadonlyArray } | undefined + { readonly customModels: unknown } | undefined >; - return legacyProviders[driverKind]?.customModels ?? []; + return readCustomModelEntries(legacyProviders[driverKind]?.customModels); } export interface AppModelOption { @@ -189,7 +191,13 @@ export function getAppModelOptions( // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { + const customNameBySlug = new Map(customModels.map((model) => [model.slug, model.name])); + for (const option of options) + if (option.isCustom) option.name = customNameBySlug.get(option.slug) ?? option.name; + for (const slug of normalizeCustomModelSlugs( + customModels.map((entry) => entry.slug), + builtInModelSlugs, + )) { if (seen.has(slug)) { continue; } @@ -197,7 +205,7 @@ export function getAppModelOptions( seen.add(slug); options.push({ slug, - name: slug, + name: customNameBySlug.get(slug) ?? slug, isCustom: true, }); } @@ -237,13 +245,19 @@ export function getAppModelOptionsForInstance( ); const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { + const customNameBySlug = new Map(customModels.map((model) => [model.slug, model.name])); + for (const option of options) + if (option.isCustom) option.name = customNameBySlug.get(option.slug) ?? option.name; + for (const slug of normalizeCustomModelSlugs( + customModels.map((entry) => entry.slug), + builtInModelSlugs, + )) { if (seen.has(slug)) { continue; } seen.add(slug); - options.push({ slug, name: slug, isCustom: true }); + options.push({ slug, name: customNameBySlug.get(slug) ?? slug, isCustom: true }); } const preferences = readInstanceModelPreferences(settings, entry.instanceId); diff --git a/docs/user/provider-accounts.md b/docs/user/provider-accounts.md index 20c2fb8a8..7d900ec69 100644 --- a/docs/user/provider-accounts.md +++ b/docs/user/provider-accounts.md @@ -10,3 +10,15 @@ but cannot change their settings or update provider software. On iOS, open Settings → Agents, select an environment, then open an account to choose its visible models. Enable all and Disable all affect that account's built-in models. Claude's auto-compaction setting applies to the selected environment. + +Custom models can have a display name and their own composer options. On web, +add a model ID in the account's model list and use Edit to customize it. On iOS, +open the account's Custom models screen. Both editors let you copy options from +an available model, start with a provider preset, or define choice and toggle +controls. Select one default per choice control. Save applies the whole edit; +Cancel leaves the saved model unchanged. + +The model ID must exist at your provider. Renaming its display label does not +change which model runs. Custom options replace that model's default option set; +only option IDs supported by the provider affect requests. Servers that predate +custom model definitions need an update before these editors can save them. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 822c2c4d4..f4c36107b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -102,6 +102,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ showing a set nothing will ever update. */ environmentThemes: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), + /** Custom model entries can include names and composer option descriptors. */ + customModelDefinitions: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dfa5de445..ea4996851 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -140,6 +140,22 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; +/** + * A user-authored custom model. `name` and `capabilities` are optional so a + * bare slug keeps its driver-default presentation; when `capabilities` is + * set, its descriptors replace the driver default in the model picker. + */ +export const CustomModelEntry = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: Schema.optional(TrimmedNonEmptyString), + capabilities: Schema.optional(ModelCapabilities), +}); +export type CustomModelEntry = typeof CustomModelEntry.Type; + +/** On-disk custom model setting: the legacy bare slug, or a full entry. */ +export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]); +export type CustomModelSetting = typeof CustomModelSetting.Type; + const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 081ceea38..01d3a7216 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,6 +5,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { + CustomModelSetting, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, @@ -431,7 +432,7 @@ export const CodexSettings = makeProviderSettingsSchema( description: "Additional CLI arguments passed to codex app-server on session start.", }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -469,7 +470,7 @@ export const ClaudeSettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -512,7 +513,7 @@ export const CursorSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -538,7 +539,7 @@ export const GrokSettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -588,7 +589,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed("auto")), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -615,7 +616,7 @@ export const HermesAcpSettings = makeProviderSettingsSchema( }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -721,7 +722,7 @@ export const OpenClawSettings = makeProviderSettingsSchema( providerSettingsForm: { control: "switch", clearWhenEmpty: "persist" }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -773,7 +774,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -857,7 +858,7 @@ export const HermesSettings = makeProviderSettingsSchema( "Start Hermes automatically", "Attach to a compatible gateway at this endpoint, or launch and supervise `hermes serve` when no local gateway is running.", ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -1263,14 +1264,14 @@ const CodexSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), launchArgs: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), launchArgs: Schema.optionalKey(TrimmedString), // Validated at the patch boundary so a typo fails the one update with a // schema error instead of a generic whole-settings failure. @@ -1281,13 +1282,13 @@ const ClaudeSettingsPatch = Schema.Struct({ const CursorSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const GrokSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const OpenCodeSettingsPatch = Schema.Struct({ @@ -1295,7 +1296,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), serverUrl: Schema.optionalKey(TrimmedString), serverPassword: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const HermesSettingsPatch = Schema.Struct({ @@ -1304,7 +1305,7 @@ const HermesSettingsPatch = Schema.Struct({ remoteAccessEnabled: Schema.optionalKey(Schema.Boolean), profileKey: Schema.optionalKey(HermesProfileKey), managedServerEnabled: Schema.optionalKey(Schema.Boolean), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), importEnabled: Schema.optionalKey(Schema.Boolean), mcpEnabled: Schema.optionalKey(Schema.Boolean), attachmentsEnabled: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 279a97cf0..b26ab7867 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { + readCustomModelEntries, + toCustomModelSetting, applyClaudePromptEffortPrefix, buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, @@ -210,3 +212,40 @@ describe("applyClaudePromptEffortPrefix", () => { ); }); }); + +describe("custom model settings", () => { + it("normalizes mixed settings without alias expansion and keeps the first exact ID", () => { + expect( + readCustomModelEntries([ + null, + 12, + " ", + " opus ", + { slug: "opus", name: "Duplicate" }, + { + slug: "private/model", + name: " Private model ", + capabilities: { optionDescriptors: "bad" }, + }, + ]), + ).toEqual([ + { slug: "opus", name: "opus", capabilities: null }, + { slug: "private/model", name: "Private model", capabilities: null }, + ]); + }); + + it("round trips custom names and options while retaining compact legacy IDs", () => { + const settings = [ + "legacy", + { slug: "private/model", name: "Private", capabilities: codexCaps }, + ]; + expect(readCustomModelEntries(settings).map(toCustomModelSetting)).toEqual(settings); + expect( + toCustomModelSetting({ + slug: "empty", + name: "empty", + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }), + ).toBe("empty"); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 9cced36c1..dc0aa52b4 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,8 +1,11 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ModelCapabilities, + ModelCapabilities, + type CustomModelSetting, type ModelSelection, ProviderDriverKind, ProviderInstanceId, @@ -296,6 +299,71 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri return model.trim() || null; } +/** A custom model setting with its optional fields resolved. */ +export interface CustomModelDefinition { + readonly slug: string; + readonly name: string; + readonly capabilities: ModelCapabilities | null; +} + +const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities); + +/** + * Read a `customModels` setting into resolved definitions. Accepts the typed + * union as well as the opaque `providerInstances[id].config` blob clients see, + * so it tolerates bare slugs, malformed rows, and unparseable capabilities + * (dropped rather than failing the whole list). Slugs are trimmed and + * deduplicated, first occurrence wins; `name` falls back to the slug. + */ +export function readCustomModelEntries(value: unknown): CustomModelDefinition[] { + if (!Array.isArray(value)) return []; + const entries: CustomModelDefinition[] = []; + const seen = new Set(); + for (const raw of value) { + const record = + typeof raw === "string" + ? { slug: raw } + : raw !== null && typeof raw === "object" + ? (raw as { slug?: unknown; name?: unknown; capabilities?: unknown }) + : null; + if (!record) continue; + const slug = normalizeCustomModelSlug(typeof record.slug === "string" ? record.slug : null); + if (!slug || seen.has(slug)) continue; + seen.add(slug); + const name = + (typeof record.name === "string" ? normalizeCustomModelSlug(record.name) : null) ?? slug; + const capabilities = + record.capabilities === undefined || record.capabilities === null + ? null + : Option.getOrNull(decodeCustomModelCapabilities(record.capabilities)); + entries.push({ + slug, + name, + capabilities: capabilities + ? createModelCapabilities({ optionDescriptors: capabilities.optionDescriptors ?? [] }) + : null, + }); + } + return entries; +} + +/** + * Write a definition back to the compact stored shape: a bare slug when it + * carries nothing custom, otherwise an entry with only the set fields. + */ +export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting { + const descriptors = entry.capabilities?.optionDescriptors ?? []; + const name = entry.name !== entry.slug ? entry.name : undefined; + if (!name && descriptors.length === 0) return entry.slug; + return { + slug: entry.slug, + ...(name ? { name } : {}), + ...(descriptors.length > 0 + ? { capabilities: createModelCapabilities({ optionDescriptors: descriptors }) } + : {}), + }; +} + export function resolveSelectableModel( provider: ProviderDriverKind, value: string | null | undefined, diff --git a/scripts/generate-swift-contract-fixtures.ts b/scripts/generate-swift-contract-fixtures.ts index 50be53e47..53a405f09 100644 --- a/scripts/generate-swift-contract-fixtures.ts +++ b/scripts/generate-swift-contract-fixtures.ts @@ -15,6 +15,7 @@ * node scripts/generate-swift-contract-fixtures.ts --check # CI: fail if stale */ import { + CustomModelSetting, ExecutionEnvironmentDescriptor, EnvironmentId, ServerProviderUsageLimits, @@ -503,7 +504,7 @@ const machineSerialized = `${JSON.stringify( label: "Studio", platform: { os: "darwin", arch: "arm64", machine: "mac-studio" }, serverVersion: "0.0.38", - capabilities: { repositoryIdentity: true, environmentIcon: true }, + capabilities: { repositoryIdentity: true, environmentIcon: true, customModelDefinitions: true }, }), null, 2, @@ -517,3 +518,37 @@ if (process.argv.includes("--check")) { process.exit(1); } } else NodeFS.writeFileSync(machinePath, machineSerialized); + +const customModelsPath = NodePath.join(NodePath.dirname(outputPath), "customModels.json"); +const customModelsSerialized = `${JSON.stringify( + Schema.encodeSync(Schema.Array(CustomModelSetting))([ + "legacy-model", + { + slug: "private/model", + name: "Private model", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High", isDefault: true }], + currentValue: "high", + }, + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], + }, + }, + ]), + null, + 2, +)}\n`; +if (process.argv.includes("--check")) { + if ( + !NodeFS.existsSync(customModelsPath) || + NodeFS.readFileSync(customModelsPath, "utf8") !== customModelsSerialized + ) { + console.error("[swift-fixtures] customModels.json is stale; regenerate fixtures."); + process.exit(1); + } +} else NodeFS.writeFileSync(customModelsPath, customModelsSerialized); From c7b8f23cb2a26566d54a6f8fbc993ccfe693818b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:33:05 +0200 Subject: [PATCH 16/93] test(mobile): use cursor terminal state in menu fixtures --- apps/mobile/src/features/terminal/terminalMenu.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 966312270..3f9a39237 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; +import { + EMPTY_TERMINAL_SESSION_STATE, + type KnownTerminalSession, +} from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -40,6 +43,7 @@ function makeKnownSession(input: { terminalId: input.terminalId, }, state: { + ...EMPTY_TERMINAL_SESSION_STATE, summary: input.cwd ? { threadId: "thread-1", @@ -55,7 +59,6 @@ function makeKnownSession(input: { updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", } : null, - buffer: "", status: input.status, error: null, hasRunningSubprocess: false, From 1912f2a7e1eb083be92df384f4e68cc9f98723e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:33:30 +0200 Subject: [PATCH 17/93] feat(chat): quote assistant responses across web and Swift --- PATCH.md | 11 +- .../src/features/threads/ThreadFeed.tsx | 12 +- .../src/environment/ServerEnvironment.ts | 1 + .../ProviderTurnControlService.test.ts | 114 ++++++ .../ProviderTurnControlService.ts | 6 +- .../RunExecutionService.test.ts | 93 +++++ .../orchestration-v2/RunExecutionService.ts | 6 +- .../ThreadTitleRegenerationService.ts | 3 +- apps/swift-ios/App/NativeFeatureClient.swift | 13 +- apps/swift-ios/Core/Models.swift | 3 + .../Features/Chat/AssistantCitation.swift | 136 +++++++ .../Chat/AssistantCitationViews.swift | 235 +++++++++++ .../Features/Chat/FeatureComposerView.swift | 42 +- .../Features/Chat/MarkdownMessageView.swift | 14 +- .../Features/Chat/ThreadDetailView.swift | 111 ++++- .../Features/Root/FeatureRootModel.swift | 2 + .../Features/Shared/FeatureModels.swift | 9 +- .../AssistantCitationFixtureTests.swift | 14 + .../CoreTests/EnvironmentMachineTests.swift | 2 + .../CoreTests/Fixtures/assistantCitation.json | 15 + .../Fixtures/environmentMachine.json | 1 + .../FeatureTests/AssistantCitationTests.swift | 56 +++ apps/web/src/components/ChatMarkdown.tsx | 7 +- apps/web/src/components/ChatView.tsx | 38 +- .../src/components/ComposerCitationNode.tsx | 223 +++++++++++ ...omposerPromptEditor.serialization.test.tsx | 29 ++ .../components/ComposerPromptEditor.test.ts | 3 + .../src/components/ComposerPromptEditor.tsx | 176 +++++--- .../components/chat/AssistantCitationChip.tsx | 199 +++++++++ .../chat/AssistantCitationCommentEditor.tsx | 97 +++++ .../chat/AssistantCitationSource.tsx | 378 ++++++++++++++++++ .../chat/AssistantSelectionToolbar.tsx | 154 +++++++ apps/web/src/components/chat/ChatComposer.tsx | 29 ++ .../src/components/chat/ComposerStashMenu.tsx | 3 +- .../src/components/chat/MessagesTimeline.tsx | 85 +++- .../src/components/chat/composerEventScope.ts | 32 ++ .../chat/useAssistantCitationTarget.ts | 171 ++++++++ .../components/composerInlineTokenPaste.ts | 36 +- apps/web/src/composer-editor-mentions.test.ts | 21 + apps/web/src/composer-editor-mentions.ts | 24 +- apps/web/src/composer-logic.ts | 7 +- apps/web/src/index.css | 19 + .../lib/assistantCitationNavigation.test.ts | 95 +++++ .../src/lib/assistantCitationNavigation.ts | 47 +++ .../src/lib/assistantTextSelection.test.ts | 51 +++ apps/web/src/lib/assistantTextSelection.ts | 282 +++++++++++++ apps/web/src/lib/selectionActions.ts | 185 +++++++++ docs/user/quoting-responses.md | 22 + packages/contracts/src/assistantCitations.ts | 31 ++ packages/contracts/src/environment.ts | 1 + packages/contracts/src/index.ts | 1 + packages/shared/package.json | 4 + .../shared/src/assistantCitations.test.ts | 349 ++++++++++++++++ packages/shared/src/assistantCitations.ts | 186 +++++++++ scripts/generate-swift-contract-fixtures.ts | 33 +- 55 files changed, 3800 insertions(+), 117 deletions(-) create mode 100644 apps/swift-ios/Features/Chat/AssistantCitation.swift create mode 100644 apps/swift-ios/Features/Chat/AssistantCitationViews.swift create mode 100644 apps/swift-ios/Tests/CoreTests/AssistantCitationFixtureTests.swift create mode 100644 apps/swift-ios/Tests/CoreTests/Fixtures/assistantCitation.json create mode 100644 apps/swift-ios/Tests/FeatureTests/AssistantCitationTests.swift create mode 100644 apps/web/src/components/ComposerCitationNode.tsx create mode 100644 apps/web/src/components/chat/AssistantCitationChip.tsx create mode 100644 apps/web/src/components/chat/AssistantCitationCommentEditor.tsx create mode 100644 apps/web/src/components/chat/AssistantCitationSource.tsx create mode 100644 apps/web/src/components/chat/AssistantSelectionToolbar.tsx create mode 100644 apps/web/src/components/chat/composerEventScope.ts create mode 100644 apps/web/src/components/chat/useAssistantCitationTarget.ts create mode 100644 apps/web/src/lib/assistantCitationNavigation.test.ts create mode 100644 apps/web/src/lib/assistantCitationNavigation.ts create mode 100644 apps/web/src/lib/assistantTextSelection.test.ts create mode 100644 apps/web/src/lib/assistantTextSelection.ts create mode 100644 apps/web/src/lib/selectionActions.ts create mode 100644 docs/user/quoting-responses.md create mode 100644 packages/contracts/src/assistantCitations.ts create mode 100644 packages/shared/src/assistantCitations.test.ts create mode 100644 packages/shared/src/assistantCitations.ts diff --git a/PATCH.md b/PATCH.md index be96dfe1b..4e13b1d95 100644 --- a/PATCH.md +++ b/PATCH.md @@ -68,6 +68,15 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera files into the native file viewer, and offers template prompts through the composer. Invalid directives remain literal and code blocks are not interpreted as directives. +- Selected assistant-text citations are now ported through V2 start and steering paths. + The durable message retains the origin-independent link; providers receive the decoded + quote and separately identified user comment. Web uses Lexical chips, bounded source + navigation and the fork's run/attempt folding (no V1 keyset pagination). Swift uses native + selection/comment sheets, draft chips outside the voice gesture surface, and its existing + V2 earlier-turn loader. Its recycled item ID stays distinct from the durable message ID + used by citations. The optional `assistantCitations` capability gates creation; Expo's + frozen feed receives only a readable quote/comment compatibility renderer. + - Ports upstream's provider-settings list/editor split onto the fork's existing `SettingsPanels.tsx` and `ProviderInstanceCard`, preserving dedicated provider environment fields and the Hermes rollout gate. Environment tabs scope reads, writes, additions and @@ -452,7 +461,7 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera project-icon propagation, composer focus/multiline/footer transitions, usage account layout, and their follow-ups need dedicated adaptation to the fork's timeline, inline settings, unified attachments and panel stores. Previously deferred onboarding, shared settings, - auto-balancing, citations, galleries, reset credits and browser-profile import stay deferred. + auto-balancing, galleries, reset credits and browser-profile import stay deferred. The removed settings section-navigation machinery was never adopted by the fork. - Expo-only UI, outbox, drag handles and Android appearance/notification changes stay excluded under the freeze. Fork release artwork, marketing, review workflows, public security-policy diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index cb4382c46..945e3a879 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,4 @@ +import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; @@ -1142,6 +1143,7 @@ function renderFeedEntry( if (entry.type === "message") { const { message } = entry; + const renderedText = renderAssistantCitationsAsText(message.text); const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); @@ -1184,7 +1186,7 @@ function renderFeedEntry( > {message.text.trim().length > 0 ? ( 0 ? ( {message.text.trim().length > 0 ? ( 0 ? ( 0 ? ( + Effect.gen(function* () { + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:citation-steer"); + const sessionId = ProviderSessionId.make("session:citation-steer"); + const providerThreadId = ProviderThreadId.make("provider-thread:citation-steer"); + const providerTurnId = ProviderTurnId.make("provider-turn:citation-steer"); + const attemptId = RunAttemptId.make("attempt:citation-steer"); + const messageId = MessageId.make("message:citation-steer"); + const text = + "Explain [Assistant quote](t3-citation://v1/e/t/m?text=Selected+answer&start=0&end=15&prefix=&suffix=&comment=Why%3F)"; + const providerThread = { + id: providerThreadId, + providerSessionId: sessionId, + driver, + providerInstanceId, + } as OrchestrationV2ProviderThread; + const base = makeProjection({ now, threadId, providerThread, providerTurnId, attemptId }); + const projection = { + ...base, + runs: [ + { + id: RunId.make("run:citation-steer"), + activeAttemptId: attemptId, + modelSelection, + threadId, + ordinal: 1, + providerInstanceId, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + messages: [ + { + id: messageId, + text, + attachments: [], + createdBy: "user", + creationSource: "web", + threadId, + runId: null, + nodeId: null, + role: "user", + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + } satisfies OrchestrationV2ThreadProjection; + let received = ""; + const runtime = { + steerTurn: (input: { message: { text: string } }) => + Effect.sync(() => { + received = input.message.text; + }), + } as unknown as ProviderAdapterV2SessionRuntime; + const layer = providerTurnControlLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStoreV2)({ + getThreadProjection: () => Effect.succeed(projection), + }), + Layer.mock(ProviderSessionManagerV2)({ + get: () => Effect.succeed(Option.some(runtime)), + }), + Layer.mock(RuntimePolicyV2)({ + resolve: () => + Effect.succeed({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }), + }), + Layer.mock(AttachmentMaterialization)({ + materialize: () => + Effect.succeed({ + inlineAttachments: [], + materialized: [], + outcome: "written" as const, + promptBlock: "Attached file context", + }), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const control = yield* ProviderTurnControlServiceV2; + yield* control.steer({ + threadId, + providerSessionId: sessionId, + providerThreadId, + providerTurnId, + messageId, + }); + }).pipe(Effect.provide(layer)); + assert.include(received, "Explain [assistant-quote-1]"); + assert.include(received, '"text": "Selected answer"'); + assert.include(received, '"comment": "Why?"'); + assert.include(received, "Attached file context"); + assert.notInclude(received, "t3-citation://"); + assert.equal(projection.messages[0]?.text, text); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 288aa0247..d049a4131 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,3 +1,4 @@ +import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { MessageId, ProviderSessionId, @@ -291,7 +292,10 @@ export const layer: Layer.Layer< providerTurnId: loaded.providerTurn.id, message: { messageId: message.id, - text: appendUploadedFilesBlock(message.text, uploads.promptBlock), + text: appendUploadedFilesBlock( + expandAssistantCitationsForProvider(message.text), + uploads.promptBlock, + ), attachments: uploads.inlineAttachments, createdBy: message.createdBy, creationSource: message.creationSource, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index e47999946..6350cab44 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1,8 +1,10 @@ +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { assert, it, vi } from "@effect/vitest"; import { CheckpointScopeId, CommandId, EventId, + EnvironmentId, MessageId, NodeId, type OrchestrationV2AppThread, @@ -379,6 +381,97 @@ it.effect("refreshes MCP credential liveness before calling the provider", () => }).pipe(Effect.provide(RunExecutionTestLayer)), ); +it.effect("expands citations on the V2 start path while preserving the stored source", () => + Effect.gen(function* () { + const runExecution = yield* RunExecutionServiceV2; + const order = yield* Ref.make>([]); + const source = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("source-thread"), + messageId: MessageId.make("assistant"), + text: "Selected answer", + comment: "Explain this", + start: 0, + end: 15, + prefix: "", + suffix: "", + }); + let received = ""; + const threadId = ThreadId.make("thread:run-execution-mcp-liveness"); + const touchActiveMcpThread = vi + .spyOn(McpSessionRegistry, "touchActiveMcpThread") + .mockImplementation((touchedThreadId) => + Ref.update(order, (entries) => [...entries, `touch:${touchedThreadId}`]), + ); + + yield* runExecution + .startRootRun({ + commandId: CommandId.make("command:run-execution-mcp-liveness"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make("session:run-execution-mcp-liveness"), + session: { + events: Stream.never, + startTurn: (input: { message: { text: string } }) => + Effect.sync(() => { + received = input.message.text; + }), + } as unknown as ProviderAdapterV2SessionRuntime, + run: { + id: RunId.make("run:run-execution-mcp-liveness"), + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + } as OrchestrationV2Run, + rootNode: { + id: NodeId.make("node:run-execution-mcp-liveness"), + } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-mcp-liveness"), + } as OrchestrationV2CheckpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-mcp-liveness"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { + id: RunAttemptId.make("attempt:run-execution-mcp-liveness"), + providerTurnId: null, + } as OrchestrationV2RunAttempt, + attemptId: RunAttemptId.make("attempt:run-execution-mcp-liveness"), + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make("message:run-execution-mcp-liveness"), + text: source, + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }) + .pipe(Effect.ensuring(Effect.sync(() => touchActiveMcpThread.mockRestore()))); + + assert.include(received, "[assistant-quote-1]"); + assert.include(received, '"text": "Selected answer"'); + assert.include(received, '"comment": "Explain this"'); + assert.notInclude(received, "t3-citation://"); + assert.include(source, "t3-citation://"); + }).pipe(Effect.provide(RunExecutionTestLayer)), +); + it.effect("skips Git baseline capture for projectless Hermes runs", () => Effect.gen(function* () { const captures = yield* Ref.make(0); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e7887b579..8ac45d028 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -1,3 +1,4 @@ +import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { CommandId, type EventId, @@ -1119,7 +1120,10 @@ export const layer: Layer.Layer< attemptId: input.attemptId, rootNodeId: input.rootNode.id, providerThread: input.providerThread, - message: input.message, + message: { + ...input.message, + text: expandAssistantCitationsForProvider(input.message.text), + }, modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, }) diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 98476f7bc..f4391d504 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -1,3 +1,4 @@ +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { type ChatAttachment, CommandId, type ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -36,7 +37,7 @@ export function formatThreadTitleContext( if (message.role === "system") { continue; } - const text = message.text.trim(); + const text = assistantCitationsToPlainText(message.text).trim(); const attachmentSummary = (message.attachments ?? []) .map((attachment) => attachment.name) .join(", "); diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 3ebd70140..0b2ba618b 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -4187,6 +4187,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsPullRequests: environment.descriptor?.capabilities.pullRequests, machineKind: serverConfigsByEnvironmentID[environment.id]?.settings?.environmentIcon.flatMap(EnvironmentMachineKind.init(rawValue:))?.rawValue ?? environment.descriptor?.platform.machine, supportsEnvironmentIcon: environment.descriptor?.capabilities.environmentIcon, + supportsAssistantCitations: environment.descriptor?.capabilities.assistantCitations, supportsCustomModelDefinitions: environment.descriptor?.capabilities.customModelDefinitions ) } @@ -4473,7 +4474,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, _ role: FeatureMessageRole, _ text: String, tool: String? = nil, - state: FeatureMessageState? = nil + state: FeatureMessageState? = nil, + wireMessageID: String? = nil ) -> FeatureMessage { FeatureMessage( id: item.id, @@ -4481,7 +4483,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, text: text, createdAt: createdAt, state: state ?? (item.status.isTerminal ? .complete : .streaming), - toolName: tool + toolName: tool, + wireMessageID: wireMessageID ) } @@ -4507,8 +4510,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, createdBy: item.base.createdBy ) - case let .assistantMessage(_, text, streaming): - return message(.assistant, text, state: streaming ? .streaming : .complete) + case let .assistantMessage(messageID, text, streaming): + return message(.assistant, text, state: streaming ? .streaming : .complete, wireMessageID: messageID) case let .reasoning(text, streaming): return message(.tool, text, tool: "Thinking", state: streaming ? .streaming : .complete) @@ -5537,7 +5540,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private func previewText(_ text: String?) -> String? { guard let text else { return nil } - let compact = text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let compact = AssistantCitation.plainText(text).split(whereSeparator: \.isWhitespace).joined(separator: " ") guard !compact.isEmpty else { return nil } return compact.count > 160 ? "\(compact.prefix(157))..." : compact } diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index 8d7063c95..1ebe2ba70 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -55,6 +55,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public struct FileAttachments: Codable, Equatable, Sendable { public let maxUploadBytes: Int } public let attachmentUploads: Bool? public let fileAttachments: FileAttachments? + public let assistantCitations: Bool? public let customModelDefinitions: Bool? public let environmentIcon: Bool? public let usagePriceOverrides: Bool? @@ -73,6 +74,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadPullRequestLinking case threadPullRequestsV2 case attachmentUploads, fileAttachments + case assistantCitations case customModelDefinitions case environmentIcon case usagePriceOverrides @@ -99,6 +101,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) fileAttachments = try container.decodeIfPresent(FileAttachments.self, forKey: .fileAttachments) + assistantCitations = try container.decodeIfPresent(Bool.self, forKey: .assistantCitations) customModelDefinitions = try container.decodeIfPresent(Bool.self, forKey: .customModelDefinitions) environmentIcon = try container.decodeIfPresent(Bool.self, forKey: .environmentIcon) usagePriceOverrides = try container.decodeIfPresent(Bool.self, forKey: .usagePriceOverrides) diff --git a/apps/swift-ios/Features/Chat/AssistantCitation.swift b/apps/swift-ios/Features/Chat/AssistantCitation.swift new file mode 100644 index 000000000..fcdcf9d8a --- /dev/null +++ b/apps/swift-ios/Features/Chat/AssistantCitation.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Origin-independent quote links shared with the web composer. Offsets count +/// UTF-16 units in normalized rendered text, never Markdown source bytes. +struct AssistantCitation: Codable, Equatable, Identifiable, Sendable { + var id: String { href } + let version: Int + let environmentId: String + let threadId: String + let messageId: String + let text: String + var comment: String? + let start: Int + let end: Int + let prefix: String + let suffix: String + + var isValid: Bool { + version == 1 && [environmentId, threadId, messageId].allSatisfy { !$0.isEmpty && $0.utf16.count <= 512 } + && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && text.utf16.count <= 8_000 + && (comment?.utf16.count ?? 0) <= 8_000 && start >= 0 && end > start && end <= 9_007_199_254_740_991 + && prefix.utf16.count <= 32 && suffix.utf16.count <= 32 + } + + private static func encode(_ text: String, form: Bool) -> String { + text.utf8.map { byte in + if (65...90).contains(byte) || (97...122).contains(byte) || (48...57).contains(byte) || [45, 46, 95].contains(byte) + || (form ? byte == 42 : byte == 126) { return String(UnicodeScalar(byte)) } + if form && byte == 32 { return "+" } + return String(format: "%%%02X", byte) + }.joined() + } + + var href: String { + let path = [environmentId, threadId, messageId].map { Self.encode($0, form: false) }.joined(separator: "/") + var fields = [("text", text), ("start", String(start)), ("end", String(end)), ("prefix", prefix), ("suffix", suffix)] + if let comment { fields.append(("comment", comment)) } + return "t3-citation://v1/\(path)?" + fields.map { "\($0.0)=\(Self.encode($0.1, form: true))" }.joined(separator: "&") + } + var marker: String { "[Assistant quote](\(href))" } + + static func parse(_ href: String) -> Self? { + guard href.hasPrefix("t3-citation://v1/"), href.utf16.count <= 160_000, + href.range(of: "%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]|%[0-9A-Fa-f]?$", options: .regularExpression) == nil, + let url = URLComponents(string: href), url.scheme == "t3-citation", url.host == "v1", + url.user == nil, url.password == nil, url.port == nil, url.fragment == nil, + let query = url.percentEncodedQuery else { return nil } + let parts = url.percentEncodedPath.dropFirst().split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 3 else { return nil } + var fields: [String: String] = [:] + for part in query.split(separator: "&", omittingEmptySubsequences: false) { + let pair = part.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2, let key = String(pair[0]).replacingOccurrences(of: "+", with: " ").removingPercentEncoding, + let value = String(pair[1]).replacingOccurrences(of: "+", with: " ").removingPercentEncoding, + fields[key] == nil else { return nil } + fields[key] = value + } + guard Set(fields.keys).subtracting(["text", "start", "end", "prefix", "suffix", "comment"]).isEmpty, + let environment = String(parts[0]).removingPercentEncoding, + let thread = String(parts[1]).removingPercentEncoding, + let message = String(parts[2]).removingPercentEncoding, + let text = fields["text"], let prefix = fields["prefix"], let suffix = fields["suffix"], + let startText = fields["start"], let endText = fields["end"], + startText.range(of: "^[0-9]{1,16}$", options: .regularExpression) != nil, + endText.range(of: "^[0-9]{1,16}$", options: .regularExpression) != nil, + let start = Int(startText), let end = Int(endText) else { return nil } + let citation = Self(version: 1, environmentId: environment, threadId: thread, messageId: message, + text: text, comment: fields["comment"], start: start, end: end, prefix: prefix, suffix: suffix) + return citation.isValid ? citation : nil + } + + struct Match: Identifiable { + var id: Int { range.location } + let range: NSRange + let source: String + let citation: AssistantCitation + } + private static let links = try! NSRegularExpression(pattern: #"\[Assistant quote\]\((t3-citation://v1/[^\s)]{1,160000})\)"#) + static func matches(in text: String) -> [Match] { + let source = text as NSString + return links.matches(in: text, range: NSRange(location: 0, length: source.length)).compactMap { match in + guard let citation = parse(source.substring(with: match.range(at: 1))) else { return nil } + return Match(range: match.range, source: source.substring(with: match.range), citation: citation) + } + } + static func plainText(_ text: String) -> String { + var result = text as NSString + for match in matches(in: text).reversed() { + let comment = match.citation.comment.map { "\nComment: " + $0 } ?? "" + result = result.replacingCharacters(in: match.range, with: match.citation.text + comment) as NSString + } + return result as String + } + static func removingMarkers(from text: String) -> String { + var result = text as NSString + for match in matches(in: text).reversed() { result = result.replacingCharacters(in: match.range, with: "") as NSString } + return result as String + } + static func replacingPlainText(in stored: String, with text: String) -> String { + let markers = matches(in: stored).map(\.source) + guard !markers.isEmpty else { return text } + return text + markers.joined() + } + static func replacing(_ match: Match, in text: String, with citation: AssistantCitation?) -> String { + // Re-read the current draft: offsets from an open editor may have moved. + guard let current = matches(in: text).first(where: { $0.source == match.source }) else { return text } + return (text as NSString).replacingCharacters(in: current.range, with: citation?.marker ?? "") + } + + static func capture(text: String, range: NSRange, environmentId: String, threadId: String, messageId: String) -> Self? { + let raw = text as NSString + guard range.location >= 0, range.length > 0, range.location <= raw.length, range.length <= raw.length - range.location, + Range(range, in: text) != nil else { return nil } + func splitsSurrogate(_ offset: Int) -> Bool { + offset > 0 && offset < raw.length && (0xD800...0xDBFF).contains(raw.character(at: offset - 1)) + && (0xDC00...0xDFFF).contains(raw.character(at: offset)) + } + guard !splitsSurrogate(range.location), !splitsSurrogate(NSMaxRange(range)) else { return nil } + func normalize(_ value: String) -> String { value.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) } + let normalized = normalize(text) as NSString + var start = (normalize(raw.substring(to: range.location)) as NSString).length + if range.location > 0, + raw.substring(with: NSRange(location: range.location - 1, length: 1)).range(of: #"\s"#, options: .regularExpression) != nil, + raw.substring(with: NSRange(location: range.location, length: 1)).range(of: #"\s"#, options: .regularExpression) != nil { start -= 1 } + let end = (normalize(raw.substring(to: NSMaxRange(range))) as NSString).length + var prefixStart = max(0, start - 32) + var suffixEnd = min(normalized.length, end + 32) + if prefixStart > 0 && prefixStart < normalized.length && (0xDC00...0xDFFF).contains(normalized.character(at: prefixStart)) { prefixStart += 1 } + if suffixEnd > 0 && suffixEnd < normalized.length && (0xDC00...0xDFFF).contains(normalized.character(at: suffixEnd)) { suffixEnd -= 1 } + let citation = Self(version: 1, environmentId: environmentId, threadId: threadId, messageId: messageId, + text: raw.substring(with: range), start: start, end: end, + prefix: normalized.substring(with: NSRange(location: prefixStart, length: start - prefixStart)), + suffix: normalized.substring(with: NSRange(location: end, length: suffixEnd - end))) + return citation.isValid ? citation : nil + } +} diff --git a/apps/swift-ios/Features/Chat/AssistantCitationViews.swift b/apps/swift-ios/Features/Chat/AssistantCitationViews.swift new file mode 100644 index 000000000..f8b118f2d --- /dev/null +++ b/apps/swift-ios/Features/Chat/AssistantCitationViews.swift @@ -0,0 +1,235 @@ +import SwiftUI +import UIKit + +struct AssistantCitationContext { + let environmentId: String + let threadId: String + let onCite: (AssistantCitation) -> Void +} +private struct AssistantCitationContextKey: EnvironmentKey { + static let defaultValue: AssistantCitationContext? = nil +} +extension EnvironmentValues { + var assistantCitationContext: AssistantCitationContext? { + get { self[AssistantCitationContextKey.self] } + set { self[AssistantCitationContextKey.self] = newValue } + } +} + +struct AssistantCitationChips: View { + @Binding var text: String + @State private var editing: AssistantCitation.Match? + @SwiftUI.Environment(\.openURL) private var openURL + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(AssistantCitation.matches(in: text)) { match in + HStack(spacing: 0) { + Button { if let url = URL(string: match.citation.href) { openURL(url) } } label: { + Label(match.citation.comment?.isEmpty == false ? match.citation.comment! : match.citation.text, systemImage: "quote.bubble") + .lineLimit(1).frame(maxWidth: 180).padding(.leading, 12).padding(.trailing, 4) + }.accessibilityLabel("View quoted response: \(match.citation.text)") + Button { editing = match } label: { Image(systemName: "pencil").frame(width: 44, height: 44) } + .accessibilityLabel("Edit quote comment") + Button { text = AssistantCitation.replacing(match, in: text, with: nil) } label: { + Image(systemName: "xmark").frame(width: 44, height: 44) + }.accessibilityLabel("Remove quote") + }.font(T3Typography.supporting).buttonStyle(.plain) + .foregroundStyle(T3Colors.accent).background(T3Colors.subtleStrong, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + .sheet(item: $editing) { match in + NavigationStack { AssistantCitationCommentSheet(citation: match.citation) { citation in + text = AssistantCitation.replacing(match, in: text, with: citation) + } } + } + } +} + +struct AssistantCitationCommentSheet: View { + let citation: AssistantCitation + let onSave: (AssistantCitation) -> Void + @State private var comment: String + @SwiftUI.Environment(\.dismiss) private var dismiss + init(citation: AssistantCitation, onSave: @escaping (AssistantCitation) -> Void) { + self.citation = citation + self.onSave = onSave + _comment = State(initialValue: citation.comment ?? "") + } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Label("Assistant quote", systemImage: "quote.bubble").font(T3Typography.supportingStrong) + Text(citation.text).textSelection(.enabled).font(T3Typography.threadBody) + TextField("Add an optional comment", text: $comment, axis: .vertical) + .lineLimit(3...8).textFieldStyle(.roundedBorder) + if comment.utf16.count > 8_000 { Text("Comments can contain up to 8,000 characters.").foregroundStyle(T3Colors.danger) } + }.padding(18) + }.background(T3Colors.background).navigationTitle("Quote comment").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + var updated = citation + let trimmed = comment.trimmingCharacters(in: .whitespacesAndNewlines) + updated.comment = trimmed.isEmpty ? nil : trimmed + onSave(updated) + dismiss() + }.disabled(comment.utf16.count > 8_000) + } + } + } +} + +struct AssistantCitationSelectionSheet: View { + let text: String + let messageId: String + let context: AssistantCitationContext + @State private var range = NSRange(location: 0, length: 0) + @State private var quote: AssistantCitation? + @SwiftUI.Environment(\.dismiss) private var dismiss + private var selection: AssistantCitation? { + AssistantCitation.capture(text: text, range: range, environmentId: context.environmentId, threadId: context.threadId, messageId: messageId) + } + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + Text("Select the text you want to quote, then tap Cite.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary).padding(.horizontal, 18) + CitationSelectableText(text: text, range: $range) + }.padding(.top, 12).background(T3Colors.background).navigationTitle("Cite response").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Cite") { quote = selection }.disabled(selection == nil) } + } + .sheet(item: $quote) { citation in + NavigationStack { AssistantCitationCommentSheet(citation: citation) { updated in + context.onCite(updated) + dismiss() + } } + } + } + } +} + +private struct CitationSelectableText: UIViewRepresentable { + let text: String + @Binding var range: NSRange + func makeCoordinator() -> Coordinator { Coordinator(range: $range) } + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.isEditable = false + view.isSelectable = true + view.backgroundColor = .clear + view.textColor = .label + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.textContainerInset = UIEdgeInsets(top: 6, left: 18, bottom: 18, right: 18) + view.delegate = context.coordinator + view.text = text + return view + } + func updateUIView(_ view: UITextView, context: Context) { + context.coordinator.range = $range + if view.text != text { view.text = text } + } + final class Coordinator: NSObject, UITextViewDelegate { + var range: Binding + init(range: Binding) { self.range = range } + func textViewDidChangeSelection(_ textView: UITextView) { range.wrappedValue = textView.selectedRange } + } +} + +extension MarkdownRenderedDocument { + var citationText: String { Self.citationText(blocks) } + private static func citationText(_ blocks: [MarkdownRenderedBlock]) -> String { + blocks.map { block in + switch block { + case .paragraph(let inline), .heading(_, let inline): String(inline.attributedText.characters) + case .unorderedList(let items), .orderedList(_, let items): items.map { citationText($0.blocks) }.joined(separator: "\n") + case .blockquote(let nested), .githubAlert(_, let nested): citationText(nested) + case .table(let table): ([table.header] + table.rows).map { $0.map { String($0.attributedText.characters) }.joined(separator: "\t") }.joined(separator: "\n") + case .codeBlock(_, let code): code + case .image, .htmlEmbed, .artifactTemplate, .thematicBreak: "" + } + }.joined(separator: "\n") + } +} + +struct AssistantCitationPreview: View { + let citation: AssistantCitation + let onOpenSource: () -> Void + @SwiftUI.Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Label("Assistant quote", systemImage: "quote.bubble").font(T3Typography.supportingStrong) + Text(citation.text).font(T3Typography.threadBody).textSelection(.enabled) + if let comment = citation.comment, !comment.isEmpty { + Divider() + Text("Your comment").font(T3Typography.supportingStrong) + Text(comment).font(T3Typography.threadBody).textSelection(.enabled) + } + Button("View source response", systemImage: "arrow.up.forward") { dismiss(); onOpenSource() } + .frame(minHeight: 44) + }.padding(18) + }.background(T3Colors.background).navigationTitle("Quoted response").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + } + } +} + +struct AssistantCitationNavigationRequest: Equatable { + let id = UUID() + let citation: AssistantCitation +} + +struct CitationAwareMessageText: View { + let source: String + let isStreaming: Bool + @SwiftUI.Environment(\.openURL) private var openURL + private struct Segment: Identifiable { + let id: Int + let text: String + let citation: AssistantCitation? + } + private var segments: [Segment] { + let matches = AssistantCitation.matches(in: source) + guard !matches.isEmpty else { return [Segment(id: 0, text: source, citation: nil)] } + let raw = source as NSString + var cursor = 0 + var result: [Segment] = [] + for match in matches { + if match.range.location > cursor { + result.append(Segment(id: cursor, text: raw.substring(with: NSRange(location: cursor, length: match.range.location - cursor)), citation: nil)) + } + result.append(Segment(id: match.range.location, text: "", citation: match.citation)) + cursor = NSMaxRange(match.range) + } + if cursor < raw.length { result.append(Segment(id: cursor, text: raw.substring(from: cursor), citation: nil)) } + return result + } + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(segments) { segment in + if let citation = segment.citation { + Button { if let url = URL(string: citation.href) { openURL(url) } } label: { + VStack(alignment: .leading, spacing: 8) { + Label("Assistant quote", systemImage: "quote.bubble").font(T3Typography.supportingStrong).foregroundStyle(T3Colors.accent) + Text(citation.text).font(T3Typography.threadBody).lineLimit(4).foregroundStyle(T3Colors.textPrimary) + if let comment = citation.comment, !comment.isEmpty { + Text(comment).font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + Label("View source", systemImage: "arrow.up.forward").font(T3Typography.supporting).foregroundStyle(T3Colors.accent) + }.frame(maxWidth: .infinity, alignment: .leading).padding(12) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 12)) + }.buttonStyle(.plain) + } else if !segment.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MarkdownMessageView(segment.text, isStreaming: isStreaming) + } + } + } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 9f0fae00c..7cc753850 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -30,7 +30,12 @@ struct FeatureComposerView: View { private let voice = VoiceComposerCoordinator.shared @State private var caret = VoiceComposerCaret() @SwiftUI.Environment(\.accessibilityReduceMotion) private var reduceMotion - @Binding private var text: String + @Binding private var storedText: String + private var text: String { + get { AssistantCitation.removingMarkers(from: storedText) } + nonmutating set { storedText = AssistantCitation.replacingPlainText(in: storedText, with: newValue) } + } + private var textBinding: Binding { Binding(get: { text }, set: { text = $0 }) } @Binding private var selection: FeatureSelection? @Binding private var attachments: [FeatureDraftAttachment] /// The thread's Plan/Build mode, or nil on a surface that has no mode to @@ -97,7 +102,7 @@ struct FeatureComposerView: View { onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? = nil, onUserInputSubmit: ((String, [String: FeatureInputAnswer], [String: [FeatureUploadAttachment]], Bool) -> Void)? = nil ) { - _text = text + _storedText = text _selection = selection _attachments = attachments self.interactionMode = interactionMode @@ -126,7 +131,12 @@ struct FeatureComposerView: View { } var body: some View { - composerSurface + VStack(spacing: 8) { + if !AssistantCitation.matches(in: storedText).isEmpty { + AssistantCitationChips(text: $storedText).disabled(isSending || isStashing) + } + composerSurface + } .task(id: historyDraftKey) { historyGeneration = UUID() promptHistory = ComposerPromptHistory() @@ -509,7 +519,7 @@ struct FeatureComposerView: View { private var inputRow: some View { TextField( isWorking ? "Message to queue…" : "Ask anything…", - text: $text, + text: textBinding, axis: .vertical ) .disabled(isStashing) @@ -517,9 +527,9 @@ struct FeatureComposerView: View { guard press.modifiers.isEmpty, historyAvailable, caret.canRecallHistory(backward: press.key == .upArrow), let recalled = promptHistory.step(backward: press.key == .upArrow, - entries: ComposerPromptHistory.entries(historyMessages()), current: text) else { return .ignored } - text = recalled - caret.moveCaret(to: recalled.utf16.count) + entries: ComposerPromptHistory.entries(historyMessages()), current: storedText) else { return .ignored } + storedText = recalled + caret.moveCaret(to: text.utf16.count) return .handled } .font(T3Typography.composer) @@ -638,8 +648,8 @@ struct FeatureComposerView: View { private var historyMenu: some View { Menu { if let historyDraftKey { - Button(stashedDraft == nil ? "Stash draft" : (text.isEmpty && attachments.isEmpty ? "Restore stashed draft" : "Swap with stashed draft")) { - let current = FeatureComposerDraft(text: text, attachments: attachments) + Button(stashedDraft == nil ? "Stash draft" : (storedText.isEmpty && attachments.isEmpty ? "Restore stashed draft" : "Swap with stashed draft")) { + let current = FeatureComposerDraft(text: storedText, attachments: attachments) let generation = historyGeneration isStashing = true Task { @@ -649,22 +659,22 @@ struct FeatureComposerView: View { let restored = try await historyDraftStore.swapStash(current, for: historyDraftKey) guard generation == historyGeneration else { return } stashedDraft = current.text.isEmpty && current.attachments.isEmpty ? nil : current - text = restored.text + storedText = restored.text attachments = restored.attachments promptHistory = ComposerPromptHistory() } catch { historyError = error.localizedDescription } } - }.disabled(stashedDraft == nil && text.isEmpty && attachments.isEmpty) + }.disabled(stashedDraft == nil && storedText.isEmpty && attachments.isEmpty) } let entries = ComposerPromptHistory.entries(historyMessages()) if !entries.isEmpty { Section("Recent prompts") { ForEach(entries.suffix(20).reversed()) { entry in - Button(String(entry.prompt.prefix(100))) { - text = promptHistory.select(entry) + Button(String(AssistantCitation.plainText(entry.prompt).prefix(100))) { + storedText = promptHistory.select(entry) focused.wrappedValue = true caret.moveCaret(to: text.utf16.count) - }.disabled(!text.isEmpty && text != promptHistory.position?.prompt) + }.disabled(!storedText.isEmpty && storedText != promptHistory.position?.prompt) } } } else { Text("No sent prompts in this thread") } @@ -875,13 +885,13 @@ struct FeatureComposerView: View { } private var textIsEmpty: Bool { - text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + storedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var canSend: Bool { guard composerTrigger?.kind != .model else { return false } return FeatureComposerSubmissionEligibility.canSend( - text: text, + text: storedText, attachmentCount: attachments.count, imagesAllowed: imagesAllowed, isSending: isSending, diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift index 2a83c269a..3feae085a 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -9,6 +9,9 @@ struct MarkdownMessageView: View { } private let source: String + private let citationMessageID: String? + @State private var isCiting = false + @SwiftUI.Environment(\.assistantCitationContext) private var citationContext private let revision: MarkdownContentRevision private let isStreaming: Bool @State private var renderedDocument: MarkdownRenderedDocument? @@ -17,8 +20,9 @@ struct MarkdownMessageView: View { @State private var previewTarget: PullRequestLinkTarget? @SwiftUI.Environment(\.markdownPullRequestContext) private var pullRequestContext - init(_ source: String, isStreaming: Bool = false) { + init(_ source: String, isStreaming: Bool = false, citationMessageID: String? = nil) { self.source = source + self.citationMessageID = citationMessageID self.isStreaming = isStreaming let revision = MarkdownContentRevision(source) self.revision = revision @@ -48,6 +52,9 @@ struct MarkdownMessageView: View { } .modifier(MarkdownTextSelectionModifier(isEnabled: isSelectingText)) .contextMenu { + if citationMessageID != nil, citationContext != nil, !isStreaming { + Button("Cite text", systemImage: "quote.bubble") { isCiting = true } + } if pullRequestContext != nil { ForEach(PullRequestLinkTarget.links(in: source)) { target in Button("Preview pull request #\(String(target.number))", systemImage: "arrow.triangle.pull") { @@ -69,6 +76,11 @@ struct MarkdownMessageView: View { Label("Copy message", systemImage: "doc.on.doc") } } + .sheet(isPresented: $isCiting) { + if let citationContext, let citationMessageID { + AssistantCitationSelectionSheet(text: displayDocument?.citationText ?? source, messageId: citationMessageID, context: citationContext) + } + } .sheet(item: $previewTarget) { target in if let pullRequestContext { PullRequestLinkPreview(target: target, context: pullRequestContext) } } diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 5dfd01acc..7eca69aee 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -19,6 +19,8 @@ public struct ThreadDetailView: View { @SwiftUI.Environment(\.openURL) private var openURL + @State private var citationPreview: AssistantCitation? + @State private var citationError: String? @State private var draft = "" @State private var attachments: [FeatureDraftAttachment] = [] @State private var bannerHeight: CGFloat = 0 @@ -130,6 +132,12 @@ public struct ThreadDetailView: View { model.releaseThread(thread.id) persistDraftBeforeLeaving() } + .sheet(item: $citationPreview) { citation in + AssistantCitationPreview(citation: citation) { openCitationSource(citation) } + } + .alert("Quoted response", isPresented: Binding(get: { citationError != nil }, set: { if !$0 { citationError = nil } })) { + Button("OK") { citationError = nil } + } message: { Text(citationError ?? "") } .sheet(item: $restoreRequest) { request in CheckpointRestoreSheet( request: request, @@ -464,6 +472,21 @@ public struct ThreadDetailView: View { onOpenFile: openFile, onOpenURL: { openURL($0) }, onOpenDiff: openDiff, + citationNavigation: model.pendingAssistantCitation.flatMap { request in + request.citation.threadId == (thread.wireID ?? thread.id) && request.citation.environmentId == threadEnvironment?.id ? request : nil + }, + onCitationComplete: { request, error in + guard model.pendingAssistantCitation?.id == request.id else { return } + model.pendingAssistantCitation = nil + citationError = error + }, + onOpenCitation: { citationPreview = $0 }, + citationContext: threadEnvironment?.supportsAssistantCitations == true ? AssistantCitationContext( + environmentId: threadEnvironment?.id ?? "", threadId: thread.wireID ?? thread.id, + onCite: { citation in + guard !isSending else { return } + draft += (draft.isEmpty || draft.last?.isWhitespace == true ? "" : " ") + citation.marker + }) : nil, onUseTemplate: { template in guard !isSending else { return } let prompt = template.prompt @@ -682,6 +705,10 @@ public struct ThreadDetailView: View { } ) .simultaneousGesture(composerKeyboardDismissGesture) + .environment(\.openURL, OpenURLAction { url in + if let citation = AssistantCitation.parse(url.absoluteString) { citationPreview = citation; return .handled } + return .systemAction + }) } private var composerKeyboardDismissGesture: some Gesture { @@ -945,6 +972,17 @@ public struct ThreadDetailView: View { /// Routes a thread id from a timeline row. Whether the target is archived /// decides which stack the navigator pushes onto, and the snapshot is the /// only place this view can learn that. + private func openCitationSource(_ citation: AssistantCitation) { + guard let target = model.snapshot.threads.first(where: { + ($0.wireID ?? $0.id) == citation.threadId && $0.environmentID == citation.environmentId + }) else { + citationError = "The source thread is not available in your connected environments. The saved quote is unchanged." + return + } + model.pendingAssistantCitation = AssistantCitationNavigationRequest(citation: citation) + if target.id != thread.id { openRelatedThread(target.id) } + } + private func openRelatedThread(_ threadID: String) { let isArchived = model.snapshot.threads.first { $0.id == threadID }?.isArchived ?? false onOpenRelatedThread(threadID, isArchived) @@ -1639,6 +1677,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { let onOpenFile: (ThreadActivityFileOpenRequest) -> Void let onOpenURL: (URL) -> Void let onOpenDiff: (String, String?) -> Void + var citationNavigation: AssistantCitationNavigationRequest? = nil + var onCitationComplete: (AssistantCitationNavigationRequest, String?) -> Void = { _, _ in } + var onOpenCitation: (AssistantCitation) -> Void = { _ in } + var citationContext: AssistantCitationContext? = nil var onUseTemplate: (CodexArtifactTemplate) -> Void = { _ in } var navigationRequest: Int = 0 @@ -1701,6 +1743,8 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { onOpenFile: onOpenFile, onOpenURL: onOpenURL, onOpenDiff: onOpenDiff, + onOpenCitation: onOpenCitation, + citationContext: citationContext, onUseTemplate: onUseTemplate ), onLoadEarlier: onLoadEarlier, @@ -1708,6 +1752,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { in: collectionView ) context.coordinator.navigate(request: navigationRequest, in: collectionView) + context.coordinator.navigateCitation(citationNavigation, completion: onCitationComplete, in: collectionView) } private static func makeLayout() -> UICollectionViewLayout { @@ -1765,12 +1810,55 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { var onOpenFile: (ThreadActivityFileOpenRequest) -> Void = { _ in } var onOpenURL: (URL) -> Void = { _ in } var onOpenDiff: (String, String?) -> Void = { _, _ in } + var onOpenCitation: (AssistantCitation) -> Void = { _ in } + var citationContext: AssistantCitationContext? var onUseTemplate: (CodexArtifactTemplate) -> Void = { _ in } } private var dataSource: UICollectionViewDiffableDataSource? private var entriesByID: [String: ThreadTimelineEntry] = [:] private var orderedIDs: [String] = [] + private var citationRequest: AssistantCitationNavigationRequest? + private var citationCompletion: (AssistantCitationNavigationRequest, String?) -> Void = { _, _ in } + private var citationPages = Set() + private var citationSawLoading = false + private var applyingSnapshot = false + + func navigateCitation(_ request: AssistantCitationNavigationRequest?, completion: @escaping (AssistantCitationNavigationRequest, String?) -> Void, in collectionView: UICollectionView) { + if citationRequest?.id != request?.id { citationPages = []; citationSawLoading = false } + citationRequest = request + citationCompletion = completion + DispatchQueue.main.async { [weak self, weak collectionView] in + guard let self, let collectionView else { return } + self.revealCitation(in: collectionView) + } + } + + private func revealCitation(in collectionView: UICollectionView) { + guard let request = citationRequest, !applyingSnapshot, let dataSource else { return } + let citation = request.citation + if let entryID = orderedIDs.first(where: { + guard case let .message(message) = entriesByID[$0] else { return false } + return (message.wireMessageID ?? message.id) == citation.messageId && message.role == .assistant + }), let path = dataSource.indexPath(for: entryID) { + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false + collectionView.layoutIfNeeded() + collectionView.scrollToItem(at: path, at: .top, animated: !UIAccessibility.isReduceMotionEnabled) + UIAccessibility.post(notification: .announcement, argument: "Quoted response: \(citation.text)") + citationRequest = nil + citationCompletion(request, nil) + return + } + if currentIsLoadingEarlier { citationSawLoading = true; return } + let page = orderedIDs.first ?? "empty" + if currentCanLoadEarlier && citationPages.count < 20 { + if citationPages.insert(page).inserted { citationSawLoading = false; onLoadEarlier?(); return } + if !citationSawLoading { return } + } + citationRequest = nil + citationCompletion(request, "The source response could not be loaded. Load earlier turns and try again. Your saved quote is unchanged.") + } + private var lastNavigationRequest = 0 private var pendingPreviousTurn = false @@ -1860,8 +1948,13 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { .id(entryID) .environment(\.markdownMediaContext, context.markdownMedia) .environment(\.markdownPullRequestContext, context.pullRequests) + .environment(\.assistantCitationContext, context.citationContext) .environment(\.markdownTemplateAction, context.onUseTemplate) .environment(\.openURL, OpenURLAction { url in + if let citation = AssistantCitation.parse(url.absoluteString) { + context.onOpenCitation(citation) + return .handled + } guard let target = CodexMarkdownDirectives.fileTarget(url) else { return .systemAction } let root = context.workspaceRoot.map { $0.hasSuffix("/") ? $0 : $0 + "/" } let path = root.map { target.path.hasPrefix($0) ? String(target.path.dropFirst($0.count)) : target.path } ?? target.path @@ -2004,9 +2097,11 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { snapshot.reconfigureItems(reconfiguredIDs) } + applyingSnapshot = true dataSource.apply(snapshot, animatingDifferences: false) { [weak self, weak collectionView] in guard let self, let collectionView else { return } + self.applyingSnapshot = false DispatchQueue.main.async { if shouldFollowBottom { self.scrollToBottom( @@ -2016,6 +2111,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } else if let prependAnchor { self.restore(prependAnchor, in: collectionView, dataSource: dataSource) } + self.revealCitation(in: collectionView) if self.pendingPreviousTurn && !self.currentIsLoadingEarlier { self.pendingPreviousTurn = false self.navigateTurn(forward: false, in: collectionView, allowLoad: false) @@ -2198,6 +2294,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if let request = citationRequest { + citationRequest = nil + citationCompletion(request, nil) + } (scrollView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false scrollView.window?.endEditing(false) onDismissKeyboard?() @@ -2590,8 +2690,8 @@ struct FeatureMessageView: View { VStack(alignment: .leading, spacing: 10) { FeatureMessageAttachmentsView(attachments: message.attachments) if !message.text.isEmpty { - MarkdownMessageView( - message.text, + CitationAwareMessageText( + source: message.text, isStreaming: message.state == .streaming ) } @@ -2618,8 +2718,8 @@ struct FeatureMessageView: View { VStack(alignment: .leading, spacing: 10) { FeatureMessageAttachmentsView(attachments: message.attachments) if !message.text.isEmpty { - MarkdownMessageView( - message.text, + CitationAwareMessageText( + source: message.text, isStreaming: message.state == .streaming ) } @@ -2654,7 +2754,8 @@ struct FeatureMessageView: View { if !message.text.isEmpty { MarkdownMessageView( message.text, - isStreaming: message.state == .streaming + isStreaming: message.state == .streaming, + citationMessageID: message.wireMessageID ) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index 11b45ce42..8130d2755 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -15,6 +15,8 @@ struct FeatureDetailRenderUpdate: Equatable { @MainActor @Observable public final class FeatureRootModel { + var pendingAssistantCitation: AssistantCitationNavigationRequest? + public private(set) var snapshot = FeatureSnapshot() public private(set) var details: [String: FeatureThreadDetail] = [:] /// Advances whenever a Home presentation input changes. diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index f8aecd3f1..bf41f2925 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -41,6 +41,7 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C public var supportsPullRequests: Bool? public var machineKind: String? public var supportsEnvironmentIcon: Bool? + public var supportsAssistantCitations: Bool? = nil public var supportsCustomModelDefinitions: Bool? = nil public var machineSymbol: String { EnvironmentMachineKind(rawValue: machineKind ?? "")?.symbol ?? "server.rack" } @@ -54,6 +55,7 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C supportsPullRequests: Bool? = nil, machineKind: String? = nil, supportsEnvironmentIcon: Bool? = nil, + supportsAssistantCitations: Bool? = nil, supportsCustomModelDefinitions: Bool? = nil ) { self.id = id @@ -65,6 +67,7 @@ public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, C self.supportsPullRequests = supportsPullRequests self.machineKind = machineKind self.supportsEnvironmentIcon = supportsEnvironmentIcon + self.supportsAssistantCitations = supportsAssistantCitations self.supportsCustomModelDefinitions = supportsCustomModelDefinitions } } @@ -494,6 +497,8 @@ public struct FeatureUploadAttachment: Sendable, Equatable { public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codable { public let id: String + /// Citation links use the durable message ID, while recycled rows use the turn-item ID. + public var wireMessageID: String? = nil public var role: FeatureMessageRole public var text: String public var createdAt: Date @@ -513,9 +518,11 @@ public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codab state: FeatureMessageState = .complete, toolName: String? = nil, attachments: [FeatureMessageAttachment] = [], - createdBy: String? = nil + createdBy: String? = nil, + wireMessageID: String? = nil ) { self.id = id + self.wireMessageID = wireMessageID self.role = role self.text = text self.createdAt = createdAt diff --git a/apps/swift-ios/Tests/CoreTests/AssistantCitationFixtureTests.swift b/apps/swift-ios/Tests/CoreTests/AssistantCitationFixtureTests.swift new file mode 100644 index 000000000..7baf99993 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/AssistantCitationFixtureTests.swift @@ -0,0 +1,14 @@ +import Foundation +import Testing +@testable import T3Code + +struct AssistantCitationFixtureTests { + @Test func decodesTheContractAndURLTogether() throws { + struct Fixture: Decodable { let citation: AssistantCitation; let href: String } + let file = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Fixtures/assistantCitation.json") + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: file)) + #expect(fixture.citation.isValid) + #expect(fixture.citation.href == fixture.href) + #expect(AssistantCitation.parse(fixture.href) == fixture.citation) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift b/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift index 8f276a745..0a996871e 100644 --- a/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift +++ b/apps/swift-ios/Tests/CoreTests/EnvironmentMachineTests.swift @@ -9,6 +9,8 @@ struct EnvironmentMachineTests { let descriptor = try JSONDecoder().decode(EnvironmentDescriptor.self, from: Data(contentsOf: fixture)) #expect(descriptor.platform.machine == "mac-studio") #expect(descriptor.capabilities.environmentIcon == true) + #expect(descriptor.capabilities.assistantCitations == true) + #expect(descriptor.capabilities.customModelDefinitions == true) #expect(EnvironmentMachineKind(rawValue: descriptor.platform.machine!)?.symbol == "macstudio") } diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/assistantCitation.json b/apps/swift-ios/Tests/CoreTests/Fixtures/assistantCitation.json new file mode 100644 index 000000000..77e03e31a --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/assistantCitation.json @@ -0,0 +1,15 @@ +{ + "citation": { + "version": 1, + "environmentId": "environment/remote", + "threadId": "thread:one", + "messageId": "assistant?one", + "text": "Use `cache[key]` 🚀", + "comment": "Why?", + "start": 0, + "end": 19, + "prefix": "", + "suffix": "" + }, + "href": "t3-citation://v1/environment%2Fremote/thread%3Aone/assistant%3Fone?text=Use+%60cache%5Bkey%5D%60+%F0%9F%9A%80&start=0&end=19&prefix=&suffix=&comment=Why%3F" +} diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json b/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json index 669567432..b98668521 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/environmentMachine.json @@ -10,6 +10,7 @@ "capabilities": { "repositoryIdentity": true, "environmentIcon": true, + "assistantCitations": true, "customModelDefinitions": true } } diff --git a/apps/swift-ios/Tests/FeatureTests/AssistantCitationTests.swift b/apps/swift-ios/Tests/FeatureTests/AssistantCitationTests.swift new file mode 100644 index 000000000..7e05df1af --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/AssistantCitationTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import T3Code + +struct AssistantCitationTests { + private let link = "t3-citation://v1/environment%2Fremote/thread%3Aone/assistant%3Fone?text=Use+%60cache%5Bkey%5D%60+%F0%9F%9A%80&start=0&end=19&prefix=&suffix=&comment=Why%3F" + + @Test func webLinkRoundTripsExactly() throws { + let citation = try #require(AssistantCitation.parse(link)) + #expect(citation.environmentId == "environment/remote") + #expect(citation.text == "Use `cache[key]` 🚀") + #expect(citation.comment == "Why?") + #expect(citation.href == link) + #expect(AssistantCitation.matches(in: citation.marker).first?.citation == citation) + } + + @Test func refusesAmbiguousOrOversizedLinks() { + #expect(AssistantCitation.parse(link + "&comment=duplicate") == nil) + #expect(AssistantCitation.parse(link + "#fragment") == nil) + #expect(AssistantCitation.parse(link.replacingOccurrences(of: "&end=19", with: "&end=0")) == nil) + #expect(AssistantCitation.parse(link.replacingOccurrences(of: "Why%3F", with: String(repeating: "a", count: 8001))) == nil) + #expect(AssistantCitation.parse(link.replacingOccurrences(of: "environment%2Fremote", with: "%ZZ")) == nil) + } + + @Test func voiceEditsKeepQuoteDataWithoutMovingTheVisibleCaret() throws { + let citation = try #require(AssistantCitation.parse(link)) + let stored = "Explain " + citation.marker + let edited = AssistantCitation.replacingPlainText(in: stored, with: "Explain this") + #expect(AssistantCitation.removingMarkers(from: edited) == "Explain this") + #expect(AssistantCitation.matches(in: edited).first?.citation == citation) + let next = AssistantCitation.replacingPlainText(in: edited, with: "Explain this now") + #expect(AssistantCitation.removingMarkers(from: next) == "Explain this now") + let match = try #require(AssistantCitation.matches(in: stored).first) + var updated = citation + updated.comment = "Updated" + let moved = "Prefix " + stored + let replaced = AssistantCitation.replacing(match, in: moved, with: updated) + #expect(AssistantCitation.matches(in: replaced).first?.citation.comment == "Updated") + #expect(replaced.hasPrefix("Prefix Explain ")) + #expect(AssistantCitation.replacing(match, in: moved, with: nil) == "Prefix Explain ") + } + + @Test func selectionUsesNormalizedUTF16AndPreservesQuotedLineBreaks() throws { + let source = "Before.\nUse 🚀\n carefully.\nAfter." + let raw = source as NSString + let range = raw.range(of: "Use 🚀\n carefully.") + let citation = try #require(AssistantCitation.capture(text: source, range: range, environmentId: "e", threadId: "t", messageId: "m")) + #expect(citation.text == "Use 🚀\n carefully.") + #expect(citation.prefix == "Before. ") + #expect(citation.suffix == " After.") + #expect(citation.start == 8) + #expect(citation.end == 25) + #expect(AssistantCitation.parse(citation.href) == citation) + #expect(AssistantCitation.capture(text: "🚀", range: NSRange(location: 0, length: 1), environmentId: "e", threadId: "t", messageId: "m") == nil) + } +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c483fcbcf..c00b9d19e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,5 @@ +import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; +import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { CodexArtifactTemplateCard } from "./CodexArtifactTemplateCard"; @@ -261,7 +263,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, protocols: { ...defaultSchema.protocols, - href: [...(defaultSchema.protocols?.href ?? []), "file"], + href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation"], src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -1868,6 +1870,8 @@ function createChatMarkdownComponents(context: ChatMarkdownComponentsContext): C ); }, a({ node, href, children, title: _title, ...props }) { + const citation = href ? parseAssistantCitationHref(href) : null; + if (citation) return ; const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; // The href map is built by regex-scanning the markdown source, which // misses destinations the regex can't express (spaces, parentheses); @@ -2213,6 +2217,7 @@ function ChatMarkdown({ return buildFileLinkParentSuffixByPath(filePaths); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { + if (parseAssistantCitationHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); // Re-emit highlighted content as markdown so copying out of the rendered diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index da336fc28..e5162387b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,7 @@ +import type { AssistantCitation } from "@t3tools/contracts"; +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { assistantCitationFromLocation } from "~/lib/assistantCitationNavigation"; +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { resolveEnvironmentMachineKind, type EnvironmentMachineKind } from "@t3tools/contracts"; import { ComposerTasksContent } from "./chat/ComposerTasksBadge"; import { @@ -83,7 +87,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useLocation } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, @@ -1428,6 +1432,31 @@ function ChatViewContent(props: ChatViewProps) { const alwaysExpandActivity = settings.alwaysExpandActivity; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); + const citationLocation = useLocation({ + select: (location) => ({ + href: location.href, + key: location.state.assistantCitationActivation ?? location.state.__TSR_key, + }), + }); + const citationRequest = useMemo(() => { + const citation = assistantCitationFromLocation(citationLocation.href); + return citation && + citation.environmentId === routeThreadRef.environmentId && + citation.threadId === routeThreadRef.threadId + ? { citation, key: citationLocation.key ?? citationLocation.href } + : null; + }, [ + citationLocation.href, + citationLocation.key, + routeThreadRef.environmentId, + routeThreadRef.threadId, + ]); + const citeAssistantText = useCallback( + (citation: AssistantCitation, anchor: AssistantCitationSourceAnchor) => + composerRef.current?.citeAssistantText(citation, anchor) ?? false, + [], + ); + const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -6451,7 +6480,7 @@ function ChatViewContent(props: ChatViewProps) { firstComposerAttachmentName = firstComposerAttachment.name; } } - let titleSeed = trimmed; + let titleSeed = assistantCitationsToPlainText(trimmed); if (!titleSeed) { if (firstComposerAttachmentName) { titleSeed = `Attachment: ${firstComposerAttachmentName}`; @@ -7658,6 +7687,11 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} ; + +export type ComposerCitationCommentRequest = { + previousValue: string; + value: string; + citationStart: number; + sourceAnchor: AssistantCitationSourceAnchor; +}; + +export type ComposerCitationCommentTarget = { + nodeKey: NodeKey; + sourceAnchor?: AssistantCitationSourceAnchor; + removeOnCancel?: boolean; +}; + +export const ComposerCitationCommentContext = createContext<{ + openComment: ComposerCitationCommentTarget | null; + onOpenChange: (nodeKey: NodeKey, open: boolean) => void; + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); + +/** Consume a cite action once its controlled prompt has been committed to the editor. */ +export function $consumeComposerCitationCommentRequest(requestRef: { + current: ComposerCitationCommentRequest | null; +}): ComposerCitationCommentTarget | null { + const request = requestRef.current; + if (!request) return null; + const root = $getRoot(); + const value = root.getTextContent(); + if (value === request.previousValue) return null; + requestRef.current = null; + if (value !== request.value) return null; + + // Controlled prompts use one paragraph with inline nodes and explicit line breaks. + const paragraph = root.getFirstChild(); + if (!$isElementNode(paragraph)) return null; + let offset = 0; + for (const node of paragraph.getChildren()) { + if (offset === request.citationStart && node instanceof ComposerCitationNode) { + return { + nodeKey: node.getKey(), + sourceAnchor: request.sourceAnchor, + removeOnCancel: true, + }; + } + offset += node.getTextContentSize(); + } + return null; +} + +function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) { + const [editor] = useLexicalComposerContext(); + const commentContext = use(ComposerCitationCommentContext); + const commentTarget = + commentContext.openComment?.nodeKey === props.nodeKey ? commentContext.openComment : null; + const onSaveComment = (comment: string): boolean => { + if (!editor.isEditable()) return false; + let accepted = false; + editor.update( + () => { + const node = $getNodeByKey(props.nodeKey); + if (node instanceof ComposerCitationNode && node.isAttached()) { + node.setComment(comment); + accepted = true; + } + }, + { discrete: true, tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] }, + ); + return accepted; + }; + const onRemove = () => { + if (!editor.isEditable()) return; + editor.update( + () => { + const node = $getNodeByKey(props.nodeKey); + if (node instanceof ComposerCitationNode) { + node.selectPrevious(); + node.remove(); + } + }, + { tag: HISTORY_PUSH_TAG }, + ); + editor.getRootElement()?.focus({ preventScroll: true }); + }; + + return ( + + { + if (open && !editor.isEditable()) return; + commentContext.onOpenChange(props.nodeKey, open); + }, + ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, + }} + onRemove={onRemove} + /> + + ); +} + +export class ComposerCitationNode extends DecoratorNode { + __citation: AssistantCitation; + __source: string; + + static override getType(): string { + return "composer-citation"; + } + + static override clone(node: ComposerCitationNode): ComposerCitationNode { + return new ComposerCitationNode(node.__citation, node.__source, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerCitationNode): ComposerCitationNode { + return $createComposerCitationNode( + serializedNode.citation, + serializedNode.source, + ).updateFromJSON(serializedNode); + } + + constructor(citation: AssistantCitation, source: string, key?: NodeKey) { + super(key); + this.__citation = citation; + this.__source = source; + } + + override exportJSON(): SerializedComposerCitationNode { + const node = this.getLatest(); + return { + ...super.exportJSON(), + citation: node.__citation, + source: node.__source, + type: "composer-citation", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`; + return dom; + } + + override updateDOM(): false { + return false; + } + + override getTextContent(): string { + return this.getLatest().__source; + } + + setComment(comment: string): this { + const latest = this.getLatest(); + const citation = withAssistantCitationComment(latest.__citation, comment); + if (citation.comment === latest.__citation.comment) return latest; + const source = serializeAssistantCitation(citation); + const writable = this.getWritable(); + writable.__citation = citation; + writable.__source = source; + return writable; + } + + override isInline(): true { + return true; + } + + override decorate(): ReactElement { + return ; + } +} + +export function $createComposerCitationNode( + citation: AssistantCitation, + source: string, +): ComposerCitationNode { + return $applyNodeReplacement(new ComposerCitationNode(citation, source)); +} diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx index 980521084..578c5c6d4 100644 --- a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx @@ -1,3 +1,5 @@ +import { ComposerCitationNode } from "./ComposerCitationNode"; +import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical"; import { act, createRef } from "react"; @@ -182,3 +184,30 @@ describe("composer mention serialization", () => { expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); }); }); + +describe("composer citation serialization", () => { + it("keeps the quote through comment edits, export and reload", async () => { + const href = "t3-citation://v1/e/t/m?text=Selected+answer&start=0&end=15&prefix=&suffix="; + await renderPrompt(`[Assistant quote](${href}) explain`); + const firstCitation = () => { + const paragraph = $getRoot().getFirstChild(); + if (!$isElementNode(paragraph)) throw new Error("Missing paragraph"); + const citation = paragraph.getFirstChild(); + if (!(citation instanceof ComposerCitationNode)) throw new Error("Missing citation"); + return citation; + }; + await act(() => { + lexicalEditor.update(() => firstCitation().setComment("Why this?"), { discrete: true }); + }); + const text = editorRef.current!.readSnapshot().value; + const source = lexicalEditor.getEditorState().read(() => firstCitation().getTextContent()); + expect(parseAssistantCitationHref(source.slice("[Assistant quote](".length, -1))).toMatchObject( + { text: "Selected answer", comment: "Why this?" }, + ); + expect(text).toBe(`${source} explain`); + const exported = lexicalEditor.getEditorState().toJSON(); + await renderPrompt(""); + await act(() => lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exported))); + expect(editorRef.current?.readSnapshot().value).toBe(text); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0aab8fb0c..97b285f04 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -49,6 +49,7 @@ describe("registerComposerInlineTokenPaste", () => { { discrete: true }, ); registerComposerInlineTokenPaste(editor, { + createCitationNode: (_citation, source) => $createTextNode(source), createMentionNode: (path) => $createTextNode(``), getExpandedAbsoluteOffsetForPoint: () => 0, }); @@ -94,6 +95,7 @@ describe("registerComposerInlineTokenPaste", () => { { discrete: true }, ); registerComposerInlineTokenPaste(editor, { + createCitationNode: (_citation, source) => $createTextNode(source), createMentionNode: (path) => $createTextNode(``), getExpandedAbsoluteOffsetForPoint: () => 0, }); @@ -128,6 +130,7 @@ describe("registerComposerInlineTokenPaste", () => { { discrete: true }, ); registerComposerInlineTokenPaste(editor, { + createCitationNode: (_citation, source) => $createTextNode(source), createMentionNode: (path) => $createTextNode(``), getExpandedAbsoluteOffsetForPoint: () => 0, }); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1c578bf90..ecacb45a0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,3 +1,11 @@ +import { + $consumeComposerCitationCommentRequest, + $createComposerCitationNode, + ComposerCitationCommentContext, + ComposerCitationNode, + type ComposerCitationCommentRequest, + type ComposerCitationCommentTarget, +} from "./ComposerCitationNode"; import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; @@ -11,6 +19,9 @@ import { $applyNodeReplacement, $createRangeSelectionFromDom, $createRangeSelection, + $getNodeByKey, + HISTORY_PUSH_TAG, + SKIP_DOM_SELECTION_TAG, $getSelection, $setSelection, $isElementNode, @@ -52,6 +63,7 @@ import { useLayoutEffect, useMemo, useRef, + useState, } from "react"; import { @@ -432,12 +444,14 @@ function $createComposerTerminalContextNode( } type ComposerInlineTokenNode = + | ComposerCitationNode | ComposerMentionNode | ComposerSkillNode | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( + candidate instanceof ComposerCitationNode || candidate instanceof ComposerMentionNode || candidate instanceof ComposerSkillNode || candidate instanceof ComposerTerminalContextNode @@ -836,6 +850,10 @@ function $setComposerEditorPrompt( const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { + if (segment.type === "citation") { + paragraph.append($createComposerCitationNode(segment.citation, segment.source)); + continue; + } if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path, segment.source)); continue; @@ -872,6 +890,7 @@ function collectTerminalContextIds(node: LexicalNode): string[] { } export interface ComposerPromptEditorHandle { + requestCitationComment: (request: ComposerCitationCommentRequest) => void; isCaretOnVisualEdge: (edge: "start" | "end") => boolean; focus: () => void; focusAt: (cursor: number) => void; @@ -885,6 +904,7 @@ export interface ComposerPromptEditorHandle { } interface ComposerPromptEditorProps { + onCitationSubmitAndSend?: () => void; value: string; cursor: number; terminalContexts: ReadonlyArray; @@ -1307,6 +1327,7 @@ function ComposerInlineTokenPastePlugin() { useEffect( () => registerComposerInlineTokenPaste(editor, { + createCitationNode: $createComposerCitationNode, createMentionNode: $createComposerMentionNode, getExpandedAbsoluteOffsetForPoint, }), @@ -1591,6 +1612,7 @@ function ComposerPromptEditorInner({ placeholder, className, onRemoveTerminalContext, + onCitationSubmitAndSend, onChange, onCommandKeyDown, onPaste, @@ -1611,6 +1633,21 @@ function ComposerPromptEditorInner({ terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); + const citationCommentRequestRef = useRef(null); + const [openCitationComment, setOpenCitationComment] = + useState(null); + const citationCommentActions = useMemo( + () => ({ + openComment: openCitationComment, + onOpenChange: (nodeKey: NodeKey, open: boolean) => { + setOpenCitationComment((current) => + open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, + ); + }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + }), + [onCitationSubmitAndSend, openCitationComment], + ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), [onRemoveTerminalContext], @@ -1628,6 +1665,22 @@ function ComposerPromptEditorInner({ editor.setEditable(!disabled); }, [disabled, editor]); + useEffect(() => { + const openCitationNodeKey = openCitationComment?.nodeKey; + if (!openCitationNodeKey) return; + return editor.registerUpdateListener(({ editorState }) => { + const isAttached = editorState.read(() => { + const node = $getNodeByKey(openCitationNodeKey); + return node instanceof ComposerCitationNode && node.isAttached(); + }); + if (!isAttached) { + setOpenCitationComment((current) => + current?.nodeKey === openCitationNodeKey ? null : current, + ); + } + }); + }, [editor, openCitationComment?.nodeKey]); + useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; @@ -1658,16 +1711,27 @@ function ComposerPromptEditorInner({ } isApplyingControlledUpdateRef.current = true; - editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; - if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); - } - if (shouldRewriteEditorState || isFocused) { - $setSelectionAtComposerOffset(normalizedCursor); - } - }); + const isCiteInsertion = citationCommentRequestRef.current?.value === value; + let citationToOpen: ComposerCitationCommentTarget | null = null; + editor.update( + () => { + const shouldRewriteEditorState = + previousSnapshot.value !== value || contextsChanged || skillsChanged; + if (shouldRewriteEditorState) { + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + } + if (shouldRewriteEditorState || isFocused) { + $setSelectionAtComposerOffset(normalizedCursor); + } + citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef); + }, + { + ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}), + onUpdate: () => { + if (citationToOpen) setOpenCitationComment(citationToOpen); + }, + }, + ); queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); @@ -1748,6 +1812,13 @@ function ComposerPromptEditorInner({ ), ); }, + requestCitationComment: (request) => { + citationCommentRequestRef.current = request; + const target = editor + .getEditorState() + .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef)); + if (target) setOpenCitationComment(target); + }, readSnapshot, isCaretOnVisualEdge: (edge) => { const snapshot = readSnapshot(); @@ -1833,43 +1904,45 @@ function ComposerPromptEditorInner({ }, []); return ( - -
- } - onPaste={onPaste} - /> - } - placeholder={ - terminalContexts.length > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - -
-
+ + +
+ } + onPaste={onPaste} + /> + } + placeholder={ + terminalContexts.length > 0 ? null : ( +
+ {placeholder} +
+ ) + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + + +
+
+
); } @@ -1882,6 +1955,7 @@ export function ComposerPromptEditor({ placeholder, className, onRemoveTerminalContext, + onCitationSubmitAndSend, onChange, onCommandKeyDown, onPaste, @@ -1894,7 +1968,12 @@ export function ComposerPromptEditor({ () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], + nodes: [ + ComposerCitationNode, + ComposerMentionNode, + ComposerSkillNode, + ComposerTerminalContextNode, + ], editorState: () => { $setComposerEditorPrompt( initialValueRef.current, @@ -1918,6 +1997,7 @@ export function ComposerPromptEditor({ skills={skills} disabled={disabled} placeholder={placeholder} + {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} onRemoveTerminalContext={onRemoveTerminalContext} onChange={onChange} onPaste={onPaste} diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx new file mode 100644 index 000000000..ccfd74666 --- /dev/null +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -0,0 +1,199 @@ +import type { AssistantCitation } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { PencilIcon, QuoteIcon, XIcon } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react"; +import { + findAssistantCitationSourceAnchor, + type AssistantCitationSourceAnchor, +} from "~/lib/assistantTextSelection"; +import { cn } from "~/lib/utils"; +import { + assistantCitationHash, + assistantCitationNavigation, +} from "../../lib/assistantCitationNavigation"; +import { + CHAT_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; +import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; +import { composerFloatingLayerProps } from "./composerEventScope"; + +const CITATION_ACTION_BUTTON_CLASS_NAME = cn( + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + "text-primary/80 hover:bg-primary/10 hover:text-primary", +); + +export function AssistantCitationChip({ + citation, + onRemove, + commentEditor, +}: { + citation: AssistantCitation; + onRemove?: () => void; + commentEditor?: { + open: boolean; + sourceAnchor?: AssistantCitationSourceAnchor | undefined; + onOpenChange: (open: boolean) => void; + onCancel?: () => void; + onSave: (comment: string) => boolean; + onSaveAndSend?: (comment: string) => boolean; + }; +}) { + const navigate = useNavigate(); + const commentInputRef = useRef(null); + const commentOpen = commentEditor?.open ?? false; + const sourceAnchor = commentEditor?.sourceAnchor; + const onSourceUnavailable = useEffectEvent(() => { + if (sourceAnchor) commentEditor?.onOpenChange(false); + }); + useEffect(() => { + if (!commentOpen) return; + const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); + if (!anchor) return; + return observeAssistantCitationCommentSource({ + anchor, + citation, + onUnavailable: onSourceUnavailable, + }); + }, [citation, commentOpen, sourceAnchor]); + // A multi-line selection's bounding box spans the full message width; anchor + // the bubble to the selection's last line, where the pointer released. + const popupAnchor = sourceAnchor + ? { + contextElement: sourceAnchor.source, + getBoundingClientRect: () => { + const rects = sourceAnchor.range.getClientRects(); + return rects.item(rects.length - 1) ?? sourceAnchor.range.getBoundingClientRect(); + }, + } + : undefined; + const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); + const label = preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; + const sourceLinkProps = { + to: "/$environmentId/$threadId" as const, + params: { environmentId: citation.environmentId, threadId: citation.threadId }, + hash: assistantCitationHash(citation), + "data-markdown-copy": serializeAssistantCitation(citation), + resetScroll: false, + onClick: (event: ReactMouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + void navigate(assistantCitationNavigation(citation)); + }, + }; + const composerSourceLink = ( + +