From d897641d738c67dd7c12cbb3a273b43fe17a5eb1 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 22:03:35 -0400 Subject: [PATCH 01/46] fix(pull-requests): keep cached PR chrome on reopen (#9294) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Cursor --- .../pullRequest/PullRequestService.test.ts | 172 +++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 57 +++++- .../pullRequest/PullRequestDetailPanel.tsx | 44 ++++- .../pullRequestDetail.logic.test.ts | 104 +++++++++++ .../pullRequest/pullRequestDetail.logic.ts | 107 +++++++++-- 5 files changed, 462 insertions(+), 22 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d688430bdf5..28c44e8d1b2 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; @@ -77,6 +78,27 @@ function changeRequest(number: number, updatedAt: string): ProviderChangeRequest }; } +function hostedChangeRequest(body: string, additions = 1) { + return { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body, + additions, + changedFiles: 2, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"] as const, + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"] as const, + requestReviewers: true, + }, + }; +} + function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -2934,7 +2956,7 @@ it.effect( }), ); -it.effect("shares linked summaries and only recovers transient failures for display reads", () => +it.effect("shares linked summaries and reuses them for display without asking the host again", () => Effect.gen(function* () { let calls = 0; let failing = false; @@ -2984,7 +3006,8 @@ it.effect("shares linked summaries and only recovers transient failures for disp const stale = yield* service.summary(reference); assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); - assert.strictEqual(calls, 3); + // Display reads keep the last title and state rather than asking the host again. + assert.strictEqual(calls, 2); yield* service.invalidate({ reference }); const invalidated = yield* Effect.flip(service.summary(reference)); @@ -2992,6 +3015,151 @@ it.effect("shares linked summaries and only recovers transient failures for disp }), ); +it.effect("answers a known pull request immediately while the host refreshes", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + let calls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.gen(function* () { + calls += 1; + if (calls > 1) yield* Deferred.await(gate); + return hostedChangeRequest("cached body", 4); + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.body, "cached body"); + assert.strictEqual(first.additions, 4); + + yield* TestClock.adjust("16 seconds"); + const second = yield* service.detail(reference); + assert.strictEqual(second.body, "cached body"); + assert.strictEqual(second.additions, 4); + yield* Effect.yieldNow; + assert.strictEqual(calls, 2); + }), +); + +it.effect("does not ask the host again for a linked summary it already holds", () => + Effect.gen(function* () { + let calls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.sync(() => { + calls += 1; + return changeRequest(1, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + + const first = yield* service.summary(reference); + assert.strictEqual(first.title, "Change request 1"); + yield* TestClock.adjust("61 seconds"); + const second = yield* service.summary(reference); + assert.strictEqual(second.title, "Change request 1"); + assert.strictEqual(calls, 1); + }), +); + +it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + let detailCalls = 0; + let summaryTitle = "old title"; + let summaryState: "open" | "merged" = "open"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.gen(function* () { + detailCalls += 1; + if (detailCalls > 1) yield* Deferred.await(gate); + return hostedChangeRequest("old body", 4); + }), + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title: summaryTitle, + state: summaryState, + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.title, "Change request 1"); + + summaryTitle = "merged title"; + summaryState = "merged"; + yield* TestClock.adjust("61 seconds"); + const settled = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(settled.title, "merged title"); + assert.strictEqual(settled.state, "merged"); + + yield* TestClock.adjust("16 seconds"); + const stale = yield* service.detail(reference); + assert.strictEqual(stale.title, "Change request 1"); + yield* Effect.yieldNow; + + const display = yield* service.summary(reference); + assert.strictEqual(display.title, "merged title"); + assert.strictEqual(display.state, "merged"); + assert.strictEqual(detailCalls, 2); + }), +); + +it.effect("does not let a still-cached detail overwrite a fresher linked summary", () => + Effect.gen(function* () { + let summaryTitle = "old title"; + let summaryState: "open" | "merged" = "open"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title: summaryTitle, + state: summaryState, + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.title, "Change request 1"); + + summaryTitle = "merged title"; + summaryState = "merged"; + const settled = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(settled.state, "merged"); + + const cached = yield* service.detail(reference); + assert.strictEqual(cached.title, "Change request 1"); + yield* Effect.yieldNow; + + const display = yield* service.summary(reference); + assert.strictEqual(display.title, "merged title"); + assert.strictEqual(display.state, "merged"); + }), +); + it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => Effect.gen(function* () { let failing = false; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index ffa96af12f0..752cdf1aa5e 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2001,7 +2001,25 @@ export const make = Effect.gen(function* () { }, }), ); - return { read, record }; + /** + * A change request already read does not wait on the host again. `reuse` answers from + * what we hold and spends nothing — title, author, and state barely move, and a linked + * thread already names the change request. `revalidate` answers the same way and + * refreshes behind it, so line counts and the rest can change in place. + */ + const serveHeld = ( + key: string, + effect: Effect.Effect, + mode: "reuse" | "revalidate", + ) => { + const snapshot = held.get(key); + if (snapshot === undefined) return read(key, effect); + if (mode === "reuse") return Effect.succeed(snapshot.value); + return Effect.sync(() => runFork(Effect.ignore(read(key, effect)))).pipe( + Effect.as(snapshot.value), + ); + }; + return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld }; }; const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); @@ -2059,7 +2077,7 @@ export const make = Effect.gen(function* () { const cached = Cache.get(summaryCache, key); return options?.recoverTransientFailure === false ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) - : lastGoodSummary.read(key, cached); + : lastGoodSummary.serveHeld(key, cached, "reuse"); }; // Keys serialize positionally and parse back in the lookup, so the cache is the only holder @@ -2149,9 +2167,42 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); + const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({ + provider: detail.provider, + projectId: detail.projectId, + repository: detail.repository, + number: detail.number, + title: detail.title, + url: detail.url, + state: detail.state, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + updatedAt: detail.updatedAt, + }); + const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { + const current = lastGoodSummary.peek(key); + if (current === undefined) return true; + if (current.state === "merged" && next.state !== "merged") return false; + return next.updatedAt >= current.updatedAt; + }; const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); - return lastGoodDetail.read(key, Cache.get(detailCache, key)); + // Record the summary from a host or cache read, not the stale value + // `serveHeld` returns immediately. Skip the write when that read is older + // than a later strict summary — display reuse would otherwise keep the + // regression and never ask the host again. + return lastGoodDetail.serveHeld( + key, + Cache.get(detailCache, key).pipe( + Effect.tap((value) => { + const summary = summaryFromDetail(value); + return shouldReplaceHeldSummary(key, summary) + ? lastGoodSummary.record(key, summary) + : Effect.void; + }), + ), + "revalidate", + ); }; const activityCache = yield* Cache.makeWith( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index a109b161438..5044bddeb78 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -120,10 +120,13 @@ import { pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, + readPullRequestDetailSnapshot, + resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, resolveBaseFreshness, type PullRequestFinding, shouldRefreshPullRequestActivity, + writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; import { @@ -558,7 +561,44 @@ export function PullRequestDetailPanel({ const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); - const coreDetail = detailQuery.data; + const [cachedDetail, setCachedDetail] = useState(() => + readPullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + ), + ); + useEffect(() => { + setCachedDetail( + readPullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + ), + ); + }, [environmentId, pullRequestKey, reference.projectId, reference.repository, reference.number]); + useEffect(() => { + if (detailQuery.data === null) return; + writePullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + detailQuery.data, + ); + setCachedDetail(detailQuery.data); + }, [ + detailQuery.data, + environmentId, + pullRequestKey, + reference.projectId, + reference.repository, + reference.number, + ]); + const coreDetail = resolveDisplayedPullRequestDetail({ + live: detailQuery.data, + cached: cachedDetail, + reference, + }); const activity = activityQuery.data; const detail = useMemo( () => @@ -1240,6 +1280,8 @@ export function PullRequestDetailPanel({ ).length : 0; + // A reopen already has last time's title, author, and counts. Keep them on screen + // and let the live read replace fields — especially the diff counts — in place. if (detailQuery.isPending && !detail) { return ; } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 348cbd47789..c51429ff6d8 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -2,6 +2,7 @@ import { PullRequestAction, type PullRequestCheck, type PullRequestComment, + type PullRequestDetail, type PullRequestDetailView, type PullRequestReviewThread, } from "@t3tools/contracts"; @@ -31,12 +32,15 @@ import { pullRequestHandoffLabels, pullRequestReviewOutcome, readableFailure, + readPullRequestDetailSnapshot, + resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, describePullRequestState, editPullRequestThreadComment, + writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; import type { ReviewCommentContext } from "~/reviewCommentContext"; @@ -1317,3 +1321,103 @@ describe("which actions need the host read again after they run", () => { } }); }); + +describe("cached pull request detail", () => { + const reference = { projectId: "project-1", repository: "acme/web", number: 7 }; + const detail = (overrides: Partial = {}): PullRequestDetail => + ({ + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + projectId: "project-1", + projectTitle: "web", + workspaceRoot: "/repo", + repository: "acme/web", + number: 7, + title: "Cache the title", + body: "who made it", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: null, avatarUrl: "https://avatars.example/octocat" }, + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 12, + deletions: 3, + changedFiles: 2, + headBranch: "feat/cache", + baseBranch: "main", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + ...overrides, + }) as PullRequestDetail; + + const makeStorage = () => { + const held = new Map(); + return { + getItem: (key: string) => held.get(key) ?? null, + setItem: (key: string, value: string) => void held.set(key, value), + }; + }; + + it("hydrates the last title, author, and counts so a reopen does not ghost the tab", () => { + const storage = makeStorage(); + writePullRequestDetailSnapshot(storage, "env-1", reference, detail()); + const snapshot = readPullRequestDetailSnapshot(storage, "env-1", reference); + expect(snapshot?.title).toBe("Cache the title"); + expect(snapshot?.author?.login).toBe("octocat"); + expect(snapshot?.additions).toBe(12); + expect(snapshot?.deletions).toBe(3); + }); + + it("keeps a cached tab painted while the live read replaces the counts", () => { + const cached = detail(); + const live = detail({ additions: 40, deletions: 9, title: "Cache the title" }); + expect(resolveDisplayedPullRequestDetail({ live, cached, reference })?.additions).toBe(40); + expect(resolveDisplayedPullRequestDetail({ live: null, cached, reference })?.additions).toBe( + 12, + ); + }); + + it("does not paint another change request's snapshot", () => { + expect( + resolveDisplayedPullRequestDetail({ + live: null, + cached: detail({ number: 8 }), + reference, + }), + ).toBeNull(); + expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull(); + }); + + it("shrugs off corrupt storage and no storage at all", () => { + const storage = makeStorage(); + storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); + expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull(); + expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 755bcdf5244..cffe33f8d83 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,19 +1,22 @@ -import type { - PullRequestAction, - PullRequestActor, - PullRequestBaseComparison, - PullRequestCheck, - PullRequestChecksState, - PullRequestComment, - PullRequestCommit, - PullRequestDetailView, - PullRequestMergeability, - PullRequestReaction, - PullRequestReviewThread, - PullRequestState, - PullRequestUpdateMethod, - SourceControlProviderKind, - VcsRef, +import * as Schema from "effect/Schema"; + +import { + PullRequestDetail, + type PullRequestAction, + type PullRequestActor, + type PullRequestBaseComparison, + type PullRequestCheck, + type PullRequestChecksState, + type PullRequestComment, + type PullRequestCommit, + type PullRequestDetailView, + type PullRequestMergeability, + type PullRequestReaction, + type PullRequestReviewThread, + type PullRequestState, + type PullRequestUpdateMethod, + type SourceControlProviderKind, + type VcsRef, } from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; @@ -1009,3 +1012,75 @@ const ACTION_NEEDS_HOST_REFRESH: Record = { export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean { return ACTION_NEEDS_HOST_REFRESH[action]; } + +type SnapshotStorage = Pick; + +export interface PullRequestDetailSnapshotRef { + readonly projectId: string; + readonly repository: string; + readonly number: number; +} + +const pullRequestDetailSnapshotKey = ( + environmentId: string, + reference: PullRequestDetailSnapshotRef, +) => + `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; + +const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail); + +/** + * The last detail answered for this change request, brought back across a reload. The registry + * the queries live in is recreated with the renderer, so without this a reopen cold-starts + * into a full-tab ghost even though the title, author, and the rest barely moved. Hydrated, + * the chrome stays and the live read replaces fields in place — line counts included. + */ +export function readPullRequestDetailSnapshot( + storage: SnapshotStorage | undefined, + environmentId: string, + reference: PullRequestDetailSnapshotRef, +): PullRequestDetail | null { + try { + const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)); + if (!raw) return null; + const decoded = decodeDetailSnapshot(JSON.parse(raw)); + return decoded._tag === "Some" ? decoded.value : null; + } catch { + return null; + } +} + +export function writePullRequestDetailSnapshot( + storage: SnapshotStorage | undefined, + environmentId: string, + reference: PullRequestDetailSnapshotRef, + detail: PullRequestDetail, +): void { + try { + storage?.setItem( + pullRequestDetailSnapshotKey(environmentId, reference), + JSON.stringify(detail), + ); + } catch { + // Quota or a private-mode store: the next open waits on the live read, which is the + // cold start this snapshot exists to avoid, not a failure of its own. + } +} + +/** Live host state wins; a snapshot is only the same change request, never a neighbour's. */ +export function resolveDisplayedPullRequestDetail(input: { + readonly live: PullRequestDetail | null; + readonly cached: PullRequestDetail | null; + readonly reference: PullRequestDetailSnapshotRef; +}): PullRequestDetail | null { + if (input.live !== null) return input.live; + if ( + input.cached !== null && + input.cached.projectId === input.reference.projectId && + input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() && + input.cached.number === input.reference.number + ) { + return input.cached; + } + return null; +} From 0fbb94248581aaefda36e4f8a40ec2c6455c779a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:06:14 -0700 Subject: [PATCH 02/46] feat(environments): draw each environment as the machine it runs on (#9299) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/mobile/src/components/AppSymbol.tsx | 7 + .../components/EnvironmentMachineSymbol.tsx | 39 +++ .../connection/CloudEnvironmentRows.tsx | 22 +- .../connection/ConnectionEnvironmentRow.tsx | 24 +- apps/mobile/src/features/home/HomeScreen.tsx | 27 ++- .../threads/ThreadNavigationSidebar.tsx | 21 +- .../features/threads/thread-list-items.tsx | 24 ++ .../features/threads/thread-list-v2-items.tsx | 45 +++- .../src/environment/ServerEnvironment.ts | 4 + .../ServerEnvironmentMachine.test.ts | 225 ++++++++++++++++++ .../environment/ServerEnvironmentMachine.ts | 167 +++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 3 +- apps/web/src/components/BranchToolbar.tsx | 36 ++- apps/web/src/components/ChatView.tsx | 14 +- .../src/components/EnvironmentMachineIcon.tsx | 75 ++++++ apps/web/src/components/LegacySidebar.tsx | 13 +- apps/web/src/components/Sidebar.tsx | 52 +++- .../pullRequest/PullRequestDetailPanel.tsx | 30 ++- .../pullRequestProjectAssignment.logic.ts | 19 +- .../settings/ConnectionsSettings.tsx | 29 +++ .../settings/EnvironmentIconPicker.test.ts | 41 ++++ .../settings/EnvironmentIconPicker.tsx | 161 +++++++++++++ .../src/components/settings/settingsSearch.ts | 8 + apps/web/src/routes/_chat.pull-requests.tsx | 11 +- docs/internals/remote.md | 17 ++ docs/user/thread-sidebar.md | 15 ++ packages/contracts/src/baseSchemas.ts | 39 +++ packages/contracts/src/environment.ts | 33 ++- packages/contracts/src/server.test.ts | 53 +++++ packages/contracts/src/server.ts | 18 +- packages/contracts/src/settings.test.ts | 19 ++ packages/contracts/src/settings.ts | 15 +- 32 files changed, 1222 insertions(+), 84 deletions(-) create mode 100644 apps/mobile/src/components/EnvironmentMachineSymbol.tsx create mode 100644 apps/server/src/environment/ServerEnvironmentMachine.test.ts create mode 100644 apps/server/src/environment/ServerEnvironmentMachine.ts create mode 100644 apps/web/src/components/EnvironmentMachineIcon.tsx create mode 100644 apps/web/src/components/settings/EnvironmentIconPicker.test.ts create mode 100644 apps/web/src/components/settings/EnvironmentIconPicker.tsx diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 0c2042218cd..aa142a4755a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -22,6 +22,7 @@ import IconBox from "@tabler/icons-react-native/IconBox"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; +import IconCloud from "@tabler/icons-react-native/IconCloud"; import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; @@ -32,6 +33,7 @@ import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; +import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; import IconDots from "@tabler/icons-react-native/IconDots"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; @@ -110,6 +112,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + cloud: IconCloud, cube: IconBox, "chevron.down": IconChevronDown, "chevron.left": IconChevronLeft, @@ -129,9 +132,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "folder.fill": IconFolder, gearshape: IconSettings, "info.circle": IconInfoCircle, + laptopcomputer: IconDeviceLaptop, link: IconLink, "line.3.horizontal.decrease.circle": IconFilter, "line.3.horizontal.decrease.circle.fill": IconFilterFilled, + // Tabler has no Apple desktops; the closest silhouettes stand in on Android. + macmini: IconServer, + macstudio: IconDeviceDesktop, magnifyingglass: IconSearch, paintbrush: IconPalette, "person.crop.circle": IconUserCircle, diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx new file mode 100644 index 00000000000..46fbbd814fd --- /dev/null +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -0,0 +1,39 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import type { SFSymbol } from "expo-symbols"; + +import { SymbolView } from "./AppSymbol"; + +const SYMBOL_BY_KIND: Record = { + server: "server.rack", + cloud: "cloud", + desktop: "desktopcomputer", + laptop: "laptopcomputer", + "mac-mini": "macmini", + "mac-studio": "macstudio", +}; + +export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { + server: "Server", + cloud: "Cloud VM", + desktop: "Desktop", + laptop: "Laptop", + "mac-mini": "Mac mini", + "mac-studio": "Mac Studio", +}; + +/** The glyph an environment wears in lists; SF Symbols on iOS, Tabler on Android. */ +export function EnvironmentMachineSymbol(props: { + readonly kind: EnvironmentMachineKind; + readonly size: number; + readonly tintColorClassName: string; +}) { + return ( + + ); +} diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 4c840636c9f..163e5fcf16f 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -4,7 +4,12 @@ import { connectionStatusText, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type EnvironmentMachineKind, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useCallback, useState } from "react"; import { ActivityIndicator, @@ -15,10 +20,12 @@ import { } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { serverEnvironment } from "../../state/server"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; @@ -205,6 +212,9 @@ function ConnectedCloudEnvironmentRow(props: { readonly onDisconnect: () => void; readonly onToggleError: () => void; }) { + const serverConfig = useAtomValue( + serverEnvironment.configValueAtom(props.environment.environmentId), + ); return ( { if (enabled) { props.onConnect(); @@ -268,6 +279,8 @@ function CloudEnvironmentRowShell(props: { readonly disabled?: boolean; readonly errorExpanded: boolean; readonly label: string; + /** Absent for environments the relay lists but this device has not connected to. */ + readonly machine?: EnvironmentMachineKind; readonly onToggleError: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; @@ -323,6 +336,13 @@ function CloudEnvironmentRowShell(props: { + {props.machine ? ( + + ) : null} - - {props.environment.environmentLabel} - + + + + {props.environment.environmentLabel} + + {props.environment.displayUrl} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 34f4f4057a5..c06cbcf1e91 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,10 +12,11 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, +import { + type EnvironmentId, + resolveEnvironmentMachineKind, + type SidebarProjectGroupingMode, + type SidebarThreadSortOrder, } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -612,6 +613,16 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const machineByEnvironmentId = useMemo( + () => + new Map( + [...serverConfigs].map( + ([environmentId, config]) => + [environmentId, resolveEnvironmentMachineKind(config)] as const, + ), + ), + [serverConfigs], + ); // Canonical arranged pinned order (reorder-capable threads only) for the // Move up/down position flags. Computed from all shells, not the rendered // list, so search/scope filtering never disables or misdirects a move. @@ -740,6 +751,7 @@ export function HomeScreen(props: HomeScreenProps) { ?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)} showPendingDivider={item.showPendingDivider} showTrailingDivider={showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} @@ -797,6 +809,7 @@ export function HomeScreen(props: HomeScreenProps) { ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, @@ -847,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + machineByEnvironmentId, pinReorderEnvironmentIds, projectByKey, projectCwdByKey, @@ -938,6 +952,9 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById[item.pendingTask.message.environmentId] ?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get( + item.pendingTask.message.environmentId, + )} isLast={item.isLast} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} @@ -952,6 +969,7 @@ export function HomeScreen(props: HomeScreenProps) { environmentLabel={ props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -990,6 +1008,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleRegenerateThreadTitle, + machineByEnvironmentId, projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 4a4d36c7a21..d03a4ee05dd 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,7 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; @@ -441,6 +441,16 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const machineByEnvironmentId = useMemo( + () => + new Map( + [...serverConfigs].map( + ([environmentId, config]) => + [environmentId, resolveEnvironmentMachineKind(config)] as const, + ), + ), + [serverConfigs], + ); // Canonical arranged pinned order for Move up/down flags — computed from // all shells so search/scope filtering never disables a valid move. const arrangedPinnedKeys = useMemo(() => { @@ -819,6 +829,9 @@ function ThreadNavigationSidebarPane( ?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get( + item.pendingTask.message.environmentId, + )} pane="sidebar" showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} @@ -853,6 +866,7 @@ function ThreadNavigationSidebarPane( ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, @@ -956,6 +970,9 @@ function ThreadNavigationSidebarPane( savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get( + item.pendingTask.message.environmentId, + )} isLast={item.isLast} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} @@ -970,6 +987,7 @@ function ThreadNavigationSidebarPane( environmentLabel={ savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -1017,6 +1035,7 @@ function ThreadNavigationSidebarPane( handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + machineByEnvironmentId, movePinnedThread, openPendingTask, pinReorderEnvironmentIds, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index df10e585aaa..6a1b752d59e 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -4,6 +4,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentMachineKind } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; @@ -14,6 +15,7 @@ import Svg, { Circle, Path } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; @@ -268,6 +270,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly variant: ThreadListVariant; readonly pendingTask: PendingNewTask; readonly environmentLabel: string | null; + readonly environmentMachine?: EnvironmentMachineKind; readonly isLast: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -305,6 +308,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"} type="monochrome" /> + {props.environmentLabel && props.environmentMachine ? ( + + ) : null} {subtitleParts.length > 0 ? ( <> + {props.environmentLabel && props.environmentMachine ? ( + + ) : null} {branch || props.environmentLabel ? ( - - {branch ? ( - - {branch} - - ) : null} - {branch && props.environmentLabel ? " · " : null} - {props.environmentLabel ? ( - {props.environmentLabel} + + + {branch ? ( + + {branch} + + ) : null} + {branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + {props.environmentLabel} + ) : null} + + {props.environmentLabel && props.environmentMachine ? ( + ) : null} - + ) : null} ); @@ -327,6 +340,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { the web sidebar's remote-environment cloud icon, but as text since phones have no hover tooltips. */ readonly environmentLabel: string | null; + /** Drawn after the label so the machine reads at a glance; ignored while + the label is null. */ + readonly environmentMachine?: EnvironmentMachineKind; /** Hosting surface. "screen" (default) renders the compact Home idiom: flat edge-to-edge rows on the screen background with inset hairlines. "sidebar" renders the iPad split-view idiom: rounded rows blending @@ -767,6 +783,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : ( )} + {status !== "failed" && props.environmentLabel && props.environmentMachine ? ( + + ) : null} {pr ? ( ()( "ServerEnvironmentIdPersistenceError", @@ -188,6 +189,7 @@ export const make = Effect.gen(function* () { const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const machine = yield* detectServerEnvironmentMachineKind(); const launcher = yield* resolveServiceLauncherMode(); const serverSelfUpdate = resolveServerSelfUpdateCapability({ desktopManaged: serverConfig.mode === "desktop", @@ -206,6 +208,7 @@ export const make = Effect.gen(function* () { platform: { os: platformOs(hostPlatform), arch: platformArch(hostArchitecture), + ...(machine === null ? {} : { machine }), }, serverVersion: packageJson.version, capabilities: { @@ -222,6 +225,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, + environmentIcon: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts new file mode 100644 index 00000000000..c4318e7a9d7 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -0,0 +1,225 @@ +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("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 00000000000..e23342d12c0 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -0,0 +1,167 @@ +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"; + +// 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 [chassisType, sysVendor, productName] = yield* Effect.all([ + readOptionalFile(`${DMI_ROOT}/chassis_type`), + readOptionalFile(`${DMI_ROOT}/sys_vendor`), + readOptionalFile(`${DMI_ROOT}/product_name`), + ]); + 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/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0a8e07d1958..fecd9de8734 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 { EnvironmentId, EnvironmentMachineKind, 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; + machine: EnvironmentMachineKind; } export const EnvMode = Schema.Literals(["local", "worktree"]); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b0b1440587e..ffd338124b8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -2,16 +2,15 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, - CloudIcon, FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon, - MonitorIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; import { @@ -105,12 +104,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; - const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + ) : ( @@ -151,21 +152,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ value={environmentId} onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} > - {availableEnvironments.map((env) => { - const Icon = env.isPrimary ? MonitorIcon : CloudIcon; - return ( - - - - {env.label} - - - ); - })} + {availableEnvironments.map((env) => ( + + + + {env.label} + + + ))} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a5335856be9..c08fb2b9ee3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,6 +22,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, + resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -303,6 +304,7 @@ import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; import { + type EnvironmentOption, resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, @@ -2061,22 +2063,18 @@ function ChatViewContent(props: ChatViewProps) { (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); - const envs: Array<{ - environmentId: EnvironmentId; - projectId: ProjectId; - label: string; - isPrimary: boolean; - }> = []; + const envs: EnvironmentOption[] = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; + const environment = environmentById.get(p.environmentId) ?? null; envs.push({ environmentId: p.environmentId, projectId: p.id, - label, + label: environment?.label ?? p.environmentId, isPrimary, + machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); } // Sort: primary first, then alphabetical diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx new file mode 100644 index 00000000000..31b7a953a3a --- /dev/null +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -0,0 +1,75 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react"; +import type { FunctionComponent, SVGProps } from "react"; + +// 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. */ +export function MacMiniIcon(props: SVGProps) { + return ( + + + + + ); +} + +/** A Mac Studio: the same slab twice as tall, ports along the front foot. */ +export function MacStudioIcon(props: SVGProps) { + return ( + + + + + ); +} + +const ICON_BY_KIND: Record> = { + server: ServerIcon, + cloud: CloudIcon, + desktop: MonitorIcon, + laptop: LaptopIcon, + "mac-mini": MacMiniIcon, + "mac-studio": MacStudioIcon, +}; + +export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { + server: "Server", + cloud: "Cloud VM", + desktop: "Desktop", + laptop: "Laptop", + "mac-mini": "Mac mini", + "mac-studio": "Mac Studio", +}; + +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 ; +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6d48cf6538b..370c4eaed74 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -22,6 +22,7 @@ import { ThreadWorktreeIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; @@ -48,6 +49,7 @@ import { type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, + resolveEnvironmentMachineKind, ThreadId, } from "@t3tools/contracts"; import { @@ -395,9 +397,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + // No primary (the hosted app) means every thread is remote, and the machine + // glyph is what tells the environments apart. + const isRemoteThread = thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; + const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the // user's own machine, so the cloud icon is misleading — label it "Local" and @@ -876,7 +880,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {threadEnvironmentLabel} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aca914620d9..69f9ea2f30c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -29,7 +29,12 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + resolveEnvironmentMachineKind, + type EnvironmentMachineKind, + type ScopedThreadRef, + type ThreadId, +} from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -47,7 +52,6 @@ import { PinIcon, PlusIcon, SearchIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -121,6 +125,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animatePinnedLayoutChanges, @@ -274,6 +279,7 @@ function SidebarThreadTooltip({ projectCwd, projectFaviconPath, environmentLabel, + environmentMachine, providerEntry, showInstanceBadge, modelInstanceId, @@ -287,6 +293,7 @@ function SidebarThreadTooltip({ projectCwd: string | null; projectFaviconPath: string | null; environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; providerEntry: ProviderInstanceEntry | null; showInstanceBadge: boolean; modelInstanceId: string; @@ -325,7 +332,10 @@ function SidebarThreadTooltip({ ) : null} {environmentLabel ? (
- +
{environmentLabel}
) : null} @@ -735,6 +745,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; projectCwd: string | null; projectFaviconPath: string | null; projectTitle: string | null; @@ -963,8 +974,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + // The local environment is "this machine" and needs no marker; every other + // one gets its machine glyph. With no local environment (the hosted app) + // that is every thread, which is the point: the glyph is what tells rows on + // different machines apart. + const isRemote = thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( {isRemote ? ( - + ) : null} {driverKind ? ( @@ -1636,6 +1655,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectFaviconPath: string | null; projectTitle: string | null; environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; isHighlighted: boolean; isRouteActive: boolean; @@ -1731,6 +1751,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} + environmentMachine={props.environmentMachine} providerEntry={providerEntry} showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} @@ -1871,6 +1892,19 @@ export default function Sidebar() { ), [environments], ); + const environmentMachineById = useMemo( + () => + new Map( + environments.map( + (environment) => + [ + environment.environmentId, + resolveEnvironmentMachineKind(environment.serverConfig), + ] as const, + ), + ), + [environments], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -3717,6 +3751,9 @@ export default function Sidebar() { ) ?? null } environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} + environmentMachine={ + environmentMachineById.get(thread.environmentId) ?? "server" + } providerEntryByInstanceId={ providerEntriesByEnvironment.get(thread.environmentId) ?? EMPTY_PROVIDER_ENTRIES @@ -3815,6 +3852,9 @@ export default function Sidebar() { } currentEnvironmentId={primaryEnvironmentId} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} + environmentMachine={ + environmentMachineById.get(thread.environmentId) ?? "server" + } projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5044bddeb78..e289db62593 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,13 +1,14 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { - EnvironmentId, - PullRequestAction, - PullRequestMergeMethod, - PullRequestUpdateMethod, - PullRequestRef, - PullRequestState, - ScopedThreadRef, +import { + type EnvironmentId, + type PullRequestAction, + type PullRequestMergeMethod, + type PullRequestUpdateMethod, + type PullRequestRef, + type PullRequestState, + resolveEnvironmentMachineKind, + type ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -36,7 +37,6 @@ import { PlayIcon, RefreshCwIcon, RotateCcwIcon, - ServerIcon, TriangleAlertIcon, } from "lucide-react"; import { @@ -77,6 +77,7 @@ import { AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -272,7 +273,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} @@ -729,7 +733,11 @@ export function PullRequestDetailPanel({ ? resolvePickableEnvironments( { environmentId, projectId: reference.projectId }, projects, - environments, + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + })), ) : [], [context, environmentId, environments, projects, reference.projectId], diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts index aea26286f86..d52b72ca395 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts"; /** The little of a project this needs: who holds it, and which repository it is a copy of. */ export interface AssignableProject { @@ -69,6 +69,7 @@ export interface PickableEnvironment { readonly projectId: ProjectId; readonly workspaceRoot: string; readonly label: string; + readonly machine?: EnvironmentMachineKind; } /** @@ -85,17 +86,21 @@ export interface PickableEnvironment { export function resolvePickableEnvironments( current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, projects: ReadonlyArray, - environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>, + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + readonly machine?: EnvironmentMachineKind; + }>, ): ReadonlyArray { const own = projects.find( (project) => project.environmentId === current.environmentId && project.id === current.projectId, ); const key = own === undefined ? undefined : repositoryKey(own); - const ownLabel = environments.find( + const ownEnvironment = environments.find( (environment) => environment.environmentId === current.environmentId, - )?.label; - if (own === undefined || !key || ownLabel === undefined) return []; + ); + if (own === undefined || !key || ownEnvironment === undefined) return []; const others = environments.flatMap((environment) => { if (environment.environmentId === current.environmentId) return []; // One entry per server, whichever copy comes first: a server holding two worktrees of the @@ -112,6 +117,7 @@ export function resolvePickableEnvironments( projectId: copy.id, workspaceRoot: copy.workspaceRoot, label: environment.label, + ...(environment.machine === undefined ? {} : { machine: environment.machine }), }, ]; }); @@ -123,7 +129,8 @@ export function resolvePickableEnvironments( environmentId: current.environmentId, projectId: own.id, workspaceRoot: own.workspaceRoot, - label: ownLabel, + label: ownEnvironment.label, + ...(ownEnvironment.machine === undefined ? {} : { machine: ownEnvironment.machine }), }, ...others, ]; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b487fbb8bd1..66f13208df4 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -27,6 +27,7 @@ import { type DesktopServerExposureState, type DesktopWslState, type EnvironmentId, + resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { @@ -53,6 +54,7 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { Input } from "../ui/input"; import { Checkbox } from "../ui/checkbox"; import { @@ -86,6 +88,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { AnimatedHeight } from "../AnimatedHeight"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; import { readHostedPairingRequest } from "../../hostedPairing"; @@ -1438,6 +1441,11 @@ function SavedBackendListRow({ : null } /> +

{environment.label}

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

{metadataBits.join(" · ")}

) : null} + {isConnected ? ( +
+ +
+ ) : null} {serverUpdateState.status !== "idle" ? (
@@ -3081,6 +3098,18 @@ export function ConnectionsSettings() { } /> ) : null} + {primaryEnvironmentId !== null ? ( + + } + /> + ) : null} {desktopBridge ? ( <> {renderNetworkAccessRow()} diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.test.ts b/apps/web/src/components/settings/EnvironmentIconPicker.test.ts new file mode 100644 index 00000000000..429b853ddae --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentIconPicker.test.ts @@ -0,0 +1,41 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveEnvironmentIconPickerLock } from "./EnvironmentIconPicker"; + +const config = (environmentIcon: boolean | undefined) => + ({ + environment: { capabilities: environmentIcon === undefined ? {} : { environmentIcon } }, + }) as unknown as ServerConfig; + +describe("resolveEnvironmentIconPickerLock", () => { + it("locks until the environment is connected", () => { + expect( + resolveEnvironmentIconPickerLock({ serverConfig: null, operateAccess: "granted" }), + ).toMatch(/Connect/); + }); + + it("locks on servers that predate the setting, before looking at permissions", () => { + expect( + resolveEnvironmentIconPickerLock({ + serverConfig: config(undefined), + operateAccess: "denied", + }), + ).toMatch(/too old/); + }); + + it("locks when the session cannot operate the environment", () => { + expect( + resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "denied" }), + ).toMatch(/cannot change/); + }); + + it("stays open while access is still resolving so a slow session does not flicker", () => { + expect( + resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }), + ).toBeNull(); + expect( + resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx new file mode 100644 index 00000000000..f990aa5a244 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -0,0 +1,161 @@ +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 === "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/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5a60857a537..d1bc52283ee 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -406,6 +406,14 @@ export const SETTINGS_SEARCH_ITEMS = [ ], primaryOnly: true, }, + { + id: "environment-icon", + title: "Environment icon", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], + localBackendManagementOnly: true, + }, { id: "network-access", title: "Network access", diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 525d5d058d7..7513e192c51 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,5 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { pullRequestHostOf, ThreadId } from "@t3tools/contracts"; +import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, @@ -19,8 +19,6 @@ import { ChevronDownIcon, ClockIcon, EyeIcon, - MonitorIcon, - ServerIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon, @@ -86,6 +84,7 @@ import { writePullRequestListPreferences, } from "../components/pullRequest/pullRequestListPreferences"; import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; +import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { PullRequestFiltersMenu, @@ -1736,14 +1735,14 @@ 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. + // The same shape the host pills take, so the two groups read as one control. Each server + // wears the machine it runs on. 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/internals/remote.md b/docs/internals/remote.md index 65416a19e96..d27a0741c2a 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -224,6 +224,22 @@ right action without making the transport responsible for process management. Th supervisor owns the resulting disconnect and reconnect like any other involuntary close. See [server-updates.md](./server-updates.md). +## Machine identity + +Every environment is drawn with one of a fixed set of machine glyphs (`EnvironmentMachineKind` in +contracts: server, cloud, desktop, laptop, mac-mini, mac-studio). Two inputs feed it, and the +precedence lives in one helper, `resolveEnvironmentMachineKind`, so web and mobile cannot drift: + +1. `settings.environmentIcon`, a nullable server setting the user picks in Settings → Connections. + It is server state rather than client state so every device connecting to that server agrees. +2. `environment.platform.machine`, detected once at startup by [ServerEnvironmentMachine.ts][machine] + (IOKit product name / `hw.model` on macOS, `/sys/class/dmi/id` on Linux with virtualization + markers mapped to "cloud"). Absent when there is no signal, so older servers, Windows hosts, and + containers decode without it. +3. A generic server otherwise. + +Clients treat the field like any other capability: absent means "use the fallback", never "wait". + ## Future work These remain unbuilt and are listed to keep the model honest: @@ -237,3 +253,4 @@ These remain unbuilt and are listed to keep the model honest: [authremote]: ../../packages/client-runtime/src/authorization/remote.ts [sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts [sshtunnel]: ../../packages/ssh/src/tunnel.ts +[machine]: ../../apps/server/src/environment/ServerEnvironmentMachine.ts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2a940606bf5..01b64fbae74 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -53,6 +53,21 @@ The main sidebar, right panel, and terminal drawer open and close immediately by The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at 0 ms, it snaps between the same open and closed states. +## Environment icons + +When you are connected to more than one environment, every thread that lives somewhere other than +the machine you are on wears a small icon for that machine at the end of its row: a server, a cloud +VM, a desktop, a laptop, a Mac mini, or a Mac Studio. In the hosted web app and the mobile app, +where every environment is remote, each row wears its machine so you can tell them apart at a +glance. The same icon appears in the thread tooltip, the "Run on" picker, the pull request server +filter, and the environment lists under **Settings → Connections**. + +Servers pick the icon themselves from the hardware they run on. A Mac reports its model, a Linux +machine reports its chassis type and whether it is a virtual machine, and anything without a usable +signal shows a generic server. To override it, open **Settings → Connections** and choose an icon +for that environment; **Automatic** goes back to what the server detected. The choice is stored on +that server, so every device that connects to it sees the same icon. + ## Environment artwork Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index afa9e979b72..e55e0af7ca0 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.ts b/packages/contracts/src/environment.ts index 533411e5137..e9a3f1a1bb6 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 { + EnvironmentId, + ForwardCompatibleOptional, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", @@ -14,9 +20,30 @@ 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 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", + "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, + /** Hardware shape detected at startup. Absent when the host gives no usable + signal (containers, Windows, unknown DMI), on servers that predate it, or + when a newer server names a kind this build cannot draw. */ + machine: ForwardCompatibleOptional(EnvironmentMachineKind), }); /** @@ -102,6 +129,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** Server detects `platform.machine` and persists the `environmentIcon` + setting. Older servers drop the key on write, so clients show the + picker inert rather than offering a choice that would never stick. */ + environmentIcon: Schema.optionalKey(Schema.Boolean), /** The desktop app supervising this server can be driven over RPC: server.updateServer runs its check -> download -> relaunch. Absent on desktop servers whose app predates the remote trigger, where clients diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c..47f4145207c 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,12 +1,15 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { + resolveEnvironmentMachineKind, ServerConfig, ServerProvider, ServerProviders, ServerUpsertKeybindingResult, } from "./server.ts"; +import { ServerSettings } from "./settings.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); @@ -155,3 +158,53 @@ describe("server config forward compatibility", () => { expect(parsed).toEqual([decodedBase]); }); }); + +describe("resolveEnvironmentMachineKind", () => { + const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); + const decodeSettings = Schema.decodeUnknownSync(ServerSettings); + const descriptor = (platform: Record) => + decodeDescriptor({ + environmentId: "env-1", + label: "Box", + platform: { os: "linux", arch: "x64", ...platform }, + serverVersion: "1.0.0", + capabilities: {}, + }); + + it("prefers the user's pick over what the server detected", () => { + expect( + resolveEnvironmentMachineKind({ + environment: descriptor({ machine: "mac-mini" }), + settings: decodeSettings({ environmentIcon: "laptop" }), + }), + ).toBe("laptop"); + }); + + it("uses detection when nothing is picked", () => { + expect( + resolveEnvironmentMachineKind({ + environment: descriptor({ machine: "mac-mini" }), + settings: decodeSettings({}), + }), + ).toBe("mac-mini"); + }); + + it("falls back to a server for older servers and before connect", () => { + expect( + resolveEnvironmentMachineKind({ + environment: descriptor({}), + settings: decodeSettings({}), + }), + ).toBe("server"); + expect(resolveEnvironmentMachineKind(null)).toBe("server"); + }); + + it("drops a machine kind this build does not know instead of failing the descriptor", () => { + const parsed = descriptor({ machine: "toaster" }); + + expect(parsed.platform.machine).toBeUndefined(); + expect( + resolveEnvironmentMachineKind({ environment: parsed, settings: decodeSettings({}) }), + ).toBe("server"); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b05369145ef..e2154085225 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,6 +1,10 @@ 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, @@ -572,6 +576,18 @@ export const ServerConfig = Schema.Struct({ }); export type ServerConfig = typeof ServerConfig.Type; +/** + * The machine an environment should be drawn as: the user's pick, else what + * the server detected, else a generic server. A null config (not connected + * yet, or an older server) resolves to the same generic so rows never + * flicker between glyphs. + */ +export function resolveEnvironmentMachineKind( + config: Pick | null, +): EnvironmentMachineKind { + return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server"; +} + const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ key: KeybindingValue, command: KeybindingCommand, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 3c48c8d5141..61ad3be4109 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -505,3 +505,22 @@ describe("ServerSettingsPatch string normalization", () => { expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); }); }); + +describe("ServerSettings environment icon", () => { + it("defaults to null", () => { + expect(decodeServerSettings({}).environmentIcon).toBeNull(); + }); + + it("keeps a kind this build knows", () => { + expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini"); + }); + + it("decodes a kind from a newer server as null instead of failing the snapshot", () => { + expect(decodeServerSettings({ environmentIcon: "toaster" }).environmentIcon).toBeNull(); + }); + + it("round-trips through encode", () => { + const settings = decodeServerSettings({ environmentIcon: "laptop" }); + expect(encodeServerSettings(settings).environmentIcon).toBe("laptop"); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8de2b73e2ad..836c3c19d4c 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, @@ -734,6 +734,16 @@ export const ServerSettings = Schema.Struct({ defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( Schema.withDecodingDefault(Effect.succeed("")), ), + /** + * The icon clients draw for this environment. Null means "use what the + * server detected" (`environment.platform.machine`), falling back to a + * generic server. Lives on the server, not the client, so every device + * sees the same machine. A kind picked on a newer server decodes as null + * here rather than failing the whole settings snapshot for an older client. + */ + environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), @@ -945,6 +955,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), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), From 9ebbeda5a03fd8fce7be147b65ea396e50721c68 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:25:21 -0700 Subject: [PATCH 03/46] feat(web): apply and remove labels from the pull request tab (#9313) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/server/src/auth/RpcAuthorization.ts | 2 + .../pullRequest/GitHubPullRequestCli.test.ts | 66 ++++++++- .../src/pullRequest/GitHubPullRequestCli.ts | 72 ++++++++++ .../GitHubPullRequestProvider.test.ts | 63 ++++++-- .../pullRequest/GitHubPullRequestProvider.ts | 18 +++ .../src/pullRequest/PullRequestProvider.ts | 18 +++ .../pullRequest/PullRequestService.test.ts | 119 +++++++++++++++ .../src/pullRequest/PullRequestService.ts | 87 +++++++++++ .../pullRequest/gitHubPullRequestJson.test.ts | 74 +++++++++- .../src/pullRequest/gitHubPullRequestJson.ts | 106 ++++++++++++++ apps/server/src/ws.ts | 10 ++ .../PullRequestCandidatePicker.tsx | 136 ++++++++++++++++++ .../pullRequest/PullRequestLabelPicker.tsx | 132 +++++++++++++++++ .../pullRequest/PullRequestReviewerPicker.tsx | 117 +++++---------- .../pullRequest/PullRequestSummaryTab.tsx | 47 +++--- docs/user/source-control.md | 2 + .../client-runtime/src/state/pullRequests.ts | 12 ++ packages/contracts/src/pullRequest.ts | 39 +++++ packages/contracts/src/rpc.ts | 19 +++ 19 files changed, 1025 insertions(+), 114 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index c1eb8b15764..0a86a0835d8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -75,6 +75,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 3db5c8884f3..1e6ca0ed43a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2726,7 +2726,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, + }); }), ); @@ -2889,7 +2894,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")); }), ); @@ -3029,4 +3039,56 @@ 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 c418fd0d37e..3dc41896037 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -18,6 +18,7 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerKind, + type PullRequestLabelCandidateList, type PullRequestThreadCommentsResult, type PullRequestUpdateMethod, } from "@t3tools/contracts"; @@ -42,6 +43,9 @@ import { decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, + decodeLabelCandidatesJson, + buildLabelRequestJson, + LABEL_CANDIDATES_GRAPHQL_QUERY, decodeReviewDismissalsJson, decodeReviewThreadCommentsJson, decodeReviewThreadsJson, @@ -597,6 +601,24 @@ export class GitHubPullRequestCli extends Context.Service< readonly requested: 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; @@ -1977,6 +1999,56 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid); }, + 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 }, + ); + }, + runPullRequestAction: (input) => { if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 7e6016288af..8fd0f09dd3e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -47,7 +47,14 @@ it.effect("uses one narrow read for a linked pull request summary", () => describe("gitHubViewerPermissions", () => { it("offers everything to a viewer who can write to the repository", () => { - expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ + expect( + gitHubViewerPermissions({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + }), + ).toEqual({ // Arming a merge for later is the merge, so it travels with it. actions: [ "merge", @@ -64,6 +71,7 @@ describe("gitHubViewerPermissions", () => { resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, + labels: true, }); }); @@ -71,7 +79,12 @@ describe("gitHubViewerPermissions", () => { // Every open-source pull request somebody else opened: GitHub says no to all five actions // and to resolving, and yes to commenting and to every verdict. expect( - gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }), + gitHubViewerPermissions({ + canWrite: false, + canTriage: false, + canUpdate: false, + didAuthor: false, + }), ).toEqual({ actions: [], comment: true, @@ -79,11 +92,31 @@ 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, + }); + }); + + it("lets a triager label without letting them merge or ask for a review", () => { + const permissions = gitHubViewerPermissions({ + canWrite: false, + canTriage: true, + canUpdate: false, + didAuthor: false, }); + expect(permissions.labels).toBe(true); + expect(permissions.requestReviewers).toBe(false); + expect(permissions.actions).toEqual([]); }); it("keeps an author's own pull request theirs to close, with read access and no more", () => { - expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({ + expect( + gitHubViewerPermissions({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }), + ).toEqual({ // Merging is the one thing writing is needed for, now or later; the rest an author may do. actions: ["ready", "draft", "close", "reopen"], comment: true, @@ -91,6 +124,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, }); }); @@ -110,6 +144,7 @@ describe("gitHubViewerPermissions", () => { resolve: false, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, + labels: false, }); expect(detail.workflowApprovalsRequired).toBeUndefined(); expect(detail.checks).toContainEqual({ @@ -158,7 +193,12 @@ describe("gitHubViewerPermissions", () => { mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }), + Effect.succeed({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: false, + }), }), ), ), @@ -254,7 +294,7 @@ describe("gitHubViewerPermissions", () => { mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -318,7 +358,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -365,7 +405,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -404,7 +444,7 @@ it.effect("propagates workflow discovery rate limits", () => mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -420,7 +460,8 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ getPullRequestDetail: () => Effect.succeed(openDetail), getPullRequestBaseComparison: () => comparison, - getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }); it.effect("offers update-branch when the comparison grants it", () => @@ -466,7 +507,7 @@ describe("getViewerPermissions", () => { getViewerAccess: (input) => Effect.sync(() => { viewerAllowReserve = input.allowReserve; - return { canWrite: true, canUpdate: true, didAuthor: false }; + return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; }), }), ), @@ -501,7 +542,7 @@ describe("getViewerPermissions", () => { }), ), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5288040de25..5315dd2ed5d 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + labels: true, }; /** @@ -93,6 +94,8 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), + // Triage is the one role that labels without writing, which is what triage is for. + labels: access.canTriage, }; } @@ -526,6 +529,21 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("setReviewerRequest"))), + 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 155bf64109c..1459d7cec92 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -26,6 +26,7 @@ import type { PullRequestReviewVerdict, PullRequestReviewerCandidateList, PullRequestReviewerKind, + PullRequestLabelCandidateList, PullRequestState, PullRequestUpdateMethod, PullRequestViewerPermissions, @@ -469,6 +470,23 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * The repository's labels, with the ones already on the change request marked. Present with + * `setLabels` only where `capabilities.labels` is true; the service refuses both without it. + */ + 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 28c44e8d1b2..0a4d17280ad 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2284,6 +2284,125 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch }), ); +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, "You need 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, "You need 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 }, + 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 }); + }), +); + it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => Effect.gen(function* () { let hostCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 752cdf1aa5e..116087eb8f7 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -38,6 +38,8 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, + type PullRequestLabelCandidateList, + type PullRequestLabelChangeInput, type PullRequestSubmitReviewInput, type PullRequestSummary, type PullRequestThreadReplyInput, @@ -170,6 +172,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") {} @@ -213,6 +221,7 @@ const ACTION_ACCESS_REFUSALS: Record = { * sentence is only ever the answer where a host said no. */ const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; +const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; /** A project this page can read: its remote is on a host with an implementation. */ interface SupportedProject { @@ -473,6 +482,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), @@ -1843,6 +1856,78 @@ export const make = Effect.gen(function* () { }), ); + /** + * The labels, like the reviewer candidates, are wanted only by somebody about to change them, + * so the same permission guards the list and the change. + */ + 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 === false + ? 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 === false + ? 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"))), + ), + ); + }), + ); + /** * The line counts for rows already on the page, which the listing left out because on GitHub * they cost more than everything else on the row put together. @@ -2336,6 +2421,8 @@ export const make = Effect.gen(function* () { // The candidate list is deliberately read fresh per menu-open, so it stays uncached. reviewerCandidates, requestReviewers: invalidatedByMutation(requestReviewers), + labelCandidates, + setLabels: invalidatedByMutation(setLabels), invalidate, }); }); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 000dbada204..f6f5957f587 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -11,6 +11,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodeLabelCandidatesJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, decodeReviewThreadCommentsJson, @@ -832,7 +833,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", () => { @@ -845,7 +846,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", () => { @@ -854,10 +855,79 @@ 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, }); }); + + it("reads triage as enough to label, and not enough to write", () => { + const access = expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + 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); + }); }); describe("review thread decoding", () => { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 9cc21b42939..12fb376d3ed 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -24,6 +24,8 @@ import type { PullRequestReviewerCandidate, PullRequestReviewerCandidateList, PullRequestReviewerKind, + PullRequestLabelCandidate, + PullRequestLabelCandidateList, PullRequestState, PullRequestThreadComment, } from "@t3tools/contracts"; @@ -1994,6 +1996,11 @@ function toCanWrite(viewerPermission: string | null | undefined): boolean { } } +/** Triage is the least role GitHub lets label a pull request; it is not a write. */ +function toCanTriage(viewerPermission: string | null | undefined): boolean { + return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); +} + export function decodeRepositoryAccessJson( raw: string, ): Result.Result { @@ -2222,6 +2229,99 @@ export function buildReviewerRequestJson( }); } +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 }); +} + /** * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the * repository, the other two about this pull request in particular — which is why an author with @@ -2229,6 +2329,11 @@ export function buildReviewerRequestJson( */ export interface GitHubViewerAccess { readonly canWrite: boolean; + /** + * The viewer's role reaches triage, which is the least that may label. Everyone who can write + * can triage; a triager is the one role that can label without being able to merge. + */ + readonly canTriage: boolean; /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ readonly canUpdate: boolean; readonly didAuthor: boolean; @@ -2275,6 +2380,7 @@ export function decodeViewerPermissionsJson( const repository = decoded.success.data.repository; return Result.succeed({ canWrite: toCanWrite(repository.viewerPermission), + canTriage: toCanTriage(repository.viewerPermission), ...toPullRequestViewerFields(repository.pullRequest), }); } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 28ade015f8b..cb00bbee73f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1976,6 +1976,16 @@ const makeWsRpcLayer = ( pullRequests.requestReviewers(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.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx new file mode 100644 index 00000000000..b42061628d1 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx @@ -0,0 +1,136 @@ +/** + * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a + * scrolling body that says when the list is loading, could not be read, is empty, or is not all + * of it. The rows and the words are the caller's; the frame is the same either way. + */ +import type { ReactNode } from "react"; + +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestPeopleGhost } from "./PullRequestGhosts"; + +export function PullRequestCandidatePicker({ + icon, + label, + allowed, + disabledReason, + open, + onOpenChange, + query, + onQueryChange, + searchLabel, + isPending, + error, + candidates, + emptyLabel, + noMatchLabel, + errorLabel, + truncated, + truncatedLabel, + candidateKey, + disabled, + onSelect, + children, +}: { + icon: ReactNode; + /** The trigger's accessible name; the button carries an icon alone. */ + label: string; + /** False where the host would refuse this account's change. Disabled with the reason rather + * than hidden: a control that vanishes teaches nobody why. */ + allowed: boolean; + disabledReason: string; + open: boolean; + onOpenChange: (open: boolean) => void; + query: string; + onQueryChange: (query: string) => void; + searchLabel: string; + isPending: boolean; + error: string | null; + /** Already narrowed by the query; the shell only decides which state to show. */ + candidates: ReadonlyArray; + emptyLabel: string; + noMatchLabel: string; + /** Leads the host's own message, which follows it in the same sentence. */ + errorLabel: string; + /** The host has more than the read asked for, so a name missing here may still be askable. */ + truncated: boolean; + truncatedLabel: string; + candidateKey: (candidate: T) => string; + /** Every row locks while one change is in flight, so a second press cannot race the first. */ + disabled: boolean; + onSelect: (candidate: T) => void; + children: (candidate: T) => ReactNode; +}) { + if (!allowed) { + return ( + + + {icon} + + } + /> + {disabledReason} + + ); + } + + return ( + + + {icon} + + } + /> + +
+ onQueryChange(event.currentTarget.value)} + placeholder={searchLabel} + aria-label={searchLabel} + size="compact" + /> +
+
+ {isPending ? ( + + ) : error !== null ? ( +

+ {errorLabel} {error} +

+ ) : candidates.length === 0 ? ( +

+ {query.length > 0 ? noMatchLabel : emptyLabel} +

+ ) : ( + candidates.map((candidate) => ( + // Stays open on press: a change is confirmed by the row's own check turning over, + // and a second label or reviewer is usually wanted right after the first. + onSelect(candidate)} + className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" + > + {children(candidate)} + + )) + )} + {truncated ? ( + // Typing filters what arrived; it does not ask the host again, so this says what the + // list is rather than offering a search that would find nothing further. +

{truncatedLabel}

+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx new file mode 100644 index 00000000000..f1e5fc6a1da --- /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); + 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; + } + onChanged(); + candidatesQuery.refresh(); + }; + + 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/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index 8330c87a929..a4da0d99514 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -18,12 +18,8 @@ import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; -import { PullRequestPeopleGhost } from "./PullRequestGhosts"; +import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; import { PullRequestActorLabel } from "./pullRequestPresentation"; import { readableFailure } from "./pullRequestDetail.logic"; @@ -104,85 +100,40 @@ export function PullRequestReviewerPicker({ candidatesQuery.refresh(); }; - if (!allowed) { - return ( - - - - - } - /> - - Asking someone to review needs write access on this repository - - - ); - } - return ( - - - - - } - /> - -
- setQuery(event.currentTarget.value)} - placeholder="Search people with access" - aria-label="Search people with access" - size="compact" - /> -
-
- {candidatesQuery.isPending ? ( - - ) : candidatesQuery.error !== null ? ( -

- The people with access could not be read. {candidatesQuery.error} -

- ) : candidates.length === 0 ? ( -

- {query.length > 0 - ? "Nobody with access matches that." - : "Nobody else has access to this repository."} -

- ) : ( - candidates.map((candidate) => ( - - )) - )} - {candidatesQuery.data?.truncated ? ( - // Typing filters what arrived; it does not ask the host again, so this says what the - // list is rather than offering a search that would find nothing further. -

- This repository has more people with access than are listed here. Ask for the rest on - the host. -

+ } + label="Request a review" + allowed={allowed} + disabledReason="Asking someone to review needs write access on this repository" + open={open} + onOpenChange={setOpen} + query={query} + onQueryChange={setQuery} + searchLabel="Search people with access" + isPending={candidatesQuery.isPending} + error={candidatesQuery.error} + candidates={candidates} + emptyLabel="Nobody else has access to this repository." + noMatchLabel="Nobody with access matches that." + errorLabel="The people with access could not be read." + truncated={candidatesQuery.data?.truncated === true} + truncatedLabel="This repository has more people with access than are listed here. Ask for the rest on the host." + candidateKey={(candidate) => `${candidate.kind}:${candidate.id}`} + disabled={pending !== null} + onSelect={(candidate) => void toggle(candidate)} + > + {(candidate) => ( + <> + + {candidate.kind === "team" ? ( + team + ) : null} + {candidate.isRequested ? ( + ) : null} -
-
-
+ + )} + ); } diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 5a3144dadce..eccd9de9b1f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -41,6 +41,7 @@ import { pullRequestReviewOutcomeRingClassName, pullRequestReviewOutcomeStaleLabel, } from "./pullRequestPresentation"; +import { PullRequestLabelPicker } from "./PullRequestLabelPicker"; import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { @@ -664,25 +665,39 @@ export function PullRequestSummaryTab({ ) : null} - {detail.labels.length > 0 ? ( + {/* The row is shown empty only where a label could be put on it from here; on a host + with none to offer, an empty row is a row about nothing. */} + {detail.labels.length > 0 || detail.capabilities.labels === true ? ( } label="Labels"> - {detail.labels.map((label) => { - const dot = pullRequestLabelColor(label.color); - return ( - + {detail.labels.length === 0 ? ( + None + ) : ( + detail.labels.map((label) => { + const dot = pullRequestLabelColor(label.color); + return ( - {label.name} - - ); - })} + key={label.name} + className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" + > + + {label.name} + + ); + }) + )} + {detail.capabilities.labels === true ? ( + + ) : null} ) : null} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 1573a151ce5..10d1995fa66 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -73,6 +73,8 @@ T3 Code works with the platforms your team already uses: - Rewrite your own comments the same way, wherever they are shown - Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its comments stay read-only here, as they already were +- On GitHub, put a label on a pull request or take one off from the **Labels** row of the review. + Changing labels needs triage access or better on the repository ### Know Your Setup at a Glance diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index b98524e0482..28c85ffe5c1 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -204,6 +204,18 @@ export function createPullRequestEnvironmentAtoms( scheduler: commandScheduler, concurrency: serialPerEnvironment, }), + /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ + labelCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:label-candidates", + tag: WS_METHODS.pullRequestsLabelCandidates, + staleTimeMs: 60_000, + }), + setLabels: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:set-labels", + tag: WS_METHODS.pullRequestsSetLabels, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), setThreadResolution: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-thread-resolution", tag: WS_METHODS.pullRequestsSetThreadResolution, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 9cf7dbfd668..04c9d53cf77 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -291,6 +291,21 @@ export const PullRequestReviewerCandidateList = Schema.Struct({ }); export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type; +/** A label the repository defines, with whether this change request already wears it. */ +export const PullRequestLabelCandidate = Schema.Struct({ + ...PullRequestLabel.fields, + description: Schema.NullOr(Schema.String), + isApplied: Schema.Boolean, +}); +export type PullRequestLabelCandidate = typeof PullRequestLabelCandidate.Type; + +export const PullRequestLabelCandidateList = Schema.Struct({ + candidates: Schema.Array(PullRequestLabelCandidate), + /** The repository defines more labels than the read asked for; the list is not all of them. */ + truncated: Schema.Boolean, +}); +export type PullRequestLabelCandidateList = typeof PullRequestLabelCandidateList.Type; + export const PullRequestCommit = Schema.Struct({ oid: TrimmedNonEmptyString, messageHeadline: Schema.String, @@ -397,6 +412,12 @@ export const PullRequestCapabilities = Schema.Struct({ * what every server before this one was. */ edit: Schema.optional(PullRequestEditCapabilities), + /** + * The repository's labels can be listed, and one put on a change request or taken off it. + * Optional for the same reason `edit` is: a server that says nothing about labels has no way + * to change them, which is what every server before this field was. + */ + labels: Schema.optional(Schema.Boolean), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; @@ -426,6 +447,12 @@ export const PullRequestViewerPermissions = Schema.Struct({ * Absent or empty means they may not, which is also what a host with no such action says. */ updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)), + /** + * This viewer may put a label on the change request, and take one off. Absent is granted, like + * every permission here; the capability beside it is what decides whether a label can be + * changed on this host at all. + */ + labels: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; @@ -998,6 +1025,18 @@ export const PullRequestReviewerRequestInput = Schema.Struct({ }); export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type; +/** + * Putting a label on and taking it off are one operation with `applied` turned around, which is + * what pressing the same row in the menu twice is. Named by the label's own name, which is how + * GitHub addresses one. + */ +export const PullRequestLabelChangeInput = Schema.Struct({ + ...PullRequestRef.fields, + labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(25)), + applied: Schema.Boolean, +}); +export type PullRequestLabelChangeInput = typeof PullRequestLabelChangeInput.Type; + export const PullRequestUnavailableReason = Schema.Literals([ "cli-missing", "cli-unauthenticated", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9c009baabcc..1e964dc3771 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -100,6 +100,8 @@ import { PullRequestSummary, PullRequestReviewerCandidateList, PullRequestReviewerRequestInput, + PullRequestLabelCandidateList, + PullRequestLabelChangeInput, PullRequestSubmitReviewInput, PullRequestThreadCommentsInput, PullRequestThreadCommentsResult, @@ -319,6 +321,8 @@ export const WS_METHODS = { pullRequestsInvalidate: "pullRequests.invalidate", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", + pullRequestsLabelCandidates: "pullRequests.labelCandidates", + pullRequestsSetLabels: "pullRequests.setLabels", // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", @@ -630,6 +634,19 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest error: PullRequestRpcError, }); +/** Read when the label menu opens, for the same reason the reviewer candidates are. */ +export const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { + payload: PullRequestRef, + success: PullRequestLabelCandidateList, + error: PullRequestRpcError, +}); + +export const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { + payload: PullRequestLabelChangeInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -1087,6 +1104,8 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsInvalidateRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, + WsPullRequestsLabelCandidatesRpc, + WsPullRequestsSetLabelsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, From 31eeb443305a4e11c8b20a7c8ff2b3f5e841eb0f Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 22:36:35 -0400 Subject: [PATCH 04/46] fix(sidebar): collapse settled and snoozed shelves by default (#9314) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../use-thread-list-v2-shelf-preferences.ts | 8 +++--- apps/mobile/src/lib/storage.test.ts | 26 ++++++++++--------- .../src/persistence/mobile-preferences.ts | 19 +++++++------- apps/web/src/components/Sidebar.tsx | 8 +++--- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index d4599336472..8ffab4e045b 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -14,9 +14,9 @@ export function useThreadListV2ShelfPreferences() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); const snoozedShelfExpanded = - loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; const settledShelfExpanded = - !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + loaded && preferencesResult.value.threadListSettledShelfExpanded === true; const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); const settledShelfExpandedRef = useRef(settledShelfExpanded); snoozedShelfExpandedRef.current = snoozedShelfExpanded; @@ -26,13 +26,13 @@ export function useThreadListV2ShelfPreferences() { if (!loaded) return; const expanded = !snoozedShelfExpandedRef.current; snoozedShelfExpandedRef.current = expanded; - savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + savePreferences({ threadListSnoozedShelfExpanded: expanded }); }, [loaded, savePreferences]); const toggleSettledShelf = useCallback(() => { if (!loaded) return; const expanded = !settledShelfExpandedRef.current; settledShelfExpandedRef.current = expanded; - savePreferences({ threadListV2SettledShelfExpanded: expanded }); + savePreferences({ threadListSettledShelfExpanded: expanded }); }, [loaded, savePreferences]); return { diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a1c7960a570..15e385067dc 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -213,33 +213,35 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists Thread List v2 shelf expansion preferences", async () => { + it("persists thread list shelf expansion preferences", async () => { await expect( savePreferencesPatch({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }), ).resolves.toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); await expect(loadPreferences()).resolves.toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); }); - it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + it("drops legacy and invalid thread list shelf expansion preferences", async () => { mocks.setPreferencesJson( JSON.stringify({ baseFontSize: 17, - threadListV2SettledShelfExpanded: "false", - threadListV2SnoozedShelfExpanded: 1, + threadListV2SettledShelfExpanded: true, + threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: "false", + threadListSnoozedShelfExpanded: 1, }), 10, ); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index cf4c29c6041..f14a73f15cb 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -41,10 +41,9 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; - /** Undefined preserves the default expanded Settled shelf. */ - readonly threadListV2SettledShelfExpanded?: boolean; - /** Undefined preserves the default collapsed Snoozed shelf. */ - readonly threadListV2SnoozedShelfExpanded?: boolean; + /** Fresh keys reset both shelves to collapsed when users update. */ + readonly threadListSettledShelfExpanded?: boolean; + readonly threadListSnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -102,8 +101,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; - threadListV2SettledShelfExpanded?: boolean; - threadListV2SnoozedShelfExpanded?: boolean; + threadListSettledShelfExpanded?: boolean; + threadListSnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -171,11 +170,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } - if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { - preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + if (typeof parsed.threadListSettledShelfExpanded === "boolean") { + preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } - if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { - preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { + preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; } return preferences; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 69f9ea2f30c..0c18c42c8db 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -211,9 +211,9 @@ import { // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; -// Keep the v2 key so existing preferences survive the v2-to-default rename. -const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; -const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +// Fresh keys deliberately reset both shelves to collapsed for existing users. +const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; +const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -2274,7 +2274,7 @@ export default function Sidebar() { ); const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, - true, + false, Schema.Boolean, ); const toggleSettledShelf = useCallback( From 922bd692251bc803c12a3fab159efe83c957bb70 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:37:41 -0700 Subject: [PATCH 05/46] refactor(media): unify file and media previews across clients (#9253) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- .../t3-markdown-text/src/markdownLinks.ts | 156 +------- .../ios/T3NativeFilePresentation.swift | 2 + .../components/ComposerAttachmentStrip.tsx | 55 +-- .../mobile/src/components/FilePreview.ios.tsx | 7 +- .../src/components/MediaActionsMenu.tsx | 4 +- .../src/components/MediaImagePreview.tsx | 2 +- .../src/components/MediaVideoPlayer.tsx | 81 ++--- .../src/components/MediaVideoPreviewModal.tsx | 96 ----- .../src/components/VideoAttachmentMenu.tsx | 53 --- .../src/components/VideoAttachmentTile.tsx | 98 +++--- .../src/components/VideoPreviewModal.ios.tsx | 58 ++- .../src/components/VideoPreviewModal.tsx | 333 ++++++++---------- .../features/files/ThreadFilesRouteScreen.tsx | 2 +- .../files/WorkspaceFileImagePreview.tsx | 113 ++---- .../files/WorkspaceFileVideoPreview.tsx | 7 - .../files/workspace-file-image-cache.test.ts | 64 ---- .../files/workspace-file-image-cache.ts | 48 --- .../src/features/threads/ThreadFeed.tsx | 158 +++++---- apps/mobile/src/lib/markdownMedia.test.ts | 11 + apps/mobile/src/lib/markdownMedia.ts | 66 +--- apps/mobile/src/lib/mediaActions.ts | 119 ++++--- apps/mobile/src/lib/videoPreviewSource.ts | 63 +++- apps/mobile/src/state/assets.ts | 40 +-- apps/web/src/assets/assetUrls.ts | 59 ++-- apps/web/src/components/ChatMarkdown.tsx | 24 -- .../web/src/components/ChatView.logic.test.ts | 9 - apps/web/src/components/ChatView.logic.ts | 9 - apps/web/src/components/ChatView.tsx | 65 +--- apps/web/src/components/chat/ChatComposer.tsx | 16 +- .../components/chat/ExpandedImageDialog.tsx | 60 ++-- .../components/chat/ExpandedImagePreview.tsx | 90 ++--- .../components/chat/MessagesTimeline.test.tsx | 47 ++- .../src/components/chat/MessagesTimeline.tsx | 93 ++--- .../web/src/components/media/MediaActions.tsx | 37 +- .../src/components/media/MediaVideoPlayer.tsx | 36 +- apps/web/src/filePathDisplay.ts | 27 +- apps/web/src/markdown-links.test.ts | 9 +- apps/web/src/markdown-links.ts | 239 ++----------- apps/web/src/terminal-links.ts | 40 +-- docs/internals/mobile-navigation.md | 19 +- docs/user/composer.md | 22 +- packages/client-runtime/package.json | 8 + packages/client-runtime/src/markdownImages.ts | 80 ++--- .../client-runtime/src/markdownLinks.test.ts | 142 +++++++- packages/client-runtime/src/markdownLinks.ts | 205 ++++++++++- packages/client-runtime/src/mediaActions.ts | 8 + packages/client-runtime/src/mediaReference.ts | 9 +- .../client-runtime/src/mediaSource.test.ts | 141 ++++++++ packages/client-runtime/src/mediaSource.ts | 104 ++++++ packages/client-runtime/src/state/assets.ts | 38 +- .../src/work-log/presentation.test.ts | 16 +- .../src/work-log/presentation.ts | 34 +- 52 files changed, 1582 insertions(+), 1740 deletions(-) delete mode 100644 apps/mobile/src/components/MediaVideoPreviewModal.tsx delete mode 100644 apps/mobile/src/components/VideoAttachmentMenu.tsx delete mode 100644 apps/mobile/src/features/files/workspace-file-image-cache.test.ts delete mode 100644 apps/mobile/src/features/files/workspace-file-image-cache.ts create mode 100644 packages/client-runtime/src/mediaActions.ts create mode 100644 packages/client-runtime/src/mediaSource.test.ts create mode 100644 packages/client-runtime/src/mediaSource.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 0caa24c3404..17658534416 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -1,31 +1,15 @@ import { + fileBasename, + formatFilePathPosition, inlineCodeFilePathCandidate, - isConventionalFilePosition, + normalizeMarkdownLinkDestination, + parseMarkdownFileLink, } from "@t3tools/client-runtime/markdown-links"; import { videoMimeType } from "@t3tools/shared/video"; import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; -const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSIX_FILE_ROOT_PREFIXES = [ - "/Users/", - "/home/", - "/tmp/", - "/var/", - "/etc/", - "/opt/", - "/mnt/", - "/Volumes/", - "/private/", - "/root/", -] as const; export type MarkdownLinkPresentation = | { @@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly> = { zsh: "bash", }; -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -function normalizeDestination(value: string): string { - const trimmed = value.trim(); - return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; -} - /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ export function normalizeNativeMarkdownUrl(value: string): string { return value.startsWith("//") ? `https:${value}` : value; } -function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { - try { - const parsed = new URL(href); - if (parsed.protocol.toLowerCase() !== "file:") { - return null; - } - const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; - const rawPath = uncHostname - ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` - : parsed.pathname; - const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; - return { path, hash: parsed.hash }; - } catch { - return null; - } -} - -function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { - const hashIndex = value.indexOf("#"); - const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; - const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; - const queryIndex = pathWithSearch.indexOf("?"); - return { - path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, - hash, - }; -} - -function splitFilePosition( - path: string, - hash: string, -): { readonly path: string; readonly line?: number; readonly column?: number } { - const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); - const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); - const match = suffixMatch ?? hashMatch; - if (!match?.[1]) { - return { path }; - } - - const line = Number.parseInt(match[1], 10); - const column = match[2] ? Number.parseInt(match[2], 10) : undefined; - const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; - return { - path: pathWithoutPosition, - ...(line > 0 ? { line } : {}), - ...(column !== undefined && column > 0 ? { column } : {}), - }; -} - -function looksLikePosixFilesystemPath(path: string): boolean { - if (!path.startsWith("/")) { - return false; - } - if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { - return true; - } - if (POSITION_SUFFIX_PATTERN.test(path)) { - return true; - } - const basename = path.slice(path.lastIndexOf("/") + 1); - return /\.[A-Za-z0-9_-]+$/.test(basename); -} - -function looksLikeFilePath(value: string): boolean { - if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { - return true; - } - if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { - return true; - } - if (value.startsWith("/")) { - return looksLikePosixFilesystemPath(value); - } - if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { - return true; - } - if (isConventionalFilePosition(value)) return true; - return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); -} - -function fileLabel(value: string): string { - const normalized = value.replaceAll("\\", "/"); - const basename = normalized.slice(normalized.lastIndexOf("/") + 1); - return basename || normalized; -} - export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { - const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; @@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { } export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { - const normalized = normalizeDestination(href); + const normalized = normalizeMarkdownLinkDestination(href); try { const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { @@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese // Relative paths and non-URL link destinations are handled below. } - const source = normalized.toLowerCase().startsWith("file:") - ? fileUrlTarget(normalized) - : stripSearchAndHash(normalized); - const decodedSource = source - ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } - : null; - const fileTarget = decodedSource - ? splitFilePosition(decodedSource.path, decodedSource.hash) - : null; - const targetWithPosition = fileTarget - ? `${fileTarget.path}${ - fileTarget.line - ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` - : "" - }` - : null; - if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { + const target = parseMarkdownFileLink(normalized); + if (target) { return { kind: "file", href: normalized, - icon: resolveMarkdownFileIcon(fileTarget.path), - label: fileLabel(targetWithPosition), - path: fileTarget.path, - ...(fileTarget.line ? { line: fileTarget.line } : {}), - ...(fileTarget.column ? { column: fileTarget.column } : {}), + icon: resolveMarkdownFileIcon(target.path), + label: fileBasename(formatFilePathPosition(target)), + path: target.path, + ...(target.line ? { line: target.line } : {}), + ...(target.column ? { column: target.column } : {}), }; } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift index 1a7009c3821..45954053125 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -144,6 +144,8 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, type = detectedType } else if CGPDFDocument(download as CFURL) != nil { type = .pdf + } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" { + type = .svg } else { throw URLError(.cannotDecodeContentData) } diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 16f0d422af7..b5d86c44109 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,12 +1,12 @@ import { SymbolView } from "../components/AppSymbol"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useRef, useState } from "react"; -import { Alert, Image, Pressable, ScrollView, View } from "react-native"; +import { useMemo } from "react"; +import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { MediaActionsSource } from "../lib/mediaActions"; import { PresentationSource } from "./NativePresentation"; import type { FilePreviewSource } from "./FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; @@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; - const shareRef = useRef(null); - const [sharing, setSharing] = useState(false); - useEffect( - () => () => { - shareRef.current?.abort(); - shareRef.current = null; - }, - [], + const actionsSource = useMemo( + () => ({ + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + sourceIdentifier, + attachment, + }), + [attachment, sourceIdentifier], ); - const onShare = () => { - if (shareRef.current) return; - const controller = new AbortController(); - shareRef.current = controller; - setSharing(true); - void (async () => { - const preview = await loadLocalAttachmentPreview(attachment, controller.signal); - if (!preview) return; - try { - await preview.share(controller.signal, sourceIdentifier); - } finally { - preview.dispose(); - } - })() - .catch((error: unknown) => { - if (!controller.signal.aborted) { - Alert.alert( - "Could not share video", - error instanceof Error ? error.message : "Try again.", - ); - } - }) - .finally(() => { - if (shareRef.current === controller) { - shareRef.current = null; - setSharing(false); - } - }); - }; - return ( props.onPressVideo(attachment, sourceIdentifier)} - onShare={onShare} - disabled={sharing} + actionsSource={actionsSource} style={style} /> ); diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index c2f5a6d72cc..af943b8be9a 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; -import { MediaImagePreview } from "./MediaImagePreview"; const NativeControls = requireNativeModule<{ presentFile( @@ -47,9 +46,5 @@ export function FilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; }) { - return props.source.kind === "image" && props.source.actionsSource ? ( - - ) : ( - - ); + return ; } diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx index a4b44e85258..f54e1378ba8 100644 --- a/apps/mobile/src/components/MediaActionsMenu.tsx +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -1,6 +1,6 @@ import { MenuView } from "@react-native-menu/menu"; import type { ReactElement } from "react"; -import { Platform, View, type PressableProps } from "react-native"; +import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import type { useMediaActions } from "../lib/mediaActions"; import { SymbolView } from "./AppSymbol"; @@ -10,6 +10,7 @@ export function MediaActionsMenu(props: { readonly media: ReturnType; readonly inModal?: boolean; readonly children?: ReactElement; + readonly style?: StyleProp; }) { if (props.media.actions.length === 0) return props.children ?? null; // Android's normal anchored menu lives in the app-root portal, behind native modals. @@ -18,6 +19,7 @@ export function MediaActionsMenu(props: { return ( ({ id, diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 5bdc9140ddc..03e317c296a 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -42,7 +42,7 @@ function ImagePreviewHeader() { ); } -/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +/** Android keeps media actions in its in-app image viewer. iOS uses Quick Look. */ export function MediaImagePreview(props: MediaImagePreviewProps) { return ( diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index a065f75e139..0e3d8416aff 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -101,8 +101,8 @@ interface MediaVideoPlayerProps { readonly thumbnailVisible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + readonly autoPlay?: boolean; readonly paused?: boolean; - readonly onExpand?: () => void; readonly actionsSource?: MediaActionsSource; } @@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { ) : ( - setPlaybackUri(props.uri)} - className="flex-1 items-center justify-center gap-2 px-4" - > - {!props.unavailable ? ( - - ) : null} - {props.unavailable ? ( - Video unavailable - ) : props.uri === null ? ( - - ) : ( - <> + + 0 ? "Touch and hold for media actions" : undefined + } + accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} + // Stays pressable so the long-press menu still opens on a failed or unsigned tile. + onPress={() => { + if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); + }} + className="flex-1 items-center justify-center px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + )} + {props.uri !== null && !props.unavailable ? ( {props.name} - - )} - + ) : null} + + )} - {props.onExpand ? ( - { - setPlaybackUri(null); - props.onExpand?.(); - }} - className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" - > - Expand - - ) : null} - {props.actionsSource ? ( - - - - ) : null} ); } diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx deleted file mode 100644 index 6c231194701..00000000000 --- a/apps/mobile/src/components/MediaVideoPreviewModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect } from "react"; -import { Keyboard, Modal, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { useMediaActions } from "../lib/mediaActions"; -import { MediaActionsMenu } from "./MediaActionsMenu"; -import { - mediaVideoPreviewUri, - mediaVideoThumbnailKey, - type MediaVideoPreviewSource, -} from "../lib/videoPreviewSource"; -import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; -import { usePreparedConnection } from "../state/session"; -import { AppText } from "./AppText"; -import { SymbolView } from "./AppSymbol"; -import { MediaVideoPlayer } from "./MediaVideoPlayer"; -import { MediaSourceCaption } from "./MediaSourceCaption"; - -/** Media files stream in place. A client-side copy is made only for an explicit share. */ -export function MediaVideoPreviewModal(props: { - readonly source: MediaVideoPreviewSource; - readonly onRequestClose: () => void; -}) { - const { source } = props; - const insets = useSafeAreaInsets(); - const environmentId = "environmentId" in source ? source.environmentId : null; - const connection = usePreparedConnection(environmentId); - const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); - const refreshAssetUrl = useRefreshAssetUrl( - environmentId, - "resource" in source ? source.resource : null, - ); - const resolvePlaybackUri = - "resource" in source - ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) - : undefined; - const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); - const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); - const unavailable = - uri === null && - environmentId !== null && - (connection._tag === "None" || asset._tag === "Failure"); - - useEffect(() => Keyboard.dismiss(), []); - return ( - - - - - {source.name} - - - - - - - - - - - {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} - - - - - ); -} diff --git a/apps/mobile/src/components/VideoAttachmentMenu.tsx b/apps/mobile/src/components/VideoAttachmentMenu.tsx deleted file mode 100644 index 301d6503a50..00000000000 --- a/apps/mobile/src/components/VideoAttachmentMenu.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ReactElement } from "react"; -import { Platform, type PressableProps } from "react-native"; - -import { ControlPillMenu } from "./ControlPill"; -import { PresentationSource } from "./NativePresentation"; - -export function VideoAttachmentMenu(props: { - readonly sourceIdentifier: string; - readonly onOpen: () => void; - readonly onShare?: () => void; - readonly disabled?: boolean; - readonly children: ReactElement; -}) { - return ( - { - if (!props.disabled) props.onOpen(); - }} - accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} - onAccessibilityAction={({ nativeEvent }) => { - if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); - }} - > - {Platform.OS === "ios" && props.onShare ? ( - { - if (nativeEvent.event === "share") props.onShare?.(); - }} - > - {props.children} - - ) : ( - props.children - )} - - ); -} diff --git a/apps/mobile/src/components/VideoAttachmentTile.tsx b/apps/mobile/src/components/VideoAttachmentTile.tsx index 6f582ac5f00..f8c7e2c1cbd 100644 --- a/apps/mobile/src/components/VideoAttachmentTile.tsx +++ b/apps/mobile/src/components/VideoAttachmentTile.tsx @@ -2,65 +2,79 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react import { cn } from "../lib/cn"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; -import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { PresentationSource } from "./NativePresentation"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; export function VideoAttachmentTile(props: { readonly name: string; readonly sourceIdentifier: string; readonly thumbnailSource: string | DraftComposerFileAttachment | null; + readonly actionsSource?: MediaActionsSource; readonly compact?: boolean; readonly onPress: (sourceIdentifier: string) => void; - readonly onShare?: () => void; readonly disabled?: boolean; readonly className?: string; readonly style?: StyleProp; }) { + const mediaActions = useMediaActions(props.disabled ? undefined : props.actionsSource); + const hasActions = mediaActions.actions.length > 0; return ( - props.onPress(props.sourceIdentifier)} - onShare={props.onShare} - disabled={props.disabled} + { + if (!props.disabled) props.onPress(props.sourceIdentifier); + }} + accessibilityActions={mediaActions.actions.map(({ id, title }) => ({ + name: id, + label: title, + }))} + onAccessibilityAction={({ nativeEvent }) => { + if (props.disabled) return; + mediaActions.actions.find(({ id }) => id === nativeEvent.actionName)?.run(); + }} > - props.onPress(props.sourceIdentifier)} - className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} - style={props.style} - > - - + props.onPress(props.sourceIdentifier)} + className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} + style={props.style} > - - - {!props.compact ? ( - - - {props.name} - + + + - ) : null} - - + {!props.compact ? ( + + + {props.name} + + + ) : null} + + + ); } diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 88a1b5191dd..ae69e0ead89 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -1,14 +1,12 @@ import { useIsFocused } from "@react-navigation/native"; -import { videoMimeType } from "@t3tools/shared/video"; import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId, useState } from "react"; import { Alert, Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import { useAssetUrlState } from "../state/assets"; +import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; -import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; @@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{ }>("T3NativeControls"); function NativeVideoPreview(props: { - readonly source: AttachmentVideoPreviewSource; + readonly source: VideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; - const { attachment } = source; + const localAttachment = source.type === "local" ? source.attachment : null; const identifier = useId(); const onRequestClose = useEffectEvent(props.onRequestClose); - const environmentId = source.type === "remote" ? source.environmentId : null; + const environmentId = + source.type === "media" && "environmentId" in source ? source.environmentId : null; + const resource = source.type === "media" && "resource" in source ? source.resource : null; const preparedConnection = usePreparedConnection(environmentId); - const mimeType = videoMimeType(attachment) ?? attachment.mimeType; - const assetUrl = useAssetUrlState( - environmentId, - source.type === "remote" - ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } - : null, - ); - const [playbackUrl, setPlaybackUrl] = useState(() => - assetUrl._tag === "Success" ? assetUrl.url : null, - ); + const assetUrl = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); + const name = source.type === "media" ? source.name : source.attachment.name; + // The first minted URL is kept so a background refresh does not restart playback. + const resolvedUrl = + source.type === "media" + ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null) + : null; + const [playbackUrl, setPlaybackUrl] = useState(resolvedUrl); const loadError = - source.type === "remote" && playbackUrl === null + resource !== null && playbackUrl === null ? preparedConnection._tag === "None" ? "Reconnect to this environment and open the video again." : assetUrl._tag === "Failure" @@ -53,8 +52,8 @@ function NativeVideoPreview(props: { useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); - }, [playbackUrl, assetUrl]); + if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); + }, [playbackUrl, resolvedUrl]); useEffect(() => { if (!loadError) return; Alert.alert("Could not open video", loadError); @@ -62,21 +61,21 @@ function NativeVideoPreview(props: { }, [loadError]); useEffect(() => { - if (source.type === "remote" && playbackUrl === null) return; + if (localAttachment === null && playbackUrl === null) return; const controller = new AbortController(); let ready = false; void (async () => { const file = - source.type === "local" - ? await loadLocalAttachmentPreview(source.attachment, controller.signal) + localAttachment !== null + ? await loadLocalAttachmentPreview(localAttachment, controller.signal) : null; - if (source.type === "local" && !file) return; + if (localAttachment !== null && !file) return; try { if (controller.signal.aborted) return; ready = true; await NativeControls.presentVideo( file?.uri ?? playbackUrl!, - attachment.name, + name, source.sourceIdentifier ?? "", identifier, ); @@ -87,10 +86,12 @@ function NativeVideoPreview(props: { } })().catch((error: unknown) => { if (controller.signal.aborted) return; + // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. + if (ready) void refreshAssetUrl(); Alert.alert( "Could not open video", ready - ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." + ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original." : error instanceof Error ? error.message : "Could not load this video.", @@ -101,7 +102,7 @@ function NativeVideoPreview(props: { controller.abort(); void NativeControls.dismissVideo(identifier).catch(() => undefined); }; - }, [source, attachment.name, playbackUrl, identifier]); + }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); return null; } @@ -118,8 +119,5 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; - if (props.source.type === "media") { - return ; - } return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index cc56b3952b7..2a585b5e1a3 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,190 +1,139 @@ import { useIsFocused } from "@react-navigation/native"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEvent } from "expo"; -import { useVideoPlayer, VideoView } from "expo-video"; import { useEffect, useRef, useState } from "react"; -import { - ActivityIndicator, - AppState, - Keyboard, - Modal, - Pressable, - StyleSheet, - View, -} from "react-native"; +import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - downloadAttachmentForPreview, - type AttachmentPreviewFile, -} from "../lib/attachmentDownload"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; -import { useAssetUrlState } from "../state/assets"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type LocalVideoPreviewSource, + type MediaVideoPreviewSource, + type VideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; -import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; +import { SymbolView } from "./AppSymbol"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; -function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { - const player = useVideoPlayer(props.file.uri, (player) => { - player.staysActiveInBackground = false; - if (AppState.currentState === "active") player.play(); - }); - const { status } = useEvent(player, "statusChange", { status: player.status }); - const shareControllerRef = useRef(null); - const [sharing, setSharing] = useState(false); - const [shareError, setShareError] = useState(null); - - useEffect( - () => () => { - shareControllerRef.current?.abort(); - shareControllerRef.current = null; - }, - [], - ); +interface PlaybackState { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly unavailable: boolean; + readonly error: string | null; + readonly actionsSource: MediaActionsSource | undefined; +} - const onShare = () => { - if (shareControllerRef.current) return; - player.pause(); - const controller = new AbortController(); - shareControllerRef.current = controller; - setSharing(true); - setShareError(null); - void props.file - .share(controller.signal) - .catch((error: unknown) => { - if (!controller.signal.aborted) { - setShareError(error instanceof Error ? error.message : "Could not share this video."); - } - }) - .finally(() => { - if (shareControllerRef.current === controller) { - shareControllerRef.current = null; - setSharing(false); - } - }); +function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { + const environmentId = "environmentId" in source ? source.environmentId : null; + const resource = "resource" in source ? source.resource : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return { + uri, + ...(resource !== null + ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) } + : {}), + unavailable: + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"), + error: null, + actionsSource: source.actionsSource, }; - - return ( - <> - - {status === "error" ? ( - - This video couldn't be played on this device. You can save or share the original file. - - ) : ( - <> - - {status === "loading" ? ( - - ) : null} - - )} - - - - {sharing ? "Opening share sheet..." : "Save or share video"} - - - {shareError ? ( - - {shareError} - - ) : null} - - ); } -function OpenVideoPreviewModal(props: { - readonly source: AttachmentVideoPreviewSource; - readonly onRequestClose: () => void; -}) { - const { source } = props; +function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { const { attachment } = source; - const insets = useSafeAreaInsets(); - const environmentId = source.type === "remote" ? source.environmentId : null; - const preparedConnection = usePreparedConnection(environmentId); - const fileUri = source.type === "local" ? source.attachment.fileUri : null; - const mimeType = videoMimeType(attachment) ?? attachment.mimeType; - const assetUrl = useAssetUrlState( - environmentId, - source.type === "remote" - ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } - : null, - ); - const [downloadUrl, setDownloadUrl] = useState(null); - const [file, setFile] = useState(null); - const [failure, setFailure] = useState(null); - - useEffect(() => Keyboard.dismiss(), []); - useEffect(() => { - if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { - setDownloadUrl(assetUrl.url); - } - }, [environmentId, downloadUrl, assetUrl]); - + const [uri, setUri] = useState(null); + const [error, setError] = useState(null); + // Only a different file needs a new lease; a metadata update on the same + // draft must not dispose the file Android is still playing. + const attachmentRef = useRef(attachment); + attachmentRef.current = attachment; + const { id: attachmentId, fileUri } = attachment; useEffect(() => { - if (source.type === "remote" && downloadUrl === null) return; + setUri(null); + setError(null); const controller = new AbortController(); - let preview: AttachmentPreviewFile | null = null; - setFile(null); - setFailure(null); - const loading = - source.type === "local" - ? loadLocalAttachmentPreview(source.attachment, controller.signal) - : downloadAttachmentForPreview({ - url: downloadUrl!, - attachment: { name: attachment.name, mimeType }, - signal: controller.signal, - }); + const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal); void loading.then( - (loaded) => { - if (controller.signal.aborted) { - loaded?.dispose(); - return; - } - preview = loaded; - setFile(loaded); + (file) => { + if (file === null) return; + if (controller.signal.aborted) file.dispose(); + else setUri(file.uri); }, - (error: unknown) => { + (cause: unknown) => { if (!controller.signal.aborted) { - setFailure(error instanceof Error ? error.message : "Could not load this video."); + setError(cause instanceof Error ? cause.message : "Could not load this video."); } }, ); return () => { controller.abort(); - preview?.dispose(); + void loading.then( + (file) => file?.dispose(), + () => undefined, + ); }; - }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); + }, [attachmentId, fileUri]); + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + return { + uri, + unavailable: error !== null, + error, + actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri }, + }; +} + +function MediaPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + return ( + + ); +} + +function LocalPreviewModal(props: { + readonly source: LocalVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + return ( + + ); +} - const loadError = - failure ?? - (environmentId !== null && downloadUrl === null - ? preparedConnection._tag === "None" - ? "This environment is disconnected. Reconnect and open the video again." - : assetUrl._tag === "Failure" - ? "Could not load this video. Check the connection to this environment and try again." - : null - : null); +function OpenVideoPreviewModal(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly playback: PlaybackState; + readonly onRequestClose: () => void; +}) { + const { playback } = props; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); + useEffect(() => Keyboard.dismiss(), []); return ( - {attachment.name} + {props.name} + - {file ? ( - - ) : ( + + {playback.uri === null && !playback.unavailable ? ( - {loadError ? ( - - {loadError} - - ) : ( - <> - - Loading video... - - )} + + Loading video... + ) : ( + )} + {playback.error ? ( + + {playback.error} + + ) : null} + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + ); @@ -243,12 +211,17 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource, props.onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; - if (source.type === "media") { - return ; - } - const key = - source.type === "local" - ? `local:${source.attachment.id}:${source.attachment.fileUri}` - : `remote:${source.environmentId}:${source.attachment.id}`; - return ; + return source.type === "local" ? ( + + ) : ( + + ); } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index c682436cad8..8b7c3d35a3a 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -668,7 +668,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { id: action.id, title: action.title, icon: - action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + action.id === "save" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), inline: false, onPress: action.run, })) diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 3e6afae6f84..ae6987f4c1c 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,11 +1,8 @@ -import { useAtomValue } from "@effect/atom-react"; import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import { workspaceFileImageAtom } from "./workspace-file-image-cache"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { PresentationSource } from "../../components/NativePresentation"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; @@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: { return ( - - setPreview({ - kind: "image", - uri: props.uri, - name: props.accessibilityLabel, - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - > - - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> - - + + 0 ? "Touch and hold for media actions" : undefined + } + disabled={loadError !== null} + className="flex-1 p-4 active:bg-subtle-strong" + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + > + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + + + {loadError !== null ? ( - + ) : null} - - - - - setPreview(null)} /> ); } -function CachedWorkspaceFileImagePreview(props: { - readonly accessibilityLabel: string; - readonly uri: string; - readonly actionsSource?: MediaActionsSource; -}) { - const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); - const imageResult = useAtomValue(imageAtom); - - if (AsyncResult.isFailure(imageResult)) { - return ( - - - - ); - } - - if (!AsyncResult.isSuccess(imageResult)) { - return ( - - - Loading image... - - ); - } - - return ( - - ); -} - export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; @@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: { } return ( - Promise; readonly unavailable: boolean; }) { - const [preview, setPreview] = useState(null); const uri = props.uri; if (props.unavailable) { @@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: { name={props.name} thumbnailKey={props.thumbnailKey} actionsSource={props.source?.actionsSource} - onExpand={ - uri === null || props.source === null ? undefined : () => setPreview(props.source) - } /> - setPreview(null)} /> ); } diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts deleted file mode 100644 index 4acb67361a8..00000000000 --- a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; - -describe("workspaceFileImageAtom", () => { - it("reuses a prefetched image across route remounts", async () => { - const prefetch = vi.fn(async () => true); - const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); - const registry = AtomRegistry.make({ timeoutResolution: 1 }); - const first = imageAtom("https://example.test/image.png"); - const firstUnmount = registry.mount(first); - - await vi.waitFor(() => { - expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); - }); - firstUnmount(); - - const remounted = imageAtom("https://example.test/image.png"); - const secondUnmount = registry.mount(remounted); - - expect(remounted).toBe(first); - expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); - expect(prefetch).toHaveBeenCalledTimes(1); - - secondUnmount(); - registry.dispose(); - }); - - it("prefetches different asset URLs independently", async () => { - const prefetch = vi.fn(async () => true); - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); - const registry = AtomRegistry.make(); - const first = imageAtom("https://example.test/first.png"); - const second = imageAtom("https://example.test/second.png"); - const firstUnmount = registry.mount(first); - const secondUnmount = registry.mount(second); - - await vi.waitFor(() => { - expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); - expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); - }); - expect(prefetch).toHaveBeenCalledTimes(2); - - firstUnmount(); - secondUnmount(); - registry.dispose(); - }); - - it("exposes prefetch failures", async () => { - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); - const registry = AtomRegistry.make(); - const atom = imageAtom("https://example.test/missing.png"); - const unmount = registry.mount(atom); - - await vi.waitFor(() => { - expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); - }); - - unmount(); - registry.dispose(); - }); -}); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts deleted file mode 100644 index 3f58f65b46c..00000000000 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import { Atom } from "effect/unstable/reactivity"; - -const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; - -type ImagePrefetch = (uri: string) => Promise; - -class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} - -export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ - readonly cause?: unknown; - readonly uri: string; -}> {} - -async function prefetchWithNativeImage(uri: string): Promise { - const { Image } = await import("react-native"); - return Image.prefetch(uri); -} - -export function createWorkspaceFileImageAtomFamily(options?: { - readonly idleTtlMs?: number; - readonly prefetch?: ImagePrefetch; -}) { - const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; - const prefetch = options?.prefetch ?? prefetchWithNativeImage; - const family = Atom.family((key: WorkspaceImageCacheKey) => - Atom.make( - Effect.tryPromise({ - try: async () => { - const cached = await prefetch(key.uri); - if (!cached) { - throw new WorkspaceImagePrefetchError({ uri: key.uri }); - } - return key.uri; - }, - catch: (cause) => - cause instanceof WorkspaceImagePrefetchError - ? cause - : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), - }), - ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), - ); - - return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); -} - -export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1b7cb5f373e..65fa77f5414 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -105,6 +105,7 @@ import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { + attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, @@ -251,14 +252,21 @@ function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; + readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); - const uri = useAssetUrl(props.environmentId, { - _tag: "attachment", - attachmentId: props.attachmentId, - }); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId: props.attachmentId, + fileName: props.name, + mimeType: props.mimeType, + }), + [props.attachmentId, props.name, props.mimeType], + ); + const uri = useAssetUrl(props.environmentId, resource); if (uri === null) { return ( @@ -274,7 +282,20 @@ function MessageAttachmentImage(props: { accessibilityRole="imagebutton" accessibilityLabel={`Open ${props.name}`} onPress={() => - props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + // The viewer mints its own URL from the resource so the image survives a refresh. + props.onPressPreview({ + kind: "image", + environmentId: props.environmentId, + resource, + name: props.name, + sourceIdentifier, + actionsSource: { + name: props.name, + mimeType: props.mimeType, + environmentId: props.environmentId, + resource, + }, + }) } > @@ -389,14 +410,18 @@ function MessageAttachmentFile(props: { }; if (videoType !== null) { + const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( props.onPressVideo(attachment, sourceIdentifier)} - onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> @@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: { style={{ alignSelf: "stretch", gap: 6 }} > {props.uri === null || failed ? ( - - {failed ? ( - Image unavailable - ) : ( - - )} - {props.actionsSource ? ( - - - - ) : null} - + + 0 ? "Touch and hold for media actions" : undefined + } + className="items-center justify-center rounded-[10px] bg-md-code-bg" + style={frameStyle} + > + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( - - - - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.alt ?? "Image", - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - style={{ alignSelf: "flex-start" }} + + 0 ? "Touch and hold for media actions" : undefined + } + onPress={() => + // Quick Look picks the viewer from the name's extension, so it needs the + // file name rather than the alt text. + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.actionsSource?.name ?? props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + - - setFailedUri(props.uri)} - /> - - - - {props.actionsSource ? ( - - + setFailedUri(props.uri)} + /> - ) : null} - + + )} {props.alt ? ( @@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } -function ThreadMarkdownVideo(props: { - readonly source: MediaVideoPreviewSource; - readonly onExpand: (source: MediaVideoPreviewSource) => void; -}) { +function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { const { source } = props; const visible = useContext(ThreadMediaVisibleContext); const thumbnailKey = mediaVideoThumbnailKey(source); @@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: { thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} - onExpand={() => props.onExpand(source)} /> ); } @@ -1593,6 +1612,7 @@ function renderFeedEntry( environmentId={props.environmentId} attachmentId={attachment.id} name={attachment.name} + mimeType={attachment.mimeType} className="aspect-[1.3] w-full rounded-[14px] bg-white/15" onPressPreview={props.onPressPreview} /> @@ -1656,6 +1676,7 @@ function renderFeedEntry( environmentId={props.environmentId} attachmentId={attachment.id} name={attachment.name} + mimeType={attachment.mimeType} className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressPreview={props.onPressPreview} /> @@ -2218,7 +2239,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedVideo((current) => current ?? source)} /> ); } @@ -2667,12 +2687,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (attachment: ChatFileAttachment, sourceIdentifier: string) => { setExpandedVideo( (current) => - current ?? { - type: "remote", - environmentId: props.environmentId, - attachment, - sourceIdentifier, - }, + current ?? + attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), ); }, [props.environmentId], diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts index 77834630dc2..1d7320eb067 100644 --- a/apps/mobile/src/lib/markdownMedia.test.ts +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -61,6 +61,17 @@ describe("resolveMarkdownMediaPreview", () => { }); }); + it("serves a linked T3 attachment file in place like any other host path", () => { + const path = "/home/demo/.t3/userdata/attachments/11111111-1111-4111-8111-111111111111-mp4.mp4"; + expect(resolveMarkdownMediaPreview(path, input)).toMatchObject({ + kind: "video", + source: { + resource: { _tag: "media-file", threadId: input.threadId, path }, + actionsSource: { resource: { _tag: "media-file", path } }, + }, + }); + }); + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { expect( resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index 2196c2f22f2..b1b616f0e44 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -1,15 +1,6 @@ -import { - classifyMarkdownImageSource, - markdownImageSourceFragment, -} from "@t3tools/client-runtime/markdown-images"; +import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; -import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; -import { - mediaFileReference, - mediaReferenceFileName, - mediaUrlReference, -} from "@t3tools/client-runtime/media-reference"; import type { FilePreviewSource } from "../components/FilePreviewModal"; import type { MediaVideoPreviewSource } from "./videoPreviewSource"; @@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview( | { readonly kind: "image"; readonly source: FilePreviewSource } | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } | null { - const classified = classifyMarkdownImageSource(href, input.workspaceRoot); - if (classified._tag === "Blocked") return null; - const path = - classified._tag === "WorkspaceFile" - ? classified.path.replace(/:\d+(?::\d+)?$/, "") - : classified.uri.split(/[?#]/, 1)[0]!; - const basename = path.split(/[\\/]/).at(-1) ?? ""; - const extensionIndex = basename.lastIndexOf("."); - // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. - const detectedMimeType = - classified._tag === "Direct" - ? mediaMimeType(classified.uri) - : extensionIndex < 0 - ? null - : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); - const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); - if (mimeType === null) return null; - const kind = mimeType.startsWith("video/") ? "video" : "image"; - const reference = - classified._tag === "Direct" - ? mediaUrlReference(classified.uri) - : mediaFileReference(path, input.workspaceRoot); - const name = - (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); - const srcFragment = markdownImageSourceFragment(href); + const media = resolveMediaSource(href, input); + if (media === null || media.access === "unavailable") return null; + const { kind, name, mimeType, reference, srcFragment } = media; + const target = - classified._tag === "Direct" - ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + media.access === "direct" + ? { uri: normalizeNativeMarkdownUrl(media.uri) } : { environmentId: input.environmentId, - resource: { - _tag: "media-file" as const, - threadId: input.threadId, - path, - }, + resource: media.resource, ...(srcFragment ? { srcFragment } : {}), }; const actionsSource: MediaActionsSource = - classified._tag === "Direct" - ? { reference, uri: classified.uri, name, mimeType } + media.access === "direct" + ? { reference, uri: media.uri, name, mimeType } : { reference, environmentId: input.environmentId, threadId: input.threadId, - resource: { _tag: "media-file", threadId: input.threadId, path }, + resource: media.resource, name, mimeType, }; return kind === "video" - ? { - kind, - source: { type: "media", name, mimeType, ...target, actionsSource }, - } - : { - kind, - source: { kind, name, ...target, actionsSource }, - }; + ? { kind, source: { type: "media", name, mimeType, ...target, actionsSource } } + : { kind, source: { kind, name, ...target, actionsSource } }; } diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts index 14dc2de7bd2..c37ed76c2bf 100644 --- a/apps/mobile/src/lib/mediaActions.ts +++ b/apps/mobile/src/lib/mediaActions.ts @@ -1,4 +1,5 @@ import { useNavigation } from "@react-navigation/native"; +import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import type { MediaReference } from "@t3tools/client-runtime/media-reference"; import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; @@ -7,18 +8,23 @@ import { Alert } from "react-native"; import { useRefreshAssetUrl } from "../state/assets"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import type { DraftComposerFileAttachment } from "./composerImages"; import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; /** Authored source metadata is kept separate from temporary preview/download URLs. */ export type MediaActionsSource = { readonly reference?: MediaReference; readonly name: string; readonly mimeType: string; + /** Anchors the iOS share sheet to the view that opened the menu. */ + readonly sourceIdentifier?: string; } & ( | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } | { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; + readonly threadId?: ThreadId; readonly resource: AssetResource; } ); @@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi controller.current = request; setSharing(true); void (async () => { + if ("attachment" in source) { + const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); + if (!preview) return; + try { + await preview.share(request.signal, source.sourceIdentifier); + } finally { + preview.dispose(); + } + return; + } const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); if (request.signal.aborted) return; if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); const input = { attachment: { name: source.name, mimeType: source.mimeType }, signal: request.signal, + sourceIdentifier: source.sourceIdentifier, }; if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); else await downloadAndShareAttachment({ ...input, url: uri }); @@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi }; const reference = source?.reference; - const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source - ? [ - ...(reference?.kind === "file" - ? [ - { - id: "copy-path", - title: "Copy full path", - run: () => copyTextWithHaptic(reference.path), - }, - ...(reference.relativePath - ? [ - { - id: "copy-relative-path", - title: "Copy relative path", - run: () => copyTextWithHaptic(reference.relativePath!), - }, - ] - : []), - ...(reference.relativePath && source && "environmentId" in source - ? [ - { - id: "open-file", - title: "Open in file viewer", - run: () => { - onOpenFile?.(); - navigation.navigate("ThreadFile", { - environmentId: String(source.environmentId), - threadId: String(source.threadId), - path: reference.relativePath!.split("/"), - }); - }, - }, - ] - : []), - ] - : reference - ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const threadId = source && "threadId" in source ? source.threadId : undefined; + const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = + source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-full-path" as const, + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ] + : []), + ...(relativePath + ? [ + { + id: "copy-relative-path" as const, + title: "Copy relative path", + run: () => copyTextWithHaptic(relativePath), + }, + ] + : []), + ...(reference?.kind === "url" + ? [ + { + id: "copy-url" as const, + title: "Copy URL", + run: () => copyTextWithHaptic(reference.url), + }, + ] + : []), + ...(relativePath && "environmentId" in source && threadId !== undefined + ? [ + { + id: "open-file" as const, + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(threadId), + path: relativePath.split("/"), + }); + }, + }, + ] : []), - { - id: "share", - title: sharing ? "Opening share sheet…" : "Save or share", - run: share, - disabled: sharing, - }, - ] - : []; + { + id: "save" as const, + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; return { title: reference?.kind === "file" ? reference.path : reference?.url, actions, diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index af87a8d0f73..90db612d002 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -1,4 +1,5 @@ import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; import type { DraftComposerFileAttachment } from "./composerImages"; import type { MediaActionsSource } from "./mediaActions"; @@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = { | { readonly uri: string } | { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract; } ); @@ -32,23 +33,51 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string return JSON.stringify( "uri" in source ? ["media-video", source.uri] - : [ - "media-video", - source.environmentId, - source.resource.threadId, - source.resource.path, - source.srcFragment ?? "", - ], + : source.resource._tag === "attachment" + ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], ); } -export type AttachmentVideoPreviewSource = ( - | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } - | { - readonly type: "remote"; - readonly environmentId: EnvironmentId; - readonly attachment: ChatFileAttachment; - } -) & { readonly sourceIdentifier?: string }; +export interface LocalVideoPreviewSource { + readonly type: "local"; + readonly attachment: DraftComposerFileAttachment; + readonly sourceIdentifier?: string; +} + +export type VideoPreviewSource = LocalVideoPreviewSource | MediaVideoPreviewSource; -export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; +export function attachmentVideoPreviewSource( + environmentId: EnvironmentId, + attachment: ChatFileAttachment, + sourceIdentifier?: string, +): MediaVideoPreviewSource { + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const resource = { + _tag: "attachment" as const, + attachmentId: attachment.id, + fileName: attachment.name, + mimeType, + }; + return { + type: "media", + name: attachment.name, + mimeType, + ...(sourceIdentifier ? { sourceIdentifier } : {}), + environmentId, + resource, + actionsSource: { + name: attachment.name, + mimeType, + ...(sourceIdentifier ? { sourceIdentifier } : {}), + environmentId, + resource, + }, + }; +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 9e3e43c7cdc..af6300d8ffa 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,23 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; -import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + type AssetUrlState, + assetUrlStateFromResult, + createAssetEnvironmentAtoms, + EMPTY_ASSET_URL_ATOM, +} from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; -export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); - -const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( - Atom.withLabel("mobile-asset-url:empty"), -); +export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; -export type AssetUrlState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export function useAssetUrlState( environmentId: EnvironmentId | null, @@ -29,14 +26,10 @@ export function useAssetUrlState( ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - if (result._tag === "Failure") { - return { _tag: "Failure" }; - } - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return { _tag: "Loading" }; - } - const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ); } export function useAssetUrl( @@ -60,9 +53,10 @@ export function useRefreshAssetUrl( }); return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; - const result = await createUrl({ environmentId, input: { resource } }); - return result._tag === "Success" - ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) - : null; + const state = assetUrlStateFromResult( + await createUrl({ environmentId, input: { resource } }), + httpBaseUrl, + ); + return state._tag === "Success" ? state.url : null; }, [createUrl, environmentId, httpBaseUrl, resource]); } diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 5c642471404..84ff979e4e8 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,5 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; -import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + type AssetUrlState, + assetUrlStateFromResult, + EMPTY_ASSET_URL_ATOM, + resolveAssetUrl, +} from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; - -export type AssetUrlState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; +export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; export function useAssetUrlState( - environmentId: EnvironmentId, - resource: AssetResource, + environmentId: EnvironmentId | null, + resource: AssetResource | null, ): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( - assetEnvironment.createUrl({ - environmentId, - input: { resource }, - }), + environmentId === null || resource === null + ? EMPTY_ASSET_URL_ATOM + : assetEnvironment.createUrl({ environmentId, input: { resource } }), + ); + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, ); - if (result._tag === "Failure") { - return { _tag: "Failure" }; - } - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return { _tag: "Loading" }; - } - const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null - ? { _tag: "Failure" } - : { - _tag: "Success", - url, - ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), - }; } -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { const result = useAssetUrlState(environmentId, resource); - if (result._tag !== "Success") { - return null; - } - return result.url; + return result._tag === "Success" ? result.url : null; } -/** Re-mints an exact-file capability after a file change or an explicit retry. */ export function useAssetUrlRefresh( - environmentId: EnvironmentId, - resource: AssetResource, + environmentId: EnvironmentId | null, + resource: AssetResource | null, ): () => Promise { const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); return useCallback(async () => { + if (environmentId === null || resource === null) return; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); }, [environmentId, resource, refresh]); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b08377e36a2..9cd6accbfba 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1274,7 +1274,6 @@ function ChatMarkdownVideo(props: { readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; readonly onRetry?: (() => Promise) | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { return ( { - props.onImageExpand?.({ - images: [ - { - src, - name: props.alt || "video", - type: "video", - autoPlay: false, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - ...(props.actionsSource - ? { actionsSource: { ...props.actionsSource, src } } - : {}), - }, - ], - index: 0, - }); - } - : undefined - } /> ); } @@ -1378,7 +1356,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props style={props.style} mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} onRetry={refreshAssetUrl} - onImageExpand={props.onImageExpand} actionsSource={actionsSource} /> ); @@ -2642,7 +2619,6 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} originalUrl={originalUrl} style={authoredSizeStyle} - onImageExpand={imageExpand} actionsSource={actionsSource} /> ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9fe03c1c980..6be48b94ed3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -24,7 +24,6 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, - isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -130,14 +129,6 @@ describe("proactive panels", () => { }); }); -describe("isVideoPreviewRequestCurrent", () => { - it("rejects changed threads and replaced previews", () => { - expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); - expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); - expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); - }); -}); - describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 08737870633..cccc0a8dfe8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: { return url; } -export function isVideoPreviewRequestCurrent( - requestThreadKey: string, - currentThreadKey: string, - requestId: number, - currentRequestId: number, -): boolean { - return requestThreadKey === currentThreadKey && requestId === currentRequestId; -} - export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c08fb2b9ee3..e7f1a23bbc4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -135,7 +135,6 @@ import { type ChatMessage, isBrowserPreviewAttachment, isImageAttachment, - videoMimeType, type SessionPhase, type Thread, type TurnDiffSummary, @@ -300,7 +299,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; import { @@ -375,7 +374,6 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, - isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, @@ -1509,19 +1507,13 @@ function ChatViewContent(props: ChatViewProps) { [composerRef], ); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); - const routeThreadKeyRef = useRef(routeThreadKey); - routeThreadKeyRef.current = routeThreadKey; - const videoPreviewRequestIdRef = useRef(0); - const cancelVideoPreviewRequest = useCallback(() => { - videoPreviewRequestIdRef.current += 1; - }, []); - const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); useEffect(() => { const item = expandedImage?.images[expandedImage.index]; - if (item?.type !== "video" || !item.src.startsWith("blob:")) return; - return () => revokeBlobPreviewUrl(item.src); + if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; + const src = item.src; + return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -2575,12 +2567,11 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); - cancelVideoPreviewRequest(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; - }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); + }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -2608,18 +2599,6 @@ function ChatViewContent(props: ChatViewProps) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } - const isVideo = videoMimeType(attachment) !== null; - const action = isVideo ? "play" : "download"; - const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; - const isCurrentRequest = () => - !isVideo || - isVideoPreviewRequestCurrent( - routeThreadKey, - routeThreadKeyRef.current, - videoPreviewRequestId, - videoPreviewRequestIdRef.current, - ); - if (isVideo) setOpeningVideoAttachmentId(attachment.id); try { const url = await resolveFileAttachmentUrl({ @@ -2628,30 +2607,19 @@ function ChatViewContent(props: ChatViewProps) { httpBaseUrl: connection.httpBaseUrl, createAssetUrl: createAttachmentAssetUrl, }); - if (!isCurrentRequest()) return; - if (isVideo) { - setExpandedImage({ - images: [{ src: url, name: attachment.name, type: "video" }], - index: 0, - }); - return; - } const anchor = document.createElement("a"); anchor.href = url; anchor.download = attachment.name; anchor.click(); } catch (error) { - if (!isCurrentRequest()) return; toastManager.add({ type: "error", - title: "Could not " + action + " " + attachment.name, + title: "Could not download " + attachment.name, description: error instanceof Error ? error.message : "The attachment is unavailable.", }); - } finally { - if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } }, - [createAttachmentAssetUrl, environmentId, routeThreadKey], + [createAttachmentAssetUrl, environmentId], ); const openFileAttachment = useCallback( (attachment: ChatFileAttachment) => { @@ -4690,10 +4658,8 @@ function ChatViewContent(props: ChatViewProps) { return []; }); resetLocalDispatch(); - cancelVideoPreviewRequest(); - setOpeningVideoAttachmentId(null); setExpandedImage(null); - }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); + }, [draftId, resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -7188,14 +7154,9 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onExpandTimelineImage = useCallback( - (preview: ExpandedImagePreview) => { - cancelVideoPreviewRequest(); - setOpeningVideoAttachmentId(null); - setExpandedImage(preview); - }, - [cancelVideoPreviewRequest], - ); + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + setExpandedImage(preview); + }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; @@ -7525,7 +7486,6 @@ function ChatViewContent(props: ChatViewProps) { onImageExpand={onExpandTimelineImage} onFileOpen={openFileAttachment} onFileDownload={downloadFileAttachment} - openingVideoAttachmentId={openingVideoAttachmentId} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} @@ -7701,7 +7661,6 @@ function ChatViewContent(props: ChatViewProps) { setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} onFileOpen={openFileAttachment} - openingVideoAttachmentId={openingVideoAttachmentId} />
@@ -7922,7 +7881,7 @@ function ChatViewContent(props: ChatViewProps) { {expandedImage && ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3a1cd0fb4ae..349faeac072 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -741,7 +741,6 @@ export interface ChatComposerProps { setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; } // -------------------------------------------------------------------------- @@ -820,7 +819,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, onFileOpen, - openingVideoAttachmentId, } = props; const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; @@ -4020,7 +4018,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) maxFileAttachmentBytes !== null && file.sizeBytes <= maxFileAttachmentBytes; const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; - const isOpening = file.uploadedAttachmentId === openingVideoAttachmentId; return (
{upload?.status === "uploading" && ( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 2ee40c0b8b6..5ab287225ca 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -2,11 +2,12 @@ import { memo, useCallback, useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; -import type { ExpandedImagePreview } from "./ExpandedImagePreview"; -import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; import { OpenMediaLink } from "../media/OpenMediaLink"; import { MediaActions, type MediaActionSource } from "../media/MediaActions"; +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { isContextMenuOpen } from "../../contextMenuFallback"; interface ExpandedImageDialogProps { @@ -14,23 +15,47 @@ interface ExpandedImageDialogProps { onClose: () => void; } +const EXPANDED_MEDIA_STATE_CLASS_NAME = + "flex aspect-auto h-48 min-h-0 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl"; + function ExpandedMediaFailure({ children }: { children: ReactNode }) { return ( -
+
{children}
); } +function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { + const asset = item.actionsSource?.asset; + const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); + const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null); + const src = asset + ? assetUrl._tag === "Success" + ? assetUrl.url + (item.srcFragment ?? "") + : null + : item.src; + return ( + + ); +} + export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); - const [failedVideoSrc, setFailedVideoSrc] = useState(null); const [failedImageSrc, setFailedImageSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; const item = preview.images[index]; @@ -125,24 +150,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ > - {item.type === "video" && failedVideoSrc === item.src ? ( - -

This video could not be loaded or played.

- -
- ) : item.type === "video" ? ( -
"); }); it("renders an ordinary file download button without creating its URL in advance", () => { const entry = { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 26a2881322b..ecfe33660e0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,6 +1,5 @@ import { type AssistantCitation, - type ChatFileAttachment, type EnvironmentId, type MessageId, type ScopedThreadRef, @@ -50,6 +49,7 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { + type ChatFileAttachment, type ChatImageAttachment, isBrowserPreviewAttachment, isFileAttachment, @@ -79,7 +79,6 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, - PlayIcon, SearchIcon, SquarePenIcon, TerminalIcon, @@ -89,8 +88,14 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; -import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; +import { + buildAttachmentVideoAsset, + buildExpandedImagePreview, + ExpandedImagePreview, +} from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; @@ -188,7 +193,6 @@ interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; @@ -289,7 +293,6 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen?: (attachment: ChatFileAttachment) => void; onFileDownload?: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -341,7 +344,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, onFileDownload = NOOP_OPEN_ATTACHMENT, - openingVideoAttachmentId, activeThreadEnvironmentId, markdownCwd, resolvedTheme, @@ -638,7 +640,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen, onFileDownload, - openingVideoAttachmentId, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, @@ -663,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen, onFileDownload, - openingVideoAttachmentId, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, @@ -1127,6 +1127,45 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { + const ctx = use(TimelineRowCtx); + const asset = useMemo( + () => + file.downloadable === false + ? null + : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file), + [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name], + ); + const resource = asset?.resource ?? null; + const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource); + const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null); + + if (asset === null && src === null) { + return ( +
+ {file.name} +
+ ); + } + + return ( + + ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); // The attachment union has an open member, so guards (not literal type @@ -1165,12 +1204,12 @@ function UserTimelineRow({ row }: { row: Extract (
{image.previewUrl ? ( ) : ( @@ -1191,35 +1230,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {userVideos.map((file) => { - const isOpening = ctx.openingVideoAttachmentId === file.id; - return ( -
- -
- ); - })} + {userVideos.map((file) => ( + + ))}
)} {previewAnnotations.map((annotation, index) => ( diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index a67cadbca5a..cf79c81b9f6 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -1,3 +1,4 @@ +import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import { mediaReferenceFileName, type MediaReference, @@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) { return { save, copyImage }; } -type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; - /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ export function MediaActions({ source, @@ -82,8 +81,6 @@ export function MediaActions({ const [tooltipOpen, setTooltipOpen] = useState(false); const menuOpen = useRef(false); const reference = source.reference; - const hasActions = - source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); const showMenu = async (position: { x: number; y: number }) => { @@ -94,28 +91,37 @@ export function MediaActions({ let failureTitle = "Could not open media menu"; let progressToast: ReturnType | undefined; try { - const items: ContextMenuItem[] = []; + const noun = source.kind === "image" ? "image" : "video"; + const unavailable = source.src === null && source.asset === undefined; + const canCopyImage = + typeof navigator !== "undefined" && + Boolean(navigator.clipboard?.write) && + typeof ClipboardItem !== "undefined"; + const items: ContextMenuItem[] = []; if (reference?.kind === "file") { - items.push({ id: "copy-full", label: "Copy full path" }); + items.push({ id: "copy-full-path", label: "Copy full path" }); if (reference.relativePath) - items.push({ id: "copy-relative", label: "Copy relative path" }); + items.push({ id: "copy-relative-path", label: "Copy relative path" }); } else if (reference?.kind === "url") { items.push({ id: "copy-url", label: "Copy URL" }); } + if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); if (source.kind === "image") { - const unavailable = source.src === null && source.asset === undefined; - items.push({ id: "save", label: "Save image", disabled: unavailable }); - items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); + items.push({ + id: "copy-image", + label: "Copy image", + disabled: unavailable || !canCopyImage, + }); } - if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); const action = await api.contextMenu.show(items, position); if (!action) return; failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; const text = - action === "copy-full" && reference?.kind === "file" + action === "copy-full-path" && reference?.kind === "file" ? reference.path - : action === "copy-relative" && reference?.kind === "file" + : action === "copy-relative-path" && reference?.kind === "file" ? reference.relativePath : action === "copy-url" && reference?.kind === "url" ? reference.url @@ -131,7 +137,7 @@ export function MediaActions({ } else if (action === "save" || action === "copy-image") { progressToast = toastManager.add({ type: "loading", - title: action === "save" ? "Preparing image download…" : "Copying image…", + title: action === "save" ? `Preparing ${noun} download…` : "Copying image…", }); await (action === "save" ? save() : copyImage()); toastManager.update(progressToast, { @@ -158,7 +164,7 @@ export function MediaActions({ render={children} tabIndex={0} onContextMenu={(event) => { - if (!hasActions || event.defaultPrevented) return; + if (event.defaultPrevented) return; event.preventDefault(); event.stopPropagation(); const bounds = event.currentTarget.getBoundingClientRect(); @@ -170,7 +176,6 @@ export function MediaActions({ }} onKeyDown={(event) => { if ( - !hasActions || event.defaultPrevented || !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) ) diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 8f2d75680c1..f436beeb385 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -1,4 +1,4 @@ -import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { cn } from "../../lib/utils"; @@ -14,11 +14,13 @@ interface MediaVideoPlayerProps { readonly originalUrl?: string | undefined; readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; + readonly autoPlay?: boolean | undefined; readonly className?: string | undefined; readonly videoClassName?: string | undefined; + /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ + readonly stateClassName?: string | undefined; readonly style?: CSSProperties | undefined; readonly copyMarkdown?: string | undefined; - readonly onExpand?: ((src: string) => void) | undefined; readonly onRetry?: (() => Promise) | undefined; readonly actionsSource?: MediaActionSource | undefined; } @@ -31,11 +33,12 @@ export function MediaVideoPlayer({ originalUrl, revision = null, preload = "visible", + autoPlay = false, className, videoClassName, + stateClassName, style, copyMarkdown, - onExpand, onRetry, actionsSource, }: MediaVideoPlayerProps) { @@ -114,23 +117,6 @@ export function MediaVideoPlayer({ } }; - const expandButton = - onExpand && src !== null ? ( - - ) : null; - const player = ( @@ -159,7 +148,6 @@ export function MediaVideoPlayer({ ) : null} - {expandButton} ) : src !== null ? ( @@ -168,6 +156,7 @@ export function MediaVideoPlayer({ ref={videoRef} src={src} aria-label={label || "Video preview"} + autoPlay={autoPlay} controls playsInline preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} @@ -186,11 +175,10 @@ export function MediaVideoPlayer({ )} - {!failed && expandButton} ); return actionsSource ? {player} : player; diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 5a6e2a02e10..fc197a4092b 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,22 +1,18 @@ -import { splitPathAndPosition } from "./terminal-links"; +import { + fileBasename, + formatFilePathPosition, + splitFilePathPosition, + stripSlashPrefixedWindowsDrive, +} from "@t3tools/client-runtime/markdown-links"; function normalizePathSeparators(path: string): string { return path.replaceAll("\\", "/"); } -function canonicalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; -} - function trimTrailingPathSeparators(path: string): string { return path.replace(/[\\/]+$/, ""); } -function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; -} - function stripRelativePrefixes(path: string): string { return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); } @@ -25,15 +21,15 @@ export function formatWorkspaceRelativePath( pathWithPosition: string, workspaceRoot: string | undefined, ): string { - const { path, line, column } = splitPathAndPosition(pathWithPosition); - const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + const position = splitFilePathPosition(pathWithPosition); + const normalizedPath = stripSlashPrefixedWindowsDrive(normalizePathSeparators(position.path)); let displayPath = normalizedPath; if (workspaceRoot) { - const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( + const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive( normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), ); - const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); + const workspaceLabel = fileBasename(normalizedWorkspaceRoot); const pathForCompare = normalizedPath.toLowerCase(); const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); const workspaceWithSeparator = `${workspaceForCompare}/`; @@ -52,6 +48,5 @@ export function formatWorkspaceRelativePath( } } - if (!line) return displayPath; - return `${displayPath}:${line}${column ? `:${column}` : ""}`; + return formatFilePathPosition({ ...position, path: displayPath }); } diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 3b3bad7b25e..e3b1cc5b224 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -270,8 +270,15 @@ describe("resolveMarkdownFileLinkTarget", () => { ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); }); - it("does not treat app routes as file links", () => { + it("does not treat app routes as file links, even with a line anchor", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); + expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull(); + }); + + it("decodes an encoded drive colon in a file uri before dropping its slash", () => { + expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe( + "c:/Users/x/shot.png", + ); }); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index d55c9e1cd16..8ed59eb8775 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,53 +1,21 @@ import { + fileBasename, + formatFilePathPosition, inlineCodeFilePathCandidate, - isConventionalFilePosition, + isRelativeFilePath, + normalizeMarkdownLinkDestination, + parseFileUrlHref, + parseMarkdownFileLink, + safeDecodeURIComponent, + splitFilePathPosition, + workspaceRelativeFilePath, } from "@t3tools/client-runtime/markdown-links"; import { formatWorkspaceRelativePath } from "./filePathDisplay"; -import { - isTerminalLinkActivation, - resolvePathLinkTarget, - splitPathAndPosition, -} from "./terminal-links"; +import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links"; + +export { normalizeMarkdownLinkDestination }; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; -const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; -const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; -const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; -// Standard OS and dev-container roots; deliberately excludes app-route-ish -// prefixes like /app/ or /chat/ so SPA routes never read as files. -const POSIX_FILE_ROOT_PREFIXES = [ - "/Users/", - "/home/", - "/tmp/", - "/var/", - "/etc/", - "/opt/", - "/mnt/", - "/Volumes/", - "/private/", - "/root/", - "/usr/", - "/bin/", - "/sbin/", - "/lib/", - "/lib64/", - "/srv/", - "/dev/", - "/proc/", - "/sys/", - "/run/", - "/boot/", - "/media/", - "/workspace/", - "/workspaces/", -] as const; const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; @@ -81,112 +49,14 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - export function isWindowsDrivePathHref(href: string): boolean { - return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); -} - -function unwrapMarkdownLinkDestination(value: string): string { - return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; -} - -export function normalizeMarkdownLinkDestination(value: string): string { - return unwrapMarkdownLinkDestination(value.trim()); -} - -function stripSearchAndHash(value: string): { path: string; hash: string } { - const hashIndex = value.indexOf("#"); - const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; - const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : ""; - const queryIndex = pathWithSearch.indexOf("?"); - const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch; - return { path, hash: rawHash }; -} - -function normalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; -} - -function parseFileUrlHref( - href: string, - options?: { readonly decodePath?: boolean }, -): { path: string; hash: string } | null { - try { - const parsed = new URL(href); - if (parsed.protocol.toLowerCase() !== "file:") return null; - - const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; - const rawPath = uncHostname - ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` - : parsed.pathname; - if (rawPath.length === 0) return null; - - // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. - const normalizedPath = normalizeWindowsDrivePath(rawPath); - - return { - path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath), - hash: parsed.hash, - }; - } catch { - return null; - } + return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); } export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; - const normalizedHref = normalizeMarkdownLinkDestination(href); - const target = parseFileUrlHref(normalizedHref, { decodePath: false }); - if (!target) return null; - return `${target.path}${target.hash}`; -} - -function looksLikePosixFilesystemPath(path: string): boolean { - if (!path.startsWith("/")) return false; - if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; - if (POSITION_SUFFIX_PATTERN.test(path)) return true; - const basename = path.slice(path.lastIndexOf("/") + 1); - return /\.[A-Za-z0-9_-]+$/.test(basename); -} - -function appendLineColumnFromHash(path: string, hash: string): string { - if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path; - const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i); - if (!match?.[1]) return path; - const line = match[1]; - const column = match[2]; - return `${path}:${line}${column ? `:${column}` : ""}`; -} - -function isLikelyPathCandidate(path: string): boolean { - if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true; - if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; - if (path.startsWith("/")) return looksLikePosixFilesystemPath(path); - return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path); -} - -function isRelativePath(path: string): boolean { - return ( - RELATIVE_PATH_PREFIX_PATTERN.test(path) || - (!path.startsWith("/") && - !WINDOWS_DRIVE_PATH_PATTERN.test(path) && - !WINDOWS_UNC_PATH_PATTERN.test(path)) - ); -} - -function hasExternalScheme(path: string): boolean { - const match = path.match(EXTERNAL_SCHEME_PATTERN); - if (!match) return false; - const rest = match[2] ?? ""; - if (rest.startsWith("//")) return true; - return !POSITION_ONLY_PATTERN.test(rest); + const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href)); + return target ? `${target.path}${target.hash}` : null; } /** @@ -200,34 +70,11 @@ export function resolveMarkdownFileLinkTarget( baseDir: string | undefined = cwd, ): string | null { if (!href) return null; - const rawHref = normalizeMarkdownLinkDestination(href); - if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) return null; - - const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") - ? parseFileUrlHref(rawHref) - : null; - const source = fileUrlTarget ?? stripSearchAndHash(rawHref); - const decodedPath = normalizeWindowsDrivePath( - fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()), - ); - const decodedHash = safeDecode(source.hash.trim()); - - if (decodedPath.length === 0) return null; - if ( - !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) && - !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) && - hasExternalScheme(decodedPath) - ) { - return null; - } - - if (!isLikelyPathCandidate(decodedPath)) return null; - - const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash); - if (!isRelativePath(pathWithPosition)) { - return pathWithPosition; - } + const target = parseMarkdownFileLink(href); + if (!target) return null; + const pathWithPosition = formatFilePathPosition(target); + if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; if (!baseDir) return null; return resolvePathLinkTarget(pathWithPosition, baseDir); } @@ -246,38 +93,7 @@ export function resolveInlineCodeFileLinkMeta( const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); - if (resolved) return resolved; - - // `Makefile:12` — conventional extensionless names fail the generic - // markdown-link candidate patterns, but here the :line suffix already - // marked the span as a file reference. - if (baseDir && isConventionalFilePosition(candidate)) { - return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd); - } - return null; -} - -function basenameOfPath(path: string): string { - // A trailing separator is a valid way to write a directory, so trim it before - // taking the final segment. Without this the segment reads as empty and the - // chip renders with no label at all. - const trimmed = path.replace(/[/\\]+$/, "") || path; - const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; -} - -function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { - if (!workspaceRoot) return null; - const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); - const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace( - /\/+$/, - "", - ); - const pathForCompare = normalizedPath.toLowerCase(); - const rootForCompare = normalizedRoot.toLowerCase(); - if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; - return normalizedPath.slice(normalizedRoot.length + 1); + return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); } export function resolveMarkdownFileLinkMeta( @@ -291,19 +107,14 @@ export function resolveMarkdownFileLinkMeta( } function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { - const { path, line, column } = splitPathAndPosition(targetPath); - const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; - const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; - const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; - const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; - + const { path, line, column } = splitFilePathPosition(targetPath); return { filePath: path, targetPath, displayPath: formatWorkspaceRelativePath(targetPath, cwd), - workspaceRelativePath: workspaceRelativePath(path, cwd), - basename: basenameOfPath(path), - ...(lineNumber !== undefined ? { line: lineNumber } : {}), - ...(columnNumber !== undefined ? { column: columnNumber } : {}), + workspaceRelativePath: workspaceRelativeFilePath(path, cwd), + basename: fileBasename(path), + ...(line !== undefined ? { line } : {}), + ...(column !== undefined ? { column } : {}), }; } diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index be4258ba6b0..204d0a742a0 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -1,3 +1,8 @@ +import { + formatFilePathPosition, + splitFilePathPosition, +} from "@t3tools/client-runtime/markdown-links"; + import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; @@ -137,35 +142,6 @@ function inferHomeFromCwd(cwd: string): string | undefined { return undefined; } -export function splitPathAndPosition(value: string): { - path: string; - line: string | undefined; - column: string | undefined; -} { - let path = value; - let column: string | undefined; - let line: string | undefined; - - const columnMatch = path.match(/:(\d+)$/); - if (!columnMatch?.[1]) { - return { path, line: undefined, column: undefined }; - } - - column = columnMatch[1]; - path = path.slice(0, -columnMatch[0].length); - - const lineMatch = path.match(/:(\d+)$/); - if (lineMatch?.[1]) { - line = lineMatch[1]; - path = path.slice(0, -lineMatch[0].length); - } else { - line = column; - column = undefined; - } - - return { path, line, column }; -} - export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const urlMatches = collectMatches(line, "url", URL_PATTERN, []); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); @@ -271,7 +247,8 @@ export function isTerminalLinkActivation( } export function resolvePathLinkTarget(rawPath: string, cwd: string): string { - const { path, line, column } = splitPathAndPosition(rawPath); + const position = splitFilePathPosition(rawPath); + const { path } = position; let resolvedPath = path; if (path.startsWith("~/")) { @@ -285,6 +262,5 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { resolvedPath = joinPath(cwd, path, separator); } - if (!line) return resolvedPath; - return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; + return formatFilePathPosition({ ...position, path: resolvedPath }); } diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index 61b97ab6a86..12b0040d4bd 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -86,10 +86,15 @@ composer thumbnails, and workspace image previews. The workspace PDF web preview Open PDF action for the native viewer. Android retains its image viewer and uses the system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. -Received videos open directly from their signed asset URL. AVKit handles buffering; -the client does not download the entire file or show a separate opening overlay before -presentation. The URL is captured once per preview so credential refresh does not -restart playback. Saving or sharing still downloads the original file. +Every full-screen video preview on iOS is AVKit, playing directly from the signed asset +URL: sent attachments, composer drafts, and file links that point at a video. Inline +markdown embeds and the workspace file screen play in place with the Expo Video player. +AVKit handles buffering; the client does not download the entire file or show a separate +opening overlay before presentation. +The URL is captured once per preview so credential refresh does not restart playback. +Media actions (copy path, open in file viewer, save or share) belong to the surface that +opened the video, a long-press on the thumbnail, so the player carries no +menu. AVKit has no retry; a failure alerts and closes, re-mints the URL in the background, and the next open plays from the fresh one. The native presentation promise completes after dismissal. Local draft previews hold their file lease until that promise settles. The iOS preview component requests @@ -97,8 +102,10 @@ native dismissal when its source screen unmounts. Playback pauses in the backgro AVPlayer activates audio as playback starts. The presenter pauses and releases its own player on close, then restores the previous audio-session configuration if no other component changed it during playback. It does not deactivate the -shared session, which may still serve another player or recorder. Android retains -its React Native modal and Expo Video player. +shared session, which may still serve another player or recorder. Android plays every +video, attachments included, in its React Native modal with the Expo Video player. +Composer drafts play and share from their local file; other videos stream from the +signed URL and download only for save or share. `shareFileFromSource` uses the same source registration to anchor UIKit's activity controller. Its promise completes when the native share flow finishes, keeping diff --git a/docs/user/composer.md b/docs/user/composer.md index 13b4527ad26..681ad465141 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -37,12 +37,12 @@ download button beside it to save a copy. Other attached files download when sel Select a video attachment before or after sending to play it. Web and desktop use the browser's built-in controls. On mobile, videos open in a full-screen player with native playback controls. Supported videos show a thumbnail in the conversation and composer. -On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs -depend on the browser or device; you can save an unsupported video to open it in another app. +Received videos stream from their environment as they play on every platform. Supported formats and +codecs depend on the browser or device; you can save an unsupported video to open it in another app. On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the -conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open -the system share options. On Android, use **Save or share video** inside the preview. +conversation or draft. Touch and hold a video thumbnail, then choose **Save or share** to open +the system share options. On Android, the same menu is also available inside the preview. On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach again** next to it. Attach the file again or remove it, then send. @@ -97,20 +97,20 @@ open a media preview. Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They stream from the environment as needed, rather than downloading the entire video before playback. Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; -video embeds show a player with controls and an option to expand. Visible video previews load +video embeds show a player with the browser's controls, full screen included. Visible video previews load an initial frame when supported, but stay paused until you press Play. Video file references use a filmstrip icon. On web and desktop, hover over a preview to see its full file path or original URL. Right-click -to copy that reference, save an image, or copy an image to the clipboard. Use the video player's -built-in controls to download videos. If the player cannot decode a video, its error message +to copy that reference, save the image or video, or copy an image to the clipboard. The video +player's built-in controls can download a video too. If the player cannot decode a video, its error message offers a link to open the source in the browser. Workspace media also offers **Copy relative path** and **Open in file viewer**. These actions are available in expanded previews too. -On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its -source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file -viewer from the same menu. Saving downloads a copy only when you request it; it does not change -how the video buffers during playback. On iOS, touch and hold a file reference in a message to +On mobile, touch and hold an inline image or a video thumbnail to see its source, +copy the path or URL, or choose **Save or share**. Workspace files can open in the file viewer +from the same menu. Saving downloads a copy only when you request it; it does not change how +the video buffers during playback. On iOS, touch and hold a file reference in a message to copy its full or relative path or open it in the file viewer. Use Markdown image syntax to embed either kind of media: diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 2f5292e84ef..ffd21de8f0b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -27,6 +27,14 @@ "types": "./src/mediaReference.ts", "default": "./src/mediaReference.ts" }, + "./media-source": { + "types": "./src/mediaSource.ts", + "default": "./src/mediaSource.ts" + }, + "./media-actions": { + "types": "./src/mediaActions.ts", + "default": "./src/mediaActions.ts" + }, "./codex-file-citations": { "types": "./src/codexFileCitations.ts", "default": "./src/codexFileCitations.ts" diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 6f6fa31dbff..f4092674c5a 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -1,66 +1,27 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import { + normalizeMarkdownLinkDestination, + parseFileUrlHref, + safeDecodeURIComponent, + splitMarkdownLinkSearchAndHash, + stripSlashPrefixedWindowsDrive, +} from "./markdownLinks.ts"; + const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; export type MarkdownImageSource = | { readonly _tag: "Direct"; readonly uri: string } | { readonly _tag: "WorkspaceFile"; readonly path: string } | { readonly _tag: "Blocked" }; -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -function normalizeSource(value: string): string { - const trimmed = value.trim(); - return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; -} - export function markdownImageSourceFragment(source: string): string { - const normalizedSource = normalizeSource(source); - const hashIndex = normalizedSource.indexOf("#"); - return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : ""; -} - -function normalizeWindowsDrivePath(value: string): string { - return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; -} - -function parseFileUrl(value: string): string | null { - try { - const parsed = new URL(value); - if (parsed.protocol.toLowerCase() !== "file:") return null; - - if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") { - const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\"); - return `\\\\${safeDecode(parsed.hostname)}${pathname}`; - } - - const pathname = safeDecode(parsed.pathname); - if (pathname.length === 0) return null; - return normalizeWindowsDrivePath(pathname); - } catch { - return null; - } -} - -function stripSearchAndHash(value: string): string { - const searchIndex = value.indexOf("?"); - const hashIndex = value.indexOf("#"); - const end = [searchIndex, hashIndex] - .filter((index) => index >= 0) - .reduce((lowest, index) => Math.min(lowest, index), value.length); - return value.slice(0, end); + return splitMarkdownLinkSearchAndHash(normalizeMarkdownLinkDestination(source)).hash; } function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { - const windows = - WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); - const separator = windows ? "\\" : "/"; + const separator = isWindowsAbsolutePath(workspaceRoot) ? "\\" : "/"; const root = workspaceRoot.replace(/[\\/]+$/, ""); const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); return `${root}${separator}${path}`; @@ -77,7 +38,7 @@ export function classifyMarkdownImageSource( ): MarkdownImageSource { if (value === null || value === undefined) return { _tag: "Blocked" }; - const source = normalizeSource(value); + const source = normalizeMarkdownLinkDestination(value); if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { return { _tag: "Blocked" }; } @@ -86,13 +47,20 @@ export function classifyMarkdownImageSource( } if (/^file:/i.test(source)) { - const path = parseFileUrl(source); - return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path }; + const target = parseFileUrlHref(source); + return target === null + ? { _tag: "Blocked" } + : { + _tag: "WorkspaceFile", + path: stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)), + }; } - const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source))); + const path = stripSlashPrefixedWindowsDrive( + safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), + ); if (path.length === 0) return { _tag: "Blocked" }; - if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) { + if (path.startsWith("/") || isWindowsAbsolutePath(path)) { return { _tag: "WorkspaceFile", path }; } if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) { diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 5763fc5b6ff..cf8f9ae2d16 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; -import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js"; +import { + fileBasename, + inlineCodeFilePathCandidate, + isConventionalFilePosition, + parseFileUrlHref, + parseMarkdownFileLink, + splitFilePathPosition, + workspaceRelativeFilePath, +} from "./markdownLinks.ts"; describe("inlineCodeFilePathCandidate", () => { it.each([ @@ -28,3 +36,135 @@ describe("isConventionalFilePosition", () => { expect(isConventionalFilePosition("port:3000")).toBe(false); }); }); + +describe("parseFileUrlHref", () => { + it.each([ + ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], + [ + "file:///D:/Programme/t3code/OpenInPicker.tsx#L69", + "D:/Programme/t3code/OpenInPicker.tsx", + "#L69", + ], + ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""], + ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""], + ])("parses %s", (href, path, hash) => { + expect(parseFileUrlHref(href)).toEqual({ path, hash }); + }); + + it("keeps percent-encoding so the caller decodes once", () => { + expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe( + "/Users/julius/project/file%2520name.md", + ); + expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png"); + }); + + it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => { + expect(parseFileUrlHref(href)).toBeNull(); + }); +}); + +describe("splitFilePathPosition", () => { + it.each([ + ["src/main.ts", "", { path: "src/main.ts" }], + ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], + ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], + ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], + ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], + ["src/main.ts:0", "", { path: "src/main.ts" }], + ["src/main.ts", "#section", { path: "src/main.ts" }], + ])("splits %s%s", (path, hash, expected) => { + expect(splitFilePathPosition(path, hash)).toEqual(expected); + }); +}); + +describe("parseMarkdownFileLink", () => { + // Both clients consume this table, so a path the web app recognizes is one + // the mobile app recognizes too. + it.each([ + ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], + ["/home/me/notes.md", "/home/me/notes.md"], + ["/usr/local/bin/tool", "/usr/local/bin/tool"], + ["/workspace/Makefile", "/workspace/Makefile"], + ["/tmp/favicons/", "/tmp/favicons/"], + ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"], + ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], + ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], + ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"], + ["", "D:/Programme/t3code/ChatMarkdown.tsx"], + ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"], + ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"], + ["file://localhost/home/me/notes.md", "/home/me/notes.md"], + ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"], + ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"], + ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"], + ["./scripts/deploy", "./scripts/deploy"], + ["~/notes/today.md", "~/notes/today.md"], + ["AGENTS.md", "AGENTS.md"], + ["script.ts:10", "script.ts"], + ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], + ])("recognizes %s as a file", (href, path) => { + expect(parseMarkdownFileLink(href)?.path).toBe(path); + }); + + it.each([ + "", + "#anchor", + "//cdn.example.com/clip.mp4", + "https://example.com/docs", + "mailto:someone@example.com", + "javascript:alert(1)", + "/chat/settings", + "/chat/settings#L3", + "/app#L1", + "readme", + "TODO:12", + ])("does not treat %s as a file", (href) => { + expect(parseMarkdownFileLink(href)).toBeNull(); + }); + + it("accepts conventional extensionless names with or without a position", () => { + expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); + expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); + expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" }); + }); + + it("reads positions from suffixes and line anchors", () => { + expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ + path: "/Users/julius/project/src/main.ts", + line: 42, + column: 7, + }); + expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({ + path: "\\\\server\\share\\src\\main.ts", + line: 42, + column: 7, + }); + }); +}); + +describe("fileBasename", () => { + it.each([ + ["/tmp/favicons/", "favicons"], + ["C:\\Users\\kelchm\\.claude\\", ".claude"], + ["/tmp/", "tmp"], + ["AGENTS.md", "AGENTS.md"], + ["/", "/"], + ])("labels %s as %s", (path, basename) => { + expect(fileBasename(path)).toBe(basename); + }); +}); + +describe("workspaceRelativeFilePath", () => { + it.each([ + ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"], + ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], + ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], + ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], + ["/Repo/Project/src/main.ts", "/repo/project", "src/main.ts"], + ["/tmp/report.ts", "/repo/project", null], + ["/repo/project-two/a.ts", "/repo/project", null], + ["/repo/project/a.ts", undefined, null], + ])("relates %s to %s", (path, workspaceRoot, relativePath) => { + expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); + }); +}); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 73048357e61..81cce1fd7ee 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -1,12 +1,49 @@ -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const RELATIVE_FILE_PATH_PATTERN = + /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = + /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; +const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; +const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; +// Standard OS and dev-container roots; deliberately excludes app-route-ish +// prefixes like /app/ or /chat/ so SPA routes never read as files. +const POSIX_FILE_ROOT_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/etc/", + "/opt/", + "/mnt/", + "/Volumes/", + "/private/", + "/root/", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/srv/", + "/dev/", + "/proc/", + "/sys/", + "/run/", + "/boot/", + "/media/", + "/workspace/", + "/workspaces/", +] as const; // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. const EXTENSIONLESS_FILE_NAMES = new Set([ "Makefile", @@ -131,18 +168,14 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { const trimmed = codeText.trim(); if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; - const candidate = - WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) - ? trimmed - : trimmed.replaceAll("\\", "/"); + const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/"); const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; const hasExplicitPathShape = RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || candidate.startsWith("/") || - WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || - WINDOWS_UNC_PATH_PATTERN.test(candidate); + isWindowsAbsolutePath(candidate); if (!hasExplicitPathShape) { const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; @@ -156,3 +189,159 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { } return candidate; } + +export function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function normalizeMarkdownLinkDestination(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ +export function stripSlashPrefixedWindowsDrive(path: string): string { + return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; +} + +export function splitMarkdownLinkSearchAndHash(value: string): { + readonly path: string; + readonly hash: string; +} { + const hashIndex = value.indexOf("#"); + const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; + const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; + const queryIndex = pathWithSearch.indexOf("?"); + return { + path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, + hash, + }; +} + +/** + * Turns a `file:` URL into a host path, still percent-encoded so callers that + * decode every destination in one place do not decode file URLs twice. A + * non-localhost authority becomes a UNC share. + */ +export function parseFileUrlHref( + href: string, +): { readonly path: string; readonly hash: string } | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") return null; + + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const path = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` + : parsed.pathname; + if (path.length === 0) return null; + return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; + } catch { + return null; + } +} + +export interface FilePathPosition { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { + const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); + const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); + if (!match?.[1]) return { path }; + + const line = Number.parseInt(match[1], 10); + const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); + return { + path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, + ...(line > 0 ? { line } : {}), + ...(column !== undefined && column > 0 ? { column } : {}), + }; +} + +export function formatFilePathPosition(position: FilePathPosition): string { + if (!position.line) return position.path; + return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; +} + +export function isRelativeFilePath(path: string): boolean { + return ( + RELATIVE_PATH_PREFIX_PATTERN.test(path) || + (!path.startsWith("/") && !isWindowsAbsolutePath(path)) + ); +} + +function looksLikePosixFilesystemPath(path: string): boolean { + if (!path.startsWith("/")) return false; + if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; + if (POSITION_SUFFIX_PATTERN.test(path)) return true; + const basename = path.slice(path.lastIndexOf("/") + 1); + return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename); +} + +/** + * Decides whether a decoded link destination is a file path rather than a route + * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L` + * anchor never turns `/chat/settings` into a file. + */ +function looksLikeFilePath(path: string, authoredPath: string): boolean { + if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; + if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath); + if (EXTENSIONLESS_FILE_NAMES.has(path)) return true; + return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path); +} + +function hasExternalScheme(path: string): boolean { + if (isWindowsAbsolutePath(path)) return false; + const match = path.match(EXTERNAL_SCHEME_PATTERN); + if (!match) return false; + const rest = match[2] ?? ""; + if (rest.startsWith("//")) return true; + return !POSITION_ONLY_PATTERN.test(rest); +} + +export function parseMarkdownFileLink(href: string): FilePathPosition | null { + const normalized = normalizeMarkdownLinkDestination(href); + if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) { + return null; + } + + const source = + (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ?? + splitMarkdownLinkSearchAndHash(normalized); + // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded. + const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim())); + const hash = safeDecodeURIComponent(source.hash.trim()); + if (path.length === 0 || hasExternalScheme(path)) return null; + + const position = splitFilePathPosition(path, hash); + return looksLikeFilePath(position.path, path) ? position : null; +} + +export function fileBasename(path: string): string { + // A trailing separator is a valid way to write a directory. Trim it before + // taking the final segment so the label is never empty. + const trimmed = path.replace(/[/\\]+$/, ""); + if (trimmed.length === 0) return path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; +} + +export function workspaceRelativeFilePath( + path: string, + workspaceRoot: string | null | undefined, +): string | null { + if (!workspaceRoot) return null; + const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); + const normalizedRoot = stripSlashPrefixedWindowsDrive( + workspaceRoot.replaceAll("\\", "/"), + ).replace(/\/+$/, ""); + if (!normalizedPath.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) return null; + return normalizedPath.slice(normalizedRoot.length + 1); +} diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts new file mode 100644 index 00000000000..e0256dff93f --- /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.ts b/packages/client-runtime/src/mediaReference.ts index dcb93e6bc57..f44ec90cd87 100644 --- a/packages/client-runtime/src/mediaReference.ts +++ b/packages/client-runtime/src/mediaReference.ts @@ -1,5 +1,7 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { safeDecodeURIComponent } from "./markdownLinks.ts"; + /** The authored media location, never the temporary URL used to load its bytes. */ export type MediaReference = | { @@ -86,10 +88,5 @@ export function mediaReferenceFileName(reference: MediaReference): string | unde } catch { return undefined; } - if (!basename) return undefined; - try { - return decodeURIComponent(basename); - } catch { - return basename; - } + return basename ? safeDecodeURIComponent(basename) : undefined; } diff --git a/packages/client-runtime/src/mediaSource.test.ts b/packages/client-runtime/src/mediaSource.test.ts new file mode 100644 index 00000000000..9db7c5fee42 --- /dev/null +++ b/packages/client-runtime/src/mediaSource.test.ts @@ -0,0 +1,141 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMediaSource } from "./mediaSource.ts"; + +const threadId = ThreadId.make("thread-1"); +const attachmentId = + "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222-mp4"; + +describe("resolveMediaSource", () => { + describe("direct URLs", () => { + it("keeps the authored URL and decodes the display name once", () => { + const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; + expect(resolveMediaSource(href, { threadId })).toEqual({ + kind: "video", + mimeType: "video/mp4", + name: "clip one%20.mp4", + reference: { kind: "url", url: href }, + srcFragment: "#t=2", + access: "direct", + uri: href, + }); + }); + + it("keeps protocol-relative URLs as authored; clients add the scheme", () => { + const href = "//cdn.example.com/clip.mp4?sig=1#t=2"; + expect(resolveMediaSource(href, { threadId })).toMatchObject({ + access: "direct", + uri: href, + reference: { kind: "url", url: href }, + }); + }); + + it("accepts extensionless image embeds only when asked", () => { + const href = "https://cdn.example.com/render?id=42"; + expect(resolveMediaSource(href, { threadId })).toBeNull(); + expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({ + kind: "image", + mimeType: "image/*", + name: "render", + }); + }); + + it.each(["data:image/png;base64,AAAA", "blob:https://app.t3.codes/id"])( + "loads %s directly", + (href) => { + expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({ + access: "direct", + uri: href, + }); + }, + ); + }); + + describe("host paths", () => { + // POSIX, Windows, UNC, and file URLs must all reach the same media-file resource. + it.each([ + ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], + ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], + ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], + ["C:\\Users\\demo\\frame.png", "C:\\Users\\demo\\frame.png"], + ["/C:/Users/demo/frame.png", "C:/Users/demo/frame.png"], + ["file:///C:/Users/demo/frame.png", "C:/Users/demo/frame.png"], + ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], + ])("resolves %s through the environment", (href, path) => { + expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toEqual({ + kind: "image", + mimeType: "image/png", + name: path.split(/[\\/]/).at(-1), + reference: { kind: "file", path }, + srcFragment: "", + access: "environment", + resource: { _tag: "media-file", threadId, path }, + }); + }); + + it("joins relative paths to the workspace and records the relative reference", () => { + expect( + resolveMediaSource("screens/logo.svg?v=2#mark", { threadId, workspaceRoot: "/repo" }), + ).toEqual({ + kind: "image", + mimeType: "image/svg+xml", + name: "logo.svg", + reference: { + kind: "file", + path: "/repo/screens/logo.svg", + relativePath: "screens/logo.svg", + }, + srcFragment: "#mark", + access: "environment", + resource: { _tag: "media-file", threadId, path: "/repo/screens/logo.svg" }, + }); + }); + + it("prefers a caller-resolved path over classifying the source", () => { + expect( + resolveMediaSource("logo.svg", { + threadId, + workspaceRoot: "/repo", + resolvedFilePath: "/repo/docs/logo.svg", + }), + ).toMatchObject({ + access: "environment", + resource: { _tag: "media-file", path: "/repo/docs/logo.svg" }, + reference: { relativePath: "docs/logo.svg" }, + }); + }); + + it("separates a playback fragment from literal filename characters", () => { + expect(resolveMediaSource("/tmp/clip%23one.mp4#t=2", { threadId })).toMatchObject({ + kind: "video", + srcFragment: "#t=2", + resource: { path: "/tmp/clip#one.mp4" }, + }); + }); + + it("cannot be loaded without a thread to mint through", () => { + expect(resolveMediaSource("/tmp/clip.mp4", { threadId: undefined })).toMatchObject({ + kind: "video", + access: "unavailable", + }); + }); + + it.each(["notes.txt", "/repo/README", "/repo/archive.zip"])( + "returns null for the non-media path %s", + (href) => { + expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toBeNull(); + }, + ); + }); + + it("serves T3 attachment files in place like any other host path", () => { + const path = `/home/demo/.t3/userdata/attachments/${attachmentId}.mp4`; + expect(resolveMediaSource(path, { threadId, workspaceRoot: "/repo" })).toMatchObject({ + kind: "video", + access: "environment", + resource: { _tag: "media-file", threadId, path }, + }); + }); +}); diff --git a/packages/client-runtime/src/mediaSource.ts b/packages/client-runtime/src/mediaSource.ts new file mode 100644 index 00000000000..6c285e21ba9 --- /dev/null +++ b/packages/client-runtime/src/mediaSource.ts @@ -0,0 +1,104 @@ +import type { AssetResource, ThreadId } from "@t3tools/contracts"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; + +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, + type MarkdownImageSource, +} from "./markdownImages.ts"; +import { + fileBasename, + splitFilePathPosition, + splitMarkdownLinkSearchAndHash, +} from "./markdownLinks.ts"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, + type MediaReference, +} from "./mediaReference.ts"; + +export type MediaSourceResource = Extract; + +/** What a piece of authored media is and how its bytes can be reached. */ +export type ResolvedMediaSource = { + readonly kind: "image" | "video"; + readonly mimeType: string; + readonly name: string; + readonly reference?: MediaReference; + readonly srcFragment: string; +} & ( + | { + readonly access: "direct"; + readonly uri: string; + } + | { + readonly access: "environment"; + readonly resource: MediaSourceResource; + } + | { + readonly access: "unavailable"; + } +); + +export interface ResolveMediaSourceInput { + readonly threadId: ThreadId | undefined; + readonly workspaceRoot?: string | null | undefined; + readonly resolvedFilePath?: string | undefined; + /** Image syntax can target an endpoint without a recognizable extension. */ + readonly imageEmbed?: boolean | undefined; +} + +function classify(source: string, input: ResolveMediaSourceInput): MarkdownImageSource { + return input.resolvedFilePath === undefined + ? classifyMarkdownImageSource(source, input.workspaceRoot) + : { _tag: "WorkspaceFile", path: input.resolvedFilePath }; +} + +export function resolveMediaSource( + source: string, + input: ResolveMediaSourceInput, +): ResolvedMediaSource | null { + const classified = classify(source, input); + if (classified._tag === "Blocked") return null; + + const path = + classified._tag === "Direct" + ? splitMarkdownLinkSearchAndHash(classified.uri).path + : splitFilePathPosition(classified.path).path; + // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. + const basename = fileBasename(path); + const extensionIndex = basename.lastIndexOf("."); + const detectedMimeType = + classified._tag === "Direct" + ? mediaMimeType(classified.uri) + : extensionIndex < 0 + ? null + : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); + if (mimeType === null) return null; + const kind = mimeType.startsWith("video/") ? "video" : "image"; + + const reference = + classified._tag === "Direct" + ? mediaUrlReference(classified.uri) + : mediaFileReference(path, input.workspaceRoot); + const name = (reference && mediaReferenceFileName(reference)) || basename || kind; + const common = { + kind, + mimeType, + name, + ...(reference ? { reference } : {}), + srcFragment: markdownImageSourceFragment(source), + } as const; + + if (classified._tag === "Direct") { + return { ...common, access: "direct", uri: classified.uri }; + } + if (input.threadId === undefined) return { ...common, access: "unavailable" }; + return { + ...common, + access: "environment", + resource: { _tag: "media-file", threadId: input.threadId, path }, + }; +} diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028..f2f82def3dd 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,6 +1,11 @@ -import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { + type AssetCreateUrlResult, + AssetResource, + EnvironmentId, + WS_METHODS, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; @@ -43,6 +48,35 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin } } +export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("asset-url:empty"), +); + +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { + readonly _tag: "Success"; + readonly url: string; + /** The host path the server chose to serve, when it differs from what was asked for. */ + readonly sourcePath?: string; + }; + +export function assetUrlStateFromResult( + result: AsyncResult.AsyncResult, + httpBaseUrl: string | null, +): AssetUrlState { + if (result._tag === "Failure") return { _tag: "Failure" }; + if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" }; + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (url === null) return { _tag: "Failure" }; + return { + _tag: "Success", + url, + ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + }; +} + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index be590287a30..deb4d68cf06 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -353,17 +353,11 @@ describe("toolGroupAction", () => { describe("resolveViewedImageAsset", () => { const threadId = ThreadId.make("thread-1"); - it("loads t3 attachment paths as attachments", () => { - const attachmentId = - "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; - expect( - resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { - threadId, - workspaceRoot: "/workspace", - }), - ).toEqual({ - resource: { _tag: "attachment", attachmentId }, - alt: `${attachmentId}.png`, + it("serves t3 attachment paths in place like any other host path", () => { + const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png"; + expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({ + resource: { _tag: "media-file", threadId, path }, + alt: "11111111-1111-4111-8111-111111111111.png", srcFragment: "", }); }); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 4b9a20cf040..ae208a5bb49 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -4,10 +4,8 @@ import { type ThreadId, type ToolLifecycleItemType, } from "@t3tools/contracts"; -import { - classifyMarkdownImageSource, - markdownImageSourceFragment, -} from "@t3tools/client-runtime/markdown-images"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; export function isWorktreeSetupActivity(kind: string): boolean { @@ -350,15 +348,11 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin } export interface ViewedImageAsset { - readonly resource: Extract; + readonly resource: Extract; readonly alt: string; readonly srcFragment: string; } -const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; -const T3_ATTACHMENT_IMAGE_PATH_PATTERN = - /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; - export function resolveViewedImageAsset( source: string, input: { @@ -366,24 +360,22 @@ export function resolveViewedImageAsset( readonly workspaceRoot?: string | null | undefined; }, ): ViewedImageAsset | null { + // A relative path with no known workspace still names a media-file relative + // to the thread's workspace, so classify against "." and drop the prefix. const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); if (imageSource._tag !== "WorkspaceFile") return null; - - const path = + const resolvedFilePath = input.workspaceRoot == null && imageSource.path.startsWith("./") ? imageSource.path.slice(2) : imageSource.path; - const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source) - ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null) - : null; - return { - resource: attachmentId - ? { _tag: "attachment", attachmentId } - : { _tag: "media-file", threadId: input.threadId, path }, - alt: path.split(/[\\/]/).at(-1) ?? "image", - srcFragment: markdownImageSourceFragment(source), - }; + const media = resolveMediaSource(source, { + threadId: input.threadId, + workspaceRoot: input.workspaceRoot, + resolvedFilePath, + }); + if (media === null || media.access !== "environment") return null; + return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment }; } function toolGroupActionCount( From ec44bc56f598be16ffbc22ed0e4e095447043844 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 22:39:09 -0400 Subject: [PATCH 06/46] fix(chat): keep live tool labels in present tense (#9316) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/mobile/src/lib/threadActivity.test.ts | 14 ++++++----- apps/mobile/src/lib/threadActivity.ts | 15 ++++++------ .../chat/MessagesTimeline.logic.test.ts | 23 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 11 +++++---- .../components/chat/MessagesTimeline.test.tsx | 6 ++--- docs/user/tool-activity.md | 5 +++- .../src/work-log/presentation.test.ts | 8 +++---- .../src/work-log/presentation.ts | 9 +++++++- 8 files changed, 54 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 47bfe6755db..2ae78e5989f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -868,7 +868,7 @@ describe("buildThreadFeed", () => { title: "Call MCP tool", item: { server: "t3-code", tool: "preview_click" }, status: undefined, - displayName: "Click in the preview browser", + displayName: "Clicking in the preview browser", liveDisplayName: "Clicking in the preview browser", settledDisplayName: "Clicked in the preview browser", icon: "browser", @@ -879,7 +879,7 @@ describe("buildThreadFeed", () => { title: "Call MCP tool", item: { server: "t3-code", tool: "task_status" }, status: undefined, - displayName: "Get delegated task status", + displayName: "Getting delegated task status", liveDisplayName: "Getting delegated task status", settledDisplayName: "Got delegated task status", icon: "t3-code", @@ -1039,7 +1039,7 @@ describe("buildThreadFeed", () => { ).toMatchObject([ { type: "work-toggle", - summary: "Clicked in the preview browser", + summary: "Clicking in the preview browser", summaryToolIcon: "browser", live: true, }, @@ -1050,18 +1050,20 @@ describe("buildThreadFeed", () => { { status: "completed", displayName: "Clicked in the preview browser", + liveDisplayName: "Clicking in the preview browser", detail: "Clicked Continue", hasFailure: false, }, { status: "failed", displayName: "Failed to click in the preview browser", + liveDisplayName: "Failed to click in the preview browser", detail: "Timed out waiting for Continue", hasFailure: true, }, ])( "uses the browser call label once its action settles as $status", - ({ status, displayName, detail, hasFailure }) => { + ({ status, displayName, liveDisplayName, detail, hasFailure }) => { const turnId = TurnId.make("turn-preview-lifecycle"); const toolCallId = "preview-click"; const groupId = `work-group:tool:${turnId}:${toolCallId}`; @@ -1156,7 +1158,7 @@ describe("buildThreadFeed", () => { groupId, hiddenCount: 1, expanded: true, - summary: displayName, + summary: liveDisplayName, summaryToolIcon: "browser", hasFailure, live: true, @@ -1677,7 +1679,7 @@ describe("buildThreadFeed", () => { ( [ { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, - { lifecycleStatus: "completed", summary: "Ran pnpm", shimmer: false }, + { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c98cb40289e..89164c01db3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -17,6 +17,7 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, isWorktreeSetupActivity, + liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, @@ -1528,7 +1529,7 @@ function appendToolGroupRows( const latestActivity = latestActiveActivity ?? activities.at(-1)!; const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live - ? liveToolActivitySummary(latestActivity, active) + ? liveToolActivitySummary(latestActivity, live) : singleActivity !== null && singleActivity.toolLike && toolGroupAction(singleActivity.workEntry) !== "edit" @@ -1580,16 +1581,16 @@ function appendToolGroupRows( }); } -function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string { - const presentation = resolveWorkEntryToolPresentation( - activity.workEntry, - active ? "inProgress" : "completed", - ); +function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { + const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense); + const presentation = resolveWorkEntryToolPresentation({ + ...activity.workEntry, + toolLifecycleStatus: status, + }); if (presentation) return presentation.displayName; const command = activity.workEntry.command?.trim(); if (command) { const program = commandProgramName(command); - const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed"); const verb = status === "inProgress" ? "Running" diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 08723c7b9fe..f1cd270d7f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -99,13 +99,16 @@ describe("work entry labels", () => { ); }); - it("does not describe a finished call as still running while the turn continues", () => { + it("keeps the latest live activity in the present tense after the call completes", () => { const browserEntry = { ...entry, toolTitle: "T3-code.preview_click", toolLifecycleStatus: "completed" as const, }; expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( + "Clicking in the preview browser", + ); + expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe( "Clicked in the preview browser", ); }); @@ -134,21 +137,21 @@ describe("work entry labels", () => { }); it.each([ - ["inProgress", "Running vp"], - ["completed", "Ran vp"], - ["failed", "Failed vp"], - ["declined", "Declined vp"], - ["stopped", "Stopped vp"], + ["inProgress", "Running vp", "Running vp"], + ["completed", "Running vp", "Ran vp"], + ["failed", "Failed vp", "Failed vp"], + ["declined", "Declined vp", "Declined vp"], + ["stopped", "Stopped vp", "Stopped vp"], ] as const)( - "uses the command's %s outcome even while the turn continues", - (toolLifecycleStatus, label) => { + "uses present tense for a live %s command and the outcome once it is no longer live", + (toolLifecycleStatus, liveLabel, settledLabel) => { const commandEntry = { ...entry, command: "/bin/bash -lc 'vp test run'", toolLifecycleStatus, }; - expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(label); - expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(label); + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(liveLabel); + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(settledLabel); }, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 980a93a59bd..1fa0e7b4fe7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,6 +2,7 @@ import * as Equal from "effect/Equal"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import { + liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, @@ -65,14 +66,14 @@ export function liveWorkEntryLabel( workspaceRoot: string | undefined, active: boolean, ) { - const toolPresentation = resolveWorkEntryToolPresentation( - entry, - active ? "inProgress" : "completed", - ); + const status = liveActivityToolStatus(entry.toolLifecycleStatus, active); + const toolPresentation = resolveWorkEntryToolPresentation({ + ...entry, + toolLifecycleStatus: status, + }); if (toolPresentation) return toolPresentation.displayName; const command = entry.command?.trim(); if (command) { - const status = entry.toolLifecycleStatus ?? (active ? "inProgress" : "completed"); const verb = status === "inProgress" ? "Running" diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 495c38fa321..b8b8cad401c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1224,7 +1224,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-timeline-row-id="live-activity-row"'); }); - it("keeps the completed command in the shared activity row with a past-tense label", () => { + it("keeps the completed command in the shared activity row with a present-tense label", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Ran pnpm"); + expect(markup).toContain("Running pnpm"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("live-activity-focus"); - expect(markup).not.toContain("Running pnpm"); + expect(markup).not.toContain("Ran pnpm"); expect(markup).not.toContain("Thinking"); expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); diff --git a/docs/user/tool-activity.md b/docs/user/tool-activity.md index 67a3049016e..4394e3fc66c 100644 --- a/docs/user/tool-activity.md +++ b/docs/user/tool-activity.md @@ -8,7 +8,10 @@ indicate more calls above or below. Short groups use only the space they need. Collapsing and reopening a group preserves your reading position and any open call details. Recognized T3 tools use descriptive labels in both the running summary and individual rows. -Labels follow the call's state, such as "Clicking" while running and "Clicked" after success. +The latest live activity stays in the present tense while the turn continues, such as +"Running vp" or "Clicking in the preview browser", even after that call has completed. +Expanded rows follow the call's own state, such as "Clicked" after success. +When a call has not reported a state yet, the label stays in the present tense. Failed, declined, and stopped calls say what happened without implying success. Preview browser actions use a globe icon. Other T3 tools keep the T3 mark. Group summaries count browser actions separately, such as "Used browser 18 times" or diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index deb4d68cf06..2dfc26c36a4 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -25,7 +25,7 @@ describe("resolveWorkEntryToolPresentation", () => { "preview_click", ])("recognizes browser tool names across providers: %s", (label) => { expect(resolveWorkEntryToolPresentation({ label })).toEqual({ - displayName: "Click in the preview browser", + displayName: "Clicking in the preview browser", icon: "browser", }); }); @@ -37,7 +37,7 @@ describe("resolveWorkEntryToolPresentation", () => { toolTitle: "Inspect the current page", toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } }, }), - ).toEqual({ displayName: "Take a snapshot of the preview page", icon: "browser" }); + ).toEqual({ displayName: "Taking a snapshot of the preview page", icon: "browser" }); }); it.each([ @@ -46,7 +46,7 @@ describe("resolveWorkEntryToolPresentation", () => { ["failed", "Failed to click in the preview browser"], ["declined", "Declined to click in the preview browser"], ["stopped", "Stopped clicking in the preview browser"], - ["unknown", "Click in the preview browser"], + ["unknown", "Clicking in the preview browser"], ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => { expect( resolveWorkEntryToolPresentation({ @@ -115,7 +115,7 @@ describe("resolveWorkEntryToolPresentation", () => { label: "mcp__t3_code__task_status", toolTitle: "Check the child task", }), - ).toEqual({ displayName: "Get delegated task status", icon: "t3-code" }); + ).toEqual({ displayName: "Getting delegated task status", icon: "t3-code" }); }); it("does not brand unknown tools or another server's matching tool name", () => { diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index ae208a5bb49..e19d60b1498 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -113,7 +113,7 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string ? `Declined to ${action.toLowerCase()}` : status === "stopped" ? `Stopped ${running.toLowerCase()}` - : action; + : running; return { displayName: `${verb} ${detail}`, @@ -121,6 +121,13 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string }; } +/** Latest live activity stays present-tense unless the call itself failed, declined, or stopped. */ +export function liveActivityToolStatus(status: string | undefined, presentTense: boolean) { + if (status === "failed" || status === "declined" || status === "stopped") return status; + if (presentTense || status === "inProgress") return "inProgress"; + return "completed"; +} + /** Resolves tool identity before choosing labels or icons in either client. */ export function resolveWorkEntryToolPresentation( entry: Pick, From 194f838e7636f97739c440c7855aed195dbd7f52 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:40:26 -0700 Subject: [PATCH 07/46] chore: audit lint directives and move plugin allowlists into config (#9300) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/desktop/src/preview-pip-preload.ts | 1 - apps/mobile/metro.config.js | 3 +- .../src/MarkdownTextPrimitive.tsx | 18 ++-- apps/server/scripts/migrate-dev-db.ts | 1 - apps/server/scripts/t3-sqlite-state.ts | 1 - apps/server/src/assets/AttachmentUpload.ts | 1 - apps/server/src/http.ts | 3 +- .../src/relay/AgentAwarenessRelay.test.ts | 86 ++++++++----------- apps/server/src/server.test.ts | 16 ++-- apps/server/src/serviceLauncher.ts | 1 - .../settings/ThemeSearchSection.tsx | 13 ++- apps/web/src/hooks/useTheme.ts | 3 +- .../rules/no-global-process-runtime.ts | 21 +---- .../no-manual-effect-runtime-in-tests.test.ts | 31 +++++++ .../no-manual-effect-runtime-in-tests.ts | 66 ++++++-------- ...obile-uniwind-theme-escape-hatches.test.ts | 27 ++---- .../no-mobile-uniwind-theme-escape-hatches.ts | 71 +++++++-------- oxlint-plugin-t3code/test/utils.ts | 4 +- .../src/relay/managedRelayState.test.ts | 52 ++++++----- scripts/build-desktop-artifact.test.ts | 1 - vite.config.ts | 69 ++++++++++++++- 21 files changed, 258 insertions(+), 231 deletions(-) diff --git a/apps/desktop/src/preview-pip-preload.ts b/apps/desktop/src/preview-pip-preload.ts index 384c4129774..6771eba8aaf 100644 --- a/apps/desktop/src/preview-pip-preload.ts +++ b/apps/desktop/src/preview-pip-preload.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 3791d347c62..f8eda69b6fb 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -36,8 +36,7 @@ config.resolver = { new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`), ], extraNodeModules: { - // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(config.resolver?.extraNodeModules ?? {}), + ...config.resolver?.extraNodeModules, shiki: mobileShikiRoot, "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 2cd54b5c1ef..36e87ee9415 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -9,10 +9,10 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ StyleSheet.create({}), ]); -const textDefaults: TextProps = { +const textDefaults = { allowFontScaling: true, selectable: true, -}; +} satisfies TextProps; const useTextAncestorContext = () => React.useContext(TextAncestorContext); @@ -28,7 +28,11 @@ export type ContextMenuActionEvent = { nativeEvent: { target: number; actionIdentifier: string }; }; -export type MarkdownTextPrimitiveProps = TextProps & { +/** + * `onTextLayout` is not offered: the native view reports plain line strings + * while the React Native Text fallback reports measured `TextLayoutLine`s. + */ +export type MarkdownTextPrimitiveProps = Omit & { uiTextView?: boolean; contextMenuConfig?: string; onContextMenuAction?: (event: ContextMenuActionEvent) => void; @@ -75,16 +79,14 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr }); if (!isAncestor) { + // Press handlers are delivered by the text runs; the container never sees them. + const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest; return ( {nativeChildren} diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 5670f0d52b0..fd1b74cb474 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -24,7 +24,6 @@ * cursors never rewind. */ -// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index c34f4750c14..8423e7e6090 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index ba3539a3df4..28b20c7ba26 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3bcca884107..ddc6d67e134 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -73,8 +73,7 @@ export function downloadContentDisposition(fileName?: string): string { return "attachment"; } // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates. - // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls. - const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_"); + const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_"); const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_"); const needsExtended = asciiFallback !== sanitized; const extendedName = encodeURIComponent(sanitized).replace( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 2465052a33c..b38861c9877 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -358,58 +358,45 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ).toEqual([activeThreadId]); }); - it("signs the activity publish JWT and rejects tampering", async () => { - const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { - privateKeyEncoding: { format: "pem", type: "pkcs8" }, - publicKeyEncoding: { format: "pem", type: "spki" }, - }); - const payload = { - iss: "t3-env:env", - aud: "https://relay.example.test", - sub: "env", - jti: "nonce-1", - iat: 100, - exp: 200, - environmentId: state.environmentId, - threadId: state.threadId, - state, - } satisfies RelayAgentActivityPublishProofPayload; - const proof = await Effect.runPromise( - AgentAwarenessRelay.signRelayAgentActivityPublishProof({ + it.effect("signs the activity publish JWT and rejects tampering", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const payload = { + iss: "t3-env:env", + aud: "https://relay.example.test", + sub: "env", + jti: "nonce-1", + iat: 100, + exp: 200, + environmentId: state.environmentId, + threadId: state.threadId, + state, + } satisfies RelayAgentActivityPublishProofPayload; + const proof = yield* AgentAwarenessRelay.signRelayAgentActivityPublishProof({ privateKey: keyPair.privateKey, payload, - }), - ); - - await expect( - Effect.runPromise( - verifyRelayJwt({ - publicKey: keyPair.publicKey, - token: proof, - typ: RELAY_ACTIVITY_PUBLISH_TYP, - issuer: "t3-env:env", - audience: "https://relay.example.test", - nowEpochSeconds: 150, - }), - ), - ).resolves.toMatchObject({ jti: "nonce-1", state }); - await expect( - Effect.runPromise( + }); + const verify = (token: string) => verifyRelayJwt({ publicKey: keyPair.publicKey, - token: (() => { - const [header, body, signature = ""] = proof.split("."); - const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; - return `${header}.${body}.${corruptedSignature}`; - })(), + token, typ: RELAY_ACTIVITY_PUBLISH_TYP, issuer: "t3-env:env", audience: "https://relay.example.test", nowEpochSeconds: 150, - }), - ), - ).rejects.toBeDefined(); - }); + }); + + expect(yield* verify(proof)).toMatchObject({ jti: "nonce-1", state }); + + const [header, body, signature = ""] = proof.split("."); + const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; + const rejection = yield* Effect.flip(verify(`${header}.${body}.${corruptedSignature}`)); + expect(rejection).toBeDefined(); + }), + ); it.effect("keeps the orchestration listener armed until relay config is installed", () => Effect.scoped( @@ -557,10 +544,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Effect.scoped( Effect.gen(function* () { const originalFetch = globalThis.fetch; - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); - const fetchSeen = yield* Deferred.make(); + let resolveFetchSeen: (url: URL) => void = () => {}; + const fetchSeen = new Promise((resolve) => { + resolveFetchSeen = resolve; + }); const userSpans: Array = []; const productSpans: Array = []; const collectingTracer = (spans: Array) => @@ -648,7 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ? input : (input as unknown as { readonly url: string }).url, ); - runFork(Deferred.succeed(fetchSeen, url)); + resolveFetchSeen(url); return Promise.resolve(Response.json({ ok: true, deliveries: [] })); }) as unknown as typeof fetch; yield* Effect.addFinalizer(() => @@ -707,7 +695,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { occurredAt: now, } as unknown as OrchestrationEvent); - const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); + const url = yield* Effect.promise(() => fetchSeen).pipe(Effect.timeout("2 seconds")); expect(url.origin).toBe("https://transport.example.test"); expect(productSpans).toContain("makePublishProof"); expect(userSpans).not.toContain("makePublishProof"); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d3e94e4eea4..cfa0bf3dbee 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -56,7 +56,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; @@ -398,7 +397,9 @@ const makeBrowserOtlpPayload = (spanName: string) => ({ close }) => Effect.promise(close), ); - const runtime = ManagedRuntime.make( + // The exporter's batch fiber is forked while the layer builds and ticks on + // a wall-clock interval, so the whole tracer runs on the live clock. + yield* Layer.build( OtlpTracer.layer({ url: collector.url, exportInterval: "10 millis", @@ -411,14 +412,13 @@ const makeBrowserOtlpPayload = (spanName: string) => }, }, }).pipe(Layer.provide(browserOtlpTracingLayer)), + ).pipe( + Effect.flatMap((tracing) => + Effect.void.pipe(Effect.withSpan(spanName), Effect.provideContext(tracing)), + ), + TestClock.withLive, ); - try { - yield* Effect.promise(() => runtime.runPromise(Effect.void.pipe(Effect.withSpan(spanName)))); - } finally { - yield* Effect.promise(() => runtime.dispose()); - } - const request = yield* Effect.raceFirst( Effect.promise(() => collector.firstRequest).pipe(Effect.orDie), Effect.sleep(Duration.seconds(1)).pipe( diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 68f3c346759..b99f90e0fdf 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalDate:off // @effect-diagnostics globalTimers:off // This file is shipped as a standalone bundle and copied to a stable path by // `t3 service update`. Keep runtime imports limited to Node built-ins. diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index 14919e09ba2..b270bf7b8e4 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -204,13 +204,12 @@ export function ThemeSearchSection({ return; } void runSearch(debouncedQuery); - // `installingId` and `sortBy` are deliberately not dependencies: the - // guards above read the current values from the fresh render closure. An - // install finishing reruns the search only when the query or sort changed - // while it was in flight (checked via lastSearchKeyRef, recorded only - // once a search succeeds), so the install error the user needs to see is - // preserved across that rerun. - // eslint-disable-next-line react-hooks/exhaustive-deps + // `sortBy` is deliberately not a direct dependency: the guards above read + // the current value from the fresh render closure. An install finishing + // reruns the search only when the query or sort changed while it was in + // flight (checked via lastSearchKeyRef, recorded only once a search + // succeeds), so the install error the user needs to see is preserved + // across that rerun. }, [open, query, debouncedQuery, installingId, runSearch]); const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index e729335f96b..726a03dac7b 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -356,8 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview syncDesktopTheme(theme, followSystem, appearanceMode); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal - // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; + void document.documentElement.offsetHeight; requestAnimationFrame(() => { document.documentElement.classList.remove("no-transitions"); }); diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts index e364d29040f..f122d16db29 100644 --- a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts @@ -4,23 +4,8 @@ import * as Option from "effect/Option"; import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; const RUNTIME_PROPERTIES = new Set(["platform", "arch"]); -const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts"; const NODE_OS_MODULES = new Set(["node:os", "os"]); -const normalizePath = (path: string) => path.replaceAll("\\", "/"); - -const toRepoPath = (filename: string, cwd: string) => { - const normalizedFilename = normalizePath(filename); - const normalizedCwd = normalizePath(cwd).replace(/\/+$/u, ""); - const prefix = `${normalizedCwd}/`; - return normalizedFilename.startsWith(prefix) - ? normalizedFilename.slice(prefix.length) - : normalizedFilename; -}; - -const isHostProcessReferenceFile = (filename: string, cwd: string) => - toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE; - const isGlobalProcessObject = (node: unknown): boolean => { const expression = unwrapExpression(node); if (isIdentifier(expression, "process")) return true; @@ -48,7 +33,7 @@ export default defineRule({ type: "problem", docs: { description: - "Disallow direct host runtime platform/architecture reads outside the shared host process references.", + "Disallow direct host runtime platform/architecture reads; the shared host process references are exempted in the lint config.", }, }, createOnce(context) { @@ -117,8 +102,6 @@ export default defineRule({ before: resetBindings, ImportDeclaration: trackImportDeclaration, MemberExpression(node) { - if (isHostProcessReferenceFile(context.filename, context.cwd)) return; - const property = getPropertyName(node.property); if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return; if (!isGlobalProcessObject(node.object)) return; @@ -129,8 +112,6 @@ export default defineRule({ }); }, CallExpression(node) { - if (isHostProcessReferenceFile(context.filename, context.cwd)) return; - const property = getNodeOsRuntimeCall(node.callee); if (Option.isNone(property)) return; diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts index bd8c97f5fa4..6864699a54c 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts @@ -71,3 +71,34 @@ productionRule.valid( export const main = () => Effect.runPromise(Effect.void); `, ); + +const legacyRule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests", { + filename: "legacy.test.ts", + ruleOptions: [{ maxOccurrences: 2 }], +}); + +describe("t3code/no-manual-effect-runtime-in-tests with maxOccurrences", () => { + legacyRule.valid( + "allows occurrences up to the ceiling", + ` + import * as Effect from "effect/Effect"; + + test("first", () => Effect.runSync(Effect.void)); + test("second", () => Effect.runSync(Effect.void)); + `, + ); + + legacyRule.invalid( + "reports occurrences beyond the ceiling", + ` + import * as Effect from "effect/Effect"; + + test("first", () => Effect.runSync(Effect.void)); + test("second", () => Effect.runSync(Effect.void)); + test("third", () => Effect.runSync(Effect.void)); + `, + (output) => { + assert.equal(output.match(/no-manual-effect-runtime-in-tests/g)?.length, 1); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index e6eff1e5c21..ae90cb2f29b 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -19,44 +19,17 @@ const EFFECT_RUNTIME_METHODS = new Set([ "runSyncWith", ]); -// Existing manual runners are tracked as debt. The rule permits no net-new -// occurrences in these files, while unlisted test files must have zero. -const LEGACY_BASELINE = new Map([ - ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1], - ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2], - ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2], - ["apps/server/src/orchestration/commandInvariants.test.ts", 6], - ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42], - ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5], - ["apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts", 4], - ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70], - ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31], - ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2], - ["apps/server/src/orchestration/projector.test.ts", 20], - ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4], - ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1], - ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2], - ["apps/server/src/provider/Layers/CodexAdapter.test.ts", 1], - ["apps/server/src/provider/Layers/CodexSessionRuntime.test.ts", 5], - ["apps/server/src/provider/Layers/CursorAdapter.test.ts", 1], - ["apps/server/src/provider/Layers/CursorProvider.test.ts", 4], - ["apps/server/src/provider/Layers/ProviderService.test.ts", 2], - ["apps/server/src/provider/Layers/ProviderSessionReaper.test.ts", 21], - ["apps/server/src/relay/AgentAwarenessRelay.test.ts", 4], - ["apps/server/src/server.test.ts", 1], - ["apps/web/src/cloud/dpop.test.ts", 2], - ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], - ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], - ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1], - ["packages/client-runtime/src/wsTransport.test.ts", 2], -]); - -const baselineFor = (filename: string): number => { - const normalized = filename.replaceAll("\\", "/"); - for (const [suffix, count] of LEGACY_BASELINE) { - if (normalized.endsWith(suffix)) return count; - } - return 0; +// Existing manual runners are tracked as debt through the `maxOccurrences` +// option, set per file in the lint config. The rule permits no net-new +// occurrences in those files, while every other test file must have zero. +const readMaxOccurrences = (options: ReadonlyArray): number => { + const [first] = options; + return typeof first === "object" && + first !== null && + "maxOccurrences" in first && + typeof first.maxOccurrences === "number" + ? first.maxOccurrences + : 0; }; const manualRunnerName = (callee: unknown): Option.Option => { @@ -87,11 +60,26 @@ export default defineRule({ description: "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.", }, + schema: [ + { + type: "object", + properties: { + maxOccurrences: { + type: "integer", + minimum: 0, + description: + "Legacy debt ceiling for this file: occurrences beyond this count are reported.", + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ maxOccurrences: 0 }], }, create(context) { if (!TEST_FILE_PATTERN.test(context.filename)) return {}; - const allowedCount = baselineFor(context.filename); + const allowedCount = readMaxOccurrences(context.options); let occurrenceCount = 0; return { diff --git a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts index 67500e74709..9ba9fce14bf 100644 --- a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts +++ b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts @@ -7,15 +7,11 @@ const guardedMobileFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-them }); const reviewedInteropFile = createOxlintRuleHarness( "t3code/no-mobile-uniwind-theme-escape-hatches", - { filename: "apps/mobile/src/features/home/HomeHeader.tsx" }, + { + filename: "apps/mobile/src/features/home/HomeHeader.tsx", + ruleOptions: [{ allowUniwindTheme: true }], + }, ); -const gitOverlayInteropFile = createOxlintRuleHarness( - "t3code/no-mobile-uniwind-theme-escape-hatches", - { filename: "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx" }, -); -const webFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-theme-escape-hatches", { - filename: "apps/web/src/ThemeSurface.tsx", -}); describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => { guardedMobileFile.valid( @@ -93,18 +89,9 @@ describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => { `, ); - gitOverlayInteropFile.valid( - "allows the native liquid-glass theme boundary", - ` - import { useUniwindTheme } from "../../lib/useUniwindTheme"; - - export const tint = useUniwindTheme()["--color-glass-surface"]; - `, - ); - - webFile.valid( - "does not impose the mobile custom-theme policy on web code", - `const surface =
;`, + reviewedInteropFile.invalid( + "still reports appearance variants in reviewed interop boundaries", + `const surface = ;`, ); guardedMobileFile.invalid( diff --git a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts index 5e6a7111e81..f524709c36e 100644 --- a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts +++ b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts @@ -3,44 +3,9 @@ import * as Option from "effect/Option"; import { getPropertyName, unwrapExpression } from "../utils.ts"; -const MOBILE_SOURCE_MARKER = "/apps/mobile/src/"; const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u; const APPEARANCE_VARIANT_MESSAGE = "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token."; -const THEME_INTEROP_ALLOWLIST = new Set([ - "features/archive/ArchivedThreadsScreen.tsx", - "features/connection/ConnectionsNewRouteScreen.tsx", - "features/files/FileMarkdownPreview.tsx", - "features/files/SourceFileSurface.tsx", - "features/files/ThreadFilesRouteScreen.tsx", - "features/files/thread-file-navigator-pane.tsx", - "features/home/HomeHeader.tsx", - "features/review/ReviewSheet.tsx", - "features/review/useNativeReviewDiffBridge.ts", - "features/settings/SettingsEnvironmentsRouteScreen.tsx", - "features/settings/appearance/components/AppearancePreviews.tsx", - "features/settings/appearance/components/FontSizeSliderRow.tsx", - "features/threads/GitActionProgressOverlay.tsx", - "features/threads/NewTaskContextPickerScreens.tsx", - "features/threads/NewTaskDraftScreen.tsx", - "features/threads/ThreadComposer.tsx", - "features/threads/ThreadFeed.tsx", - "features/threads/ThreadSettingsSheet.tsx", - "features/threads/git/GitOverviewSheet.tsx", - "features/threads/thread-list-items.tsx", - "features/threads/thread-list-v2-items.tsx", - "lib/useMobileNavigationTheme.ts", - "native/T3ComposerEditor.ios.tsx", - "native/T3ComposerEditor.native.tsx", -]); - -const mobileSourcePath = (filename: string): string | undefined => { - const normalized = `/${filename.replaceAll("\\", "/")}`; - const markerIndex = normalized.lastIndexOf(MOBILE_SOURCE_MARKER); - return markerIndex === -1 - ? undefined - : normalized.slice(markerIndex + MOBILE_SOURCE_MARKER.length); -}; const literalStringValue = (node: unknown): Option.Option => { if (typeof node !== "object" || node === null) return Option.none(); @@ -54,18 +19,40 @@ const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.t const importsModule = (source: string, modulePath: string): boolean => source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath); +const readAllowUniwindTheme = (options: ReadonlyArray): boolean => { + const [first] = options; + return ( + typeof first === "object" && + first !== null && + "allowUniwindTheme" in first && + first.allowUniwindTheme === true + ); +}; + export default defineRule({ meta: { type: "problem", docs: { description: - "Keep mobile theme styling on semantic Uniwind classes and reviewed native interop boundaries.", + "Keep mobile theme styling on semantic Uniwind classes. Scope the rule to mobile sources and exempt reviewed native interop boundaries in the lint config.", }, + schema: [ + { + type: "object", + properties: { + allowUniwindTheme: { + type: "boolean", + description: + "Permit useUniwindTheme in a reviewed native/third-party interop boundary that cannot consume a className.", + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowUniwindTheme: false }], }, create(context) { - const sourcePath = mobileSourcePath(context.filename); - if (sourcePath === undefined) return {}; - + const allowUniwindTheme = readAllowUniwindTheme(context.options); const uniwindNamespaces = new Set(); const resolveVariable = (node: unknown): Variable | undefined => { @@ -131,13 +118,13 @@ export default defineRule({ if ( !isTypeOnly && - importsModule(source.value, "/useUniwindTheme") && - !THEME_INTEROP_ALLOWLIST.has(sourcePath) + !allowUniwindTheme && + importsModule(source.value, "/useUniwindTheme") ) { context.report({ node: specifier, message: - "Use className for theme styling, or review and add this native/third-party interop boundary to the lint allowlist.", + "Use className for theme styling, or review this native/third-party interop boundary and enable allowUniwindTheme for it in the lint config.", }); } } diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index 704c3436ce1..dc80ea3451f 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -61,6 +61,8 @@ interface RuleHarness { interface RuleHarnessOptions { readonly filename?: string; + /** Rule options, as they would appear after the severity in the lint config. */ + readonly ruleOptions?: ReadonlyArray; } const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => @@ -111,7 +113,7 @@ export const createOxlintRuleHarness = ( configPath, yield* encodeOxlintConfig({ jsPlugins: [{ name: "t3code", specifier: pluginPath }], - rules: { [ruleName]: "error" }, + rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] }, }), ); yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 0588da34206..8c93ec136d6 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -243,30 +243,38 @@ describe("createManagedRelayQueryManager", () => { }), ); - it("emits credential changes only when the managed relay account changes", async () => { - setManagedRelaySession(registry, { - accountId: "account-1", - readClerkToken: () => Promise.resolve("first-token"), - }); - const changes = Effect.runPromise( - managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect), - ); - await vi.waitFor(() => { - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0); - }); + it.effect("emits credential changes only when the managed relay account changes", () => + Effect.gen(function* () { + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("first-token"), + }); + const changes = yield* managedRelayAccountChanges(registry).pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan( + 0, + ); + }), + ); - setManagedRelaySession(registry, { - accountId: "account-1", - readClerkToken: () => Promise.resolve("refreshed-token"), - }); - setManagedRelaySession(registry, { - accountId: "account-2", - readClerkToken: () => Promise.resolve("second-token"), - }); - setManagedRelaySession(registry, null); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("refreshed-token"), + }); + setManagedRelaySession(registry, { + accountId: "account-2", + readClerkToken: () => Promise.resolve("second-token"), + }); + setManagedRelaySession(registry, null); - expect(Array.from(await changes)).toEqual(["account-2", null]); - }); + expect(Array.from(yield* Fiber.join(changes))).toEqual(["account-2", null]); + }), + ); it("shares one Clerk token read across concurrent relay list and status queries", async () => { const secondEnvironment = { diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d2ca3c601bf..19979c330dc 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off - packaged-archive fixtures compute the sidecar digest with the same Node primitive as the builder. import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/vite.config.ts b/vite.config.ts index 343adcbaa5a..c616998d529 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ }, fmt: { ignorePatterns: [ - ".reference", ".repos/**", ".alchemy", "dist", @@ -37,7 +36,6 @@ export default defineConfig({ "**/routeTree.gen.ts", "apps/mobile/android/**", "apps/mobile/ios/**", - "apps/web/src/lib/vendor/qrcodegen.ts", "apps/mobile/uniwind-types.d.ts", "*.icon/**", ], @@ -119,11 +117,76 @@ export default defineConfig({ "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", - "t3code/no-mobile-uniwind-theme-escape-hatches": "error", "t3code/no-native-title-tooltip": "error", "t3code/namespace-node-imports": "error", }, + overrides: [ + { + // The one place that reads the host platform to seed the injected references. + files: ["packages/shared/src/hostProcess.ts"], + rules: { "t3code/no-global-process-runtime": "off" }, + }, + { + files: ["apps/mobile/src/**"], + rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, + }, + { + // Reviewed native and third-party interop boundaries that cannot consume a className. + files: [ + "apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx", + "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", + "apps/mobile/src/features/files/FileMarkdownPreview.tsx", + "apps/mobile/src/features/files/SourceFileSurface.tsx", + "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", + "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", + "apps/mobile/src/features/home/HomeHeader.tsx", + "apps/mobile/src/features/review/ReviewSheet.tsx", + "apps/mobile/src/features/review/useNativeReviewDiffBridge.ts", + "apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx", + "apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx", + "apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx", + "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx", + "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx", + "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx", + "apps/mobile/src/features/threads/ThreadComposer.tsx", + "apps/mobile/src/features/threads/ThreadFeed.tsx", + "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx", + "apps/mobile/src/features/threads/git/GitOverviewSheet.tsx", + "apps/mobile/src/features/threads/thread-list-items.tsx", + "apps/mobile/src/features/threads/thread-list-v2-items.tsx", + "apps/mobile/src/lib/useMobileNavigationTheme.ts", + "apps/mobile/src/native/T3ComposerEditor.ios.tsx", + "apps/mobile/src/native/T3ComposerEditor.native.tsx", + ], + rules: { + "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], + }, + }, + // Legacy manual Effect runners tracked as debt: no net-new occurrences. + // Lower a ceiling when you migrate a file, and delete its entry at zero. + ...Object.entries({ + "apps/server/src/orchestration/Layers/CheckpointReactor.test.ts": 42, + "apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts": 5, + "apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts": 4, + "apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts": 66, + "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 29, + "apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts": 2, + "apps/server/src/orchestration/commandInvariants.test.ts": 5, + "apps/server/src/orchestration/projector.test.ts": 20, + "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1, + "apps/server/src/provider/Layers/CodexSessionRuntime.test.ts": 5, + "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1, + "apps/server/src/provider/Layers/CursorProvider.test.ts": 1, + "apps/server/src/provider/Layers/ProviderService.test.ts": 2, + "apps/server/src/provider/Layers/ProviderSessionReaper.test.ts": 12, + "apps/server/src/provider/acp/CursorAcpSupport.test.ts": 1, + }).map(([file, maxOccurrences]) => { + const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }]; + return { files: [file], rules: { "t3code/no-manual-effect-runtime-in-tests": rule } }; + }), + ], options: { + reportUnusedDisableDirectives: "error", // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. typeAware: false, typeCheck: false, From b9b1b8fdddf9d006fdb820af770063e1f968345b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:47:34 -0700 Subject: [PATCH 08/46] chore(ci): narrow the Effect conventions check-run agent (#9321) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- .../effect-service-conventions.md | 89 +++++++------------ 1 file changed, 31 insertions(+), 58 deletions(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index eae8e260b3c..316cf3509bf 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -5,16 +5,13 @@ effort: high input: full_diff tools: - browse_code - - git_tools - - github_api_read_only - modify_pr include: - "apps/**/*.ts" - - "apps/**/*.tsx" - "packages/**/*.ts" - - "packages/**/*.tsx" - "infra/**/*.ts" - - "infra/**/*.tsx" +exclude: + - "**/*.test.ts" labels: - vouch:trusted requires: @@ -26,74 +23,50 @@ showToolCalls: true # Effect service review -Review changed TypeScript and directly affected call sites for the conventions below. Apply them when a pull request creates, moves, refactors, or consumes an Effect service. Do not demand unrelated repository-wide cleanup. Treat these instructions as authoritative when older code differs. +Review changed TypeScript for the conventions below. They apply when a pull request creates, moves, refactors, or consumes an Effect service. Review only the lines the PR changed; older code in the same file that predates these conventions is not a finding. Do not demand repository-wide cleanup. ## Imports and module namespaces -- Import Effect library modules from their subpaths as namespaces, for example `import * as Effect from "effect/Effect"` and `import * as Layer from "effect/Layer"`. Flag consolidated named imports from `"effect"` in touched Effect service code. -- At a service boundary, import the local service module as a namespace and use its public module shape: `WorkspacePaths.WorkspacePaths`, `WorkspacePaths.make`, and `WorkspacePaths.layer`. Flag aliases such as `import { layer as workspacePathsLayer }` that erase the module namespace. -- Namespace imports are not a blanket rule. Keep named imports for whole packages such as `@t3tools/contracts`, and for modules used only for a pure helper, error, schema, config value, or standalone type. Do not request `import type * as Contracts`. -- A package subpath that is itself a service module may use a namespace import when callers access its service/tag, `make`, or `layer` members. -- When a barrel exposes an entire service module, prefer `export * as TokenStore from "./tokenStore.ts"` so consumers can use `TokenStore.TokenStore` and `TokenStore.layer`. Do not individually rename `make` and `layer` exports to simulate a namespace. +- Import Effect modules from their subpaths as namespaces: `import * as Effect from "effect/Effect"`, `import * as Layer from "effect/Layer"`. Flag named imports from the bare `"effect"` package. +- At a service boundary, import the local service module as a namespace and use its public shape: `WorkspacePaths.WorkspacePaths`, `WorkspacePaths.make`, `WorkspacePaths.layer`. Flag aliases such as `import { layer as workspacePathsLayer }` that erase the namespace. +- Named imports stay correct for whole packages such as `@t3tools/contracts` and for modules used only for a pure helper, error, schema, config value, or type. Do not request `import type * as Contracts`. +- When a barrel exposes a whole service module, prefer `export * as TokenStore from "./tokenStore.ts"` over individually renamed `make` and `layer` exports. ## Service definition -- Use the canonical single-file order: imports, error/schema declarations, the `Context.Service` tag with its inline interface, `make`, then `layer`. -- Keep a service's schemas/errors, `Context.Service` tag, construction, and layer in one canonical module when they form one implementation. -- Define the service interface inline in the `Context.Service` declaration. Do not retain a standalone `FooShape` or `FooServiceShape` interface/type. -- Refer to the inferred service interface as `Foo["Service"]`, including in mechanically updated orchestration, MCP, tests, and integration harnesses. -- Export a real `make` when the module owns construction. Do not create `make = Effect.succeed(...)` solely to force `Layer.effect`. -- Export the canonical layer as `export const layer = Layer...`. `Layer.effect` is not required: use `Layer.succeed`, `Layer.scoped`, or another appropriate constructor when that matches the implementation. -- In a concrete implementation module already named for the implementation, use plain `make` and `layer` (for example `BunPtyAdapter.ts` and `NodePtyAdapter.ts`). -- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. -- `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. +- One canonical module per service in this order: imports, error and schema declarations, the `Context.Service` tag with its interface inline, `make`, then `layer`. +- Define the interface inline in `Context.Service`. Do not add a standalone `FooShape` interface; refer to the inferred type as `Foo["Service"]`. +- Export a real `make` when the module owns construction. Do not write `make = Effect.succeed(...)` only to force `Layer.effect`; use `Layer.succeed`, `Layer.scoped`, or whichever constructor matches. +- Use plain `make` and `layer` in a module named for its implementation (`BunPtyAdapter.ts`). Keep implementation-specific names when one abstract port module holds several implementations (`makeCloudflaredRelayClient`, `layerCloudflared` in `RelayClient.ts`). `infra/relay/src/db.ts` may keep its inline `Layer.succeed(RelayDb, db)`. +- When a service moves, delete the old files and update every consumer, including orchestration, MCP, tests, and integration harnesses. Do not leave compatibility re-export shims. ## Dependency acquisition and runtime boundaries -- Production service construction must acquire Effect service dependencies from the environment with `yield* Foo.Foo`, and its `make`/`layer` types must expose those requirements. Flag factories or constructors that accept `Foo["Service"]` (or a plain object whose methods return `Effect`) when that value is an implementation dependency owned by the service. Passing service instances explicitly is acceptable in tests and integration harnesses; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. -- Do not hide dependencies in module globals, closures over singleton services, or `Layer.succeed` implementations that call runtime-backed or imperative APIs. Trace helpers used by a supposedly synchronous layer far enough to verify that asynchronous services are represented in the Effect environment. -- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at explicit application/framework boundaries such as React, native callback, CLI, or HTTP adapters. Flag their use in domain services, repositories, persistence implementations, and service constructors. A clearly named imperative adapter may bridge an Effect service into a Promise API, but it must not become a dependency of another Effect service. -- Do not create per-feature managed runtimes or Atom runtimes to smuggle the same owned resource into multiple consumers. Compose the resource once in an application-owned layer/runtime and provide its context to integration runtimes. -- When acquisition can fail but a caller must retain fallback behavior, keep the failure typed in Effect rather than bypassing the layer through an imperative runtime. Model unavailability in service operations or with an explicit optional-service layer so downstream recovery remains visible and testable. -- During review, search touched code and affected call sites for service-instance parameters, `Layer.succeed`, `ManagedRuntime.make`, and `.runPromise`/`.runPromiseExit`. Verify that each occurrence is a legitimate test seam, pure value injection, or application boundary—not fake dependency injection or a hidden runtime. +- Production service construction acquires its Effect dependencies from the environment with `yield* Foo.Foo`, and `make`/`layer` types expose those requirements. Flag a factory that takes `Foo["Service"]` (or an object of Effect-returning methods) as a parameter when that value is a service dependency. Passing service instances explicitly in tests is fine; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. +- Do not hide dependencies in module globals, closures over singleton services, or a `Layer.succeed` whose implementation calls runtime-backed or imperative APIs. +- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at application or framework boundaries: React, native callbacks, CLI, HTTP adapters. Flag them in domain services, repositories, persistence, and service constructors. A named imperative adapter may bridge an Effect service into a Promise API but must not become a dependency of another Effect service. +- Do not create per-feature managed runtimes or Atom runtimes to hand the same owned resource to several consumers. Compose the resource once in an application-owned layer and provide its context to integration runtimes. +- When acquisition can fail and callers need fallback behavior, keep the failure typed in Effect (an error in the service operation or an explicit optional-service layer) rather than bypassing the layer through an imperative runtime. -## Errors and predicates +## Errors -- Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. -- `Schema.Defect()` is not a substitute for modeling a generic error: its tag, fields, or both must identify the failure structurally, and its `message` must not merely stringify an opaque cause. A semantically precise error tag may preserve a real `cause` without inventing a redundant singleton field when no additional variable context exists; still retain any real path, resource, request, or entity context available at the wrapping site. -- Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, and normalized category/status. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. Do not add a `detail` field that merely copies `cause.message` and then use it to construct the wrapper message. -- Keep direct error attributes and log annotations safe and bounded. Do not copy raw wire payloads, command arguments or output, signed URLs, credentials, query strings, fragments, selectors, or arbitrary defect text into `detail`, `reason`, `message`, or a parallel log payload. Preserve the exact underlying value only as `cause`; expose normalized categories plus lengths/counts and safe URL protocol/hostname diagnostics where useful. Logging a sanitized error must not reintroduce a removed legacy `detail` or serialized `cause` field beside it. -- When translating or wrapping a real failure, preserve the immediate underlying error itself as `cause` alongside the structural fields so the complete error chain and stack remain available. If every construction wraps a failure, `cause` should be required; make it optional only when the same error can legitimately originate without an underlying failure. -- At a translation boundary, pass through an already structured domain error when it is part of the declared target error channel. Wrap only unknown or genuinely lower-level failures. A static factory or mapper may perform this classification when it is reused and keeps the policy next to the target error type. -- Derive the wrapper's `message` exclusively from its stable structural attributes, never from `cause`, `cause.message`, or a stringified defect. Do not replace the immediate error with only `error.cause`, erase a structured upstream error into a string, or manufacture an `Error` merely to populate `cause`. Pure validation/domain errors created without an underlying failure do not need a cause. -- Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Choose one coherent model: use distinct error classes and omit the redundant discriminator when callers or messages treat the failures as genuinely different, or use one service-level error with a multi-value operation discriminator and a generic message derived from that operation when the failures share the same semantics. -- Treat an error message exposed through an HTTP/RPC response, persisted state, UI, or another caller-visible boundary as behavior. Preserve those messages during a structural refactor. Existing distinct caller-visible messages are evidence that the failures should normally remain distinct error tags without redundant singleton discriminators, rather than being collapsed into a generic operation error. -- Split semantically distinct failures into separate error classes when a `reason`, `kind`, `phase`, or similar discriminator is used to choose the user-facing message or drive caller control flow. A discriminator used only for internal diagnostics may remain a field. -- Use `Schema.Union` of error classes when a shared schema, predicate, or helper type is useful. -- Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. -- Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. -- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. -- For startup reconciliation that repairs multiple independent entities, preserve interruption rather than reducing it to a warning. Retry a transient per-entity repair before readiness, then isolate a persistent failure so one bad entity cannot abort global startup or prevent later entities from being repaired. Require tests for both the retry-success path and persistent-failure continuation. -- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. -- When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. - -## File layout and migrations - -- When combining `domain/Services/Foo.ts` and `domain/Layers/Foo.ts`, hoist the result to `domain/Foo.ts`. -- Delete the old service/layer files. Do not leave compatibility re-export shims. Mechanically update every consumer, including orchestration, MCP, tests, and integration harnesses, to the canonical path. -- Do not flag genuinely separate implementation/adapter modules merely because they remain in an implementation-oriented directory. -- Avoid substantive orchestration or MCP redesign in service-cleanup PRs. Mechanical import, layer, and `Service["Service"]` updates are expected when required to remove obsolete paths or shapes. +- Define service failures with `Schema.TaggedErrorClass` and structured attributes: operation or stage, resource path or entity identifier, normalized category or status. Derive `message` from those attributes only. Never derive it from `cause`, `cause.message`, or a stringified defect, and do not add a `detail` field that copies `cause.message`. +- When wrapping a real failure, keep the immediate underlying error as `cause` so the chain and stack survive. Make `cause` required if every construction wraps a failure. Pure validation or domain errors created without an underlying failure need no cause. +- Keep attributes and log annotations safe and bounded: no raw wire payloads, command arguments or output, signed URLs, credentials, query strings, or arbitrary defect text. Preserve the exact value only as `cause`; expose normalized categories, lengths, counts, and safe URL protocol or hostname where useful. +- At a translation boundary, pass through an already structured domain error when it is part of the target error channel; wrap only unknown or lower-level failures. Map failures where the context is known instead of wrapping a whole multi-step pipeline in one generic error. +- Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Split into separate error classes when a discriminator drives caller control flow or the user-facing message; a discriminator used only for diagnostics may stay a field. Caller-visible messages exposed through HTTP, RPC, persisted state, or UI are behavior and must survive a structural refactor. +- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`. Construct the error at the failure boundary. Keep a mapper only when it performs real normalization, passes through domain errors, or adds reusable context; when such a mapper belongs to the target error type, prefer a static factory on that class. +- Export predicates directly as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a function with the same signature. +- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including for a single tag; do not use `catchTag` or `catchIf` with a schema predicate for that. `Effect.catch` is fine when the whole error channel is handled; `catchIf` is fine for structural predicates such as a platform error code. ## Change discipline -- Preserve useful comments, invariants, and specification documentation while moving code. -- Require every new or broadened directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic to have an adjacent comment explaining why that diagnostic must be disabled there. The directive itself is not an explanation. Report a missing explanation as a concrete violation. -- Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. -- If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. -- Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, separate error classes for diagnostic-only fields, or new tests for import-only changes. +- Every new or broadened directive that disables a lint, type-checker, LSP, or static-analysis diagnostic needs an adjacent comment explaining why. The directive itself is not an explanation; a missing one is a concrete violation. +- If backend behavior changes, require focused tests that use test layers for external services only, never mocks of core business logic. Do not require new tests for mechanical refactors or import-only changes. +- Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, or separate error classes for diagnostic-only fields. ## Reporting -Report only concrete violations introduced or retained in the pull request's changed scope. Prefer precise inline comments on the smallest relevant line range and state the expected fix. A clear convention violation may fail the check. Do not fail for optional style preferences or unrelated legacy code. +Report only violations introduced by changed lines. Post each as a precise inline comment on the smallest relevant range and state the expected fix. A clear convention violation may fail the check; optional style preferences and untouched legacy code may not. -This check defaults to failure. When there are no findings, stop immediately and make the entire final response exactly `All clear` on one line. Do not add a title, explanation, punctuation, Markdown, JSON, or trailing analysis, and do not continue reasoning after deciding the review is clean. +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. From 85f2479ffe5c9d8ffa91f1bfae5234df4061292f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 20:02:29 -0700 Subject: [PATCH 09/46] refactor(mobile): style plain views with Uniwind classes instead of the theme bridge (#9322) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- .../components/FontSizeSliderRow.tsx | 13 ++--- .../threads/NewTaskContextPickerScreens.tsx | 7 +-- .../features/threads/thread-list-items.tsx | 50 ++++--------------- .../features/threads/thread-list-v2-items.tsx | 9 ++-- vite.config.ts | 2 - 5 files changed, 17 insertions(+), 64 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 41bab785ac3..9b5d8e113f8 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -12,7 +12,6 @@ import Animated, { import type { ComponentProps } from "react"; import { AppText as Text } from "../../../../components/AppText"; -import { useUniwindTheme } from "../../../../lib/useUniwindTheme"; type SymbolName = ComponentProps["name"]; @@ -36,10 +35,6 @@ export function FontSizeSliderRow(props: { readonly value: number; readonly onChange: (value: number) => void; }) { - const theme = useUniwindTheme(); - const trackColor = theme["--color-secondary-border"]; - const fillColor = theme["--color-primary"]; - const latest = useRef(props); latest.current = props; @@ -172,12 +167,12 @@ export function FontSizeSliderRow(props: { }} > (null); const selectingBranchNameRef = useRef(null); @@ -417,11 +413,10 @@ export function NewTaskBranchPickerRouteScreen() { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 6a1b752d59e..dc5beea14d2 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -276,9 +276,6 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const compact = props.variant === "compact"; - const theme = useUniwindTheme(); - const separatorColor = theme["--color-separator"]; - const pressedBackgroundColor = theme["--color-subtle"]; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const timestamp = relativeTime(pendingTask.message.createdAt); @@ -333,25 +330,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" - className="bg-screen" + className="bg-screen active:opacity-70" onPress={() => onSelectPendingTask(pendingTask)} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - - + + {pendingTask.title} @@ -376,16 +359,16 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" + className="active:bg-subtle" onPress={() => onSelectPendingTask(pendingTask)} - style={({ pressed }) => ({ - backgroundColor: pressed ? pressedBackgroundColor : "transparent", + style={{ borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, - })} + }} > @@ -455,7 +438,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const [hovered, setHovered] = useRecyclingState(false); const theme = useUniwindTheme(); - const separatorColor = theme["--color-separator"]; const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; @@ -589,28 +571,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityHint="Swipe left for archive and delete actions" accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" - className="bg-screen" + className="bg-screen active:opacity-70" onPress={() => { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - - + + {thread.title} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 1b39ff6bd7a..38c7ca04a75 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -214,9 +214,6 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const theme = useUniwindTheme(); - const drawerColor = theme["--color-drawer"]; - const pressedBackgroundColor = theme["--color-subtle"]; const sidebarPane = props.pane === "sidebar"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; @@ -291,15 +288,15 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" + className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined} onPress={() => onSelectPendingTask(pendingTask)} style={ sidebarPane - ? ({ pressed }) => ({ - backgroundColor: pressed ? pressedBackgroundColor : drawerColor, + ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, paddingHorizontal: 12, paddingVertical: 10, - }) + } : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > diff --git a/vite.config.ts b/vite.config.ts index c616998d529..d3aec8e9764 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -144,9 +144,7 @@ export default defineConfig({ "apps/mobile/src/features/review/useNativeReviewDiffBridge.ts", "apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx", "apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx", - "apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx", "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx", - "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx", "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx", "apps/mobile/src/features/threads/ThreadComposer.tsx", "apps/mobile/src/features/threads/ThreadFeed.tsx", From 66419a1d1b80110c9702edae641bddcc2888fc98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 20:08:32 -0700 Subject: [PATCH 10/46] fix(dev): share dev servers on the loopback Vite actually binds (#9324) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- scripts/lib/dev-share.test.ts | 21 ++++++++++++++++++++- scripts/lib/dev-share.ts | 12 +++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index b2dfba3585e..2b133e456c4 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -27,11 +27,16 @@ const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); * test set the outcome of the `off` (pre-clear) and `serve` calls separately — * they are the same subcommand and are told apart by the trailing `off`. */ -const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => +const spawnerLayer = (input: { + readonly off?: CallResult; + readonly serve?: CallResult; + readonly calls?: Array>; +}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = "args" in command ? (command.args as ReadonlyArray) : []; + input.calls?.push(args); const result: CallResult = args.includes("status") ? { exitCode: 0 } : args.includes("off") @@ -102,6 +107,20 @@ describe("shareDevServer", () => { }), ); + // Vite binds `localhost`, which modern Node resolves to `::1` first, so a + // 127.0.0.1 target would proxy to a loopback nothing listens on. + it.effect("proxies to the localhost name Vite binds, not 127.0.0.1", () => + Effect.gen(function* () { + const calls: Array> = []; + yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 0 }, calls })), + ); + + const serveCall = calls.find((args) => args.includes("--bg")); + assert.deepEqual(serveCall, ["serve", "--bg", "--https=5788", "http://localhost:5788"]); + }), + ); + // The stale-mapping clear runs before serve, so a failure here leaves the // port serving nothing. Saying only "serve failed" would let an operator // assume their previous mapping survived. diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 0f843b3ba91..e3fb884eb83 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -195,7 +195,17 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in }); } - yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + // Proxy to the hostname Vite binds rather than the package default of + // 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1` + // first, so it only binds the IPv6 loopback and a 127.0.0.1 target has + // nothing behind it (tailscale answers 502). Passing `localhost` lets the + // tailscale proxy resolve it the same way Node did. Not a literal `[::1]`: + // tailscale rejects that form. + yield* ensureTailscaleServe({ + localPort: input.webPort, + servePort: input.webPort, + localHost: "localhost", + }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({ From 12f1fc427efad4b683835a72bda6f74bebb5d641 Mon Sep 17 00:00:00 2001 From: Tristan Manchester <108270628+tristanmanchester@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:11:44 +0200 Subject: [PATCH 11/46] fix(web): line up the titlebar wordmark label and version pill (#9255) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/sidebar/SidebarChrome.tsx | 2 +- apps/web/src/components/ui/badge.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8a140db0584..55c6863ec1a 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -94,7 +94,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index 67ae978f59a..32275cc4161 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -20,7 +20,9 @@ const badgeVariants = cva( default: "h-5.5 min-w-5.5 px-[calc(--spacing(1)-1px)] text-sm sm:h-4.5 sm:min-w-4.5 sm:text-xs", lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm", - sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs sm:h-4 sm:min-w-4 sm:text-[.625rem]", + // leading-none: with the inherited fractional leading the rounded font metrics + // leave the label sitting high in the fixed-height box, worse under renderer zoom. + sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs leading-none sm:h-4 sm:min-w-4 sm:text-[.625rem]", }, variant: { default: "bg-primary text-primary-foreground [button&,a&]:hover:bg-primary/90", From 9409dd20a9fbce491d49d09c79b289d8fb8bfe3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 20:16:25 -0700 Subject: [PATCH 12/46] fix(web): make the diff layout toggle a persisted setting (#9326) Co-authored-by: Claude Code --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/clientPersistenceStorage.test.ts | 13 ++++++ apps/web/src/components/DiffPanel.tsx | 12 ++--- .../pullRequest/PullRequestCodeTab.tsx | 21 +++------ .../components/settings/SettingsPanels.tsx | 44 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/diffPanelStore.test.ts | 21 --------- apps/web/src/diffPanelStore.ts | 7 --- packages/contracts/src/settings.ts | 6 +++ 9 files changed, 83 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 0bddf23e46e..0976ef48975 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -29,6 +29,7 @@ const clientSettings: ClientSettings = { contextWindowMeterEnabled: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + diffLayout: "stacked", environmentIdentificationMode: "artwork", favorites: [], fontFamilyCode: "", diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 8f849a6e7b3..db69fe96c80 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -90,4 +90,17 @@ describe("clientPersistenceStorage", () => { expect(settings).not.toHaveProperty("chatWordWrap"); expect(settings).not.toHaveProperty("diffWordWrap"); }); + + it("keeps the diff layout across reloads and defaults it to stacked", async () => { + const testWindow = getTestWindow(); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); + expect(readBrowserClientSettings()?.diffLayout).toBe("stacked"); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffLayout: "split" }); + expect(readBrowserClientSettings()?.diffLayout).toBe("split"); + }); }); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index acdc54bb2f4..c55eaa09478 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -44,7 +44,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -108,8 +108,8 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); - const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); + const diffLayout = settings.diffLayout; + const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); @@ -768,11 +768,11 @@ export default function DiffPanel({ { const next = value[0]; if (next === "stacked" || next === "split") { - setDiffRenderMode(next); + updateClientSettings({ diffLayout: next }); } }} > @@ -959,7 +959,7 @@ export default function DiffPanel({ ); }} options={{ - diffStyle: diffRenderMode === "split" ? "split" : "unified", + diffStyle: diffLayout === "split" ? "split" : "unified", lineDiffType: "none", overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d9f1e7af716..ebae8847bf3 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -26,7 +26,7 @@ import { import { useAtomRefresh } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { useClientSettings } from "~/hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; @@ -217,7 +217,8 @@ export function PullRequestCodeTab({ const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState(null); - const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); + const diffLayout = settings.diffLayout; + const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [selectedLines, setSelectedLines] = useState<{ id: string; @@ -740,7 +741,7 @@ export function PullRequestCodeTab({ const diffViewOptions = useMemo( () => ({ - diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const), + diffStyle: diffLayout === "split" ? ("split" as const) : ("unified" as const), lineDiffType: "none" as const, overflow: wordWrap ? ("wrap" as const) : ("scroll" as const), theme: resolveDiffThemeName(resolvedTheme), @@ -757,15 +758,7 @@ export function PullRequestCodeTab({ onGutterUtilityClick: beginComment, onLineSelectionEnd: beginComment, }), - [ - diffRenderMode, - wordWrap, - resolvedTheme, - loadDiffFiles, - canCommentOnLines, - draft, - beginComment, - ], + [diffLayout, wordWrap, resolvedTheme, loadDiffFiles, canCommentOnLines, draft, beginComment], ); const runThreadCommand = useCallback( @@ -1122,11 +1115,11 @@ export function PullRequestCodeTab({ { const next = value[0]; if (next === "stacked" || next === "split") { - setDiffRenderMode(next); + updateClientSettings({ diffLayout: next }); } }} > diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3c5f6d7037a..7fc8153a24f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -19,6 +19,7 @@ import { import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, + type DiffLayout, type EnvironmentIdentificationMode, MAX_APPEARANCE_CONTRAST, MAX_CODE_FONT_SIZE, @@ -169,6 +170,11 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const DIFF_LAYOUT_LABELS: Record = { + stacked: "Stacked", + split: "Split", +}; + const QUIT_CONFIRMATION_MODE_LABELS: Record = { direct: "Direct", hold: "Hold", @@ -527,6 +533,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? ["Diff layout"] : []), ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? ["Proactive panels"] : []), @@ -593,6 +600,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.diffLayout, settings.proactivePanelsEnabled, settings.environmentIdentificationMode, settings.contextWindowMeterEnabled, @@ -689,6 +697,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, @@ -2223,6 +2232,41 @@ export function GeneralSettingsPanel() { } /> + updateSettings({ diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout })} + /> + ) : null + } + control={ + + } + /> + { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - diffRenderMode: "stacked", }), ); - it("keeps the selected render mode in panel and persisted state", async () => { - useDiffPanelStore.getState().setDiffRenderMode("split"); - - expect(useDiffPanelStore.getState().diffRenderMode).toBe("split"); - expect( - useDiffPanelStore.persist.getOptions().partialize?.(useDiffPanelStore.getState()), - ).toMatchObject({ diffRenderMode: "split" }); - - const { name, storage } = useDiffPanelStore.persist.getOptions(); - if (!name) throw new Error("Expected diff panel persistence to have a storage name"); - const persisted = await storage?.getItem(name); - expect(persisted?.state).toMatchObject({ diffRenderMode: "split" }); - - useDiffPanelStore.setState({ diffRenderMode: "stacked" }); - if (persisted) await storage?.setItem(name, persisted); - await useDiffPanelStore.persist.rehydrate(); - - expect(useDiffPanelStore.getState().diffRenderMode).toBe("split"); - }); - it("defaults each thread to branch changes when the working tree is clean", () => { expect( selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index ebb560a2383..56b5ad23fec 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -10,16 +10,12 @@ export type DiffPanelSelection = | { kind: "unstaged" } | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; -export type DiffRenderMode = "stacked" | "split"; - const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; - diffRenderMode: DiffRenderMode; - setDiffRenderMode: (mode: DiffRenderMode) => void; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; @@ -37,8 +33,6 @@ export const useDiffPanelStore = create()( (set) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - diffRenderMode: "stacked", - setDiffRenderMode: (diffRenderMode) => set({ diffRenderMode }), selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -132,7 +126,6 @@ export const useDiffPanelStore = create()( partialize: (state) => ({ byThreadKey: state.byThreadKey, branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, - diffRenderMode: state.diffRenderMode, }), }, ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 836c3c19d4c..fd3b3f35dd5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -31,6 +31,10 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const DiffLayout = Schema.Literals(["stacked", "split"]); +export type DiffLayout = typeof DiffLayout.Type; +export const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -242,6 +246,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))), environmentIdentificationMode: EnvironmentIdentificationMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), ), @@ -1007,6 +1012,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), From 3b2de9da1c8763602e283e8d14b41b5d57a9d0c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 20:35:09 -0700 Subject: [PATCH 13/46] chore: dedupe lightningcss and tailwind node bindings (#9331) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/mobile/generated-uniwind-themes.css | 360 +++++++-------- pnpm-lock.yaml | 552 ++++++----------------- pnpm-workspace.yaml | 12 + 3 files changed, 332 insertions(+), 592 deletions(-) diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 7f8f9c16afc..8ba542165f1 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -21,23 +21,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -81,23 +81,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -115,7 +115,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -206,23 +206,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -331,23 +331,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -365,7 +365,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -456,23 +456,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -581,23 +581,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -615,7 +615,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -706,23 +706,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -831,23 +831,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -865,7 +865,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -956,23 +956,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -1081,23 +1081,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -1115,7 +1115,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -1206,23 +1206,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -1331,23 +1331,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -1365,7 +1365,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6789d252f92..ab8d6d2500d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,11 +71,16 @@ overrides: '@expo/metro-config': 57.0.12 expo-constants: 57.0.16 '@pierre/diffs>@shikijs/transformers': ^4.2.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + '@tailwindcss/vite': 4.3.3 '@types/node': 24.12.4 effect: 4.0.0-beta.103 expo-router: 57.0.17 expo-sharing>@expo/config-plugins: 57.0.9 expo-sharing>@expo/config-types: 57.0.2 + lightningcss: 1.33.0 + tailwindcss: 4.3.3 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 @@ -184,8 +189,8 @@ importers: specifier: 26.15.6 version: 26.15.6(electron-builder-squirrel-windows@26.15.6) tailwindcss: - specifier: ^4.0.0 - version: 4.3.0 + specifier: 4.3.3 + version: 4.3.3 vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -444,7 +449,7 @@ importers: version: 3.6.0 uniwind: specifier: 1.11.0 - version: 1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.3) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 @@ -459,8 +464,8 @@ importers: specifier: ~57.0.9 version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) tailwindcss: - specifier: ^4.0.0 - version: 4.3.0 + specifier: 4.3.3 + version: 4.3.3 typescript: specifier: 'catalog:' version: 6.0.3 @@ -656,8 +661,8 @@ importers: specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) '@tailwindcss/vite': - specifier: ^4.0.0 - version: 4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + specifier: 4.3.3 + version: 4.3.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.161.0 version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) @@ -689,8 +694,8 @@ importers: specifier: ^1.8.1 version: 1.8.1 tailwindcss: - specifier: ^4.0.0 - version: 4.3.0 + specifier: 4.3.3 + version: 4.3.3 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' @@ -4437,142 +4442,69 @@ packages: '@tabler/icons@3.44.0': resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} - '@tailwindcss/node@4.3.0': - resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - - '@tailwindcss/node@4.3.2': - resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - '@tailwindcss/oxide-android-arm64@4.3.0': - resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-android-arm64@4.3.2': - resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.3.0': - resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-arm64@4.3.2': - resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.0': - resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.3.2': - resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.0': - resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-freebsd-x64@4.3.2': - resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': - resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': - resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-arm64-musl@4.3.2': - resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-gnu@4.3.2': - resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-musl@4.3.2': - resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-wasm32-wasi@4.3.2': - resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4583,40 +4515,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': - resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.2': - resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - '@tailwindcss/oxide@4.3.0': - resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} - engines: {node: '>= 20'} - - '@tailwindcss/oxide@4.3.2': - resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.3.0': - resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -6328,12 +6244,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.21.6: - resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} - engines: {node: '>=10.13.0'} - - enhanced-resolve@5.22.1: - resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -7522,146 +7434,78 @@ packages: lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.1: - resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.1: - resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.30.1: - resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.30.1: - resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.1: - resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.30.1: - resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.1: - resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.1: - resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.30.1: - resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.30.1: - resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.30.1: - resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} - engines: {node: '>= 12.0.0'} - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} locate-path@3.0.0: @@ -9591,11 +9435,8 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.3.0: - resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - - tailwindcss@4.3.2: - resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -9862,7 +9703,7 @@ packages: metro-transform-worker: '*' react: '>=19.0.0' react-native: '>=0.81.0' - tailwindcss: '>=4' + tailwindcss: 4.3.3 peerDependenciesMeta: '@expo/metro-config': optional: true @@ -12439,7 +12280,7 @@ snapshots: glob: 13.0.6 hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 - lightningcss: 1.32.0 + lightningcss: 1.33.0 picomatch: 4.0.4 postcss: 8.5.15 resolve-from: 5.0.0 @@ -14267,133 +14108,72 @@ snapshots: '@tabler/icons@3.44.0': {} - '@tailwindcss/node@4.3.0': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.22.1 - jiti: 2.7.0 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.3.0 - - '@tailwindcss/node@4.3.2': + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.6 + enhanced-resolve: 5.24.5 jiti: 2.7.0 - lightningcss: 1.32.0 + lightningcss: 1.33.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.2 - - '@tailwindcss/oxide-android-arm64@4.3.0': - optional: true - - '@tailwindcss/oxide-android-arm64@4.3.2': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.3.0': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.3.2': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.3.0': - optional: true + tailwindcss: 4.3.3 - '@tailwindcss/oxide-darwin-x64@4.3.2': + '@tailwindcss/oxide-android-arm64@4.3.3': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.0': + '@tailwindcss/oxide-darwin-arm64@4.3.3': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.2': + '@tailwindcss/oxide-darwin-x64@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + '@tailwindcss/oxide-freebsd-x64@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + '@tailwindcss/oxide-linux-x64-musl@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + '@tailwindcss/oxide-wasm32-wasi@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.0': + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.2': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.3.2': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.3.2': - optional: true - - '@tailwindcss/oxide@4.3.0': + '@tailwindcss/oxide@4.3.3': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-x64': 4.3.0 - '@tailwindcss/oxide-freebsd-x64': 4.3.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-x64-musl': 4.3.0 - '@tailwindcss/oxide-wasm32-wasi': 4.3.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - - '@tailwindcss/oxide@4.3.2': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.2 - '@tailwindcss/oxide-darwin-arm64': 4.3.2 - '@tailwindcss/oxide-darwin-x64': 4.3.2 - '@tailwindcss/oxide-freebsd-x64': 4.3.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 - '@tailwindcss/oxide-linux-x64-musl': 4.3.2 - '@tailwindcss/oxide-wasm32-wasi': 4.3.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - - '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': - dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.3': {} @@ -14831,7 +14611,7 @@ snapshots: dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 - lightningcss: 1.32.0 + lightningcss: 1.33.0 postcss: 8.5.15 optionalDependencies: '@types/node': 24.12.4 @@ -16173,12 +15953,7 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.21.6: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - enhanced-resolve@5.22.1: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -17690,99 +17465,54 @@ snapshots: transitivePeerDependencies: - supports-color - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.30.1: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-darwin-x64@1.30.1: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-freebsd-x64@1.30.1: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.1: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.30.1: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.30.1: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.30.1: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.30.1: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.30.1: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.30.1: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.30.1: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-darwin-arm64: 1.30.1 - lightningcss-darwin-x64: 1.30.1 - lightningcss-freebsd-x64: 1.30.1 - lightningcss-linux-arm-gnueabihf: 1.30.1 - lightningcss-linux-arm64-gnu: 1.30.1 - lightningcss-linux-arm64-musl: 1.30.1 - lightningcss-linux-x64-gnu: 1.30.1 - lightningcss-linux-x64-musl: 1.30.1 - lightningcss-win32-arm64-msvc: 1.30.1 - lightningcss-win32-x64-msvc: 1.30.1 - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 locate-path@3.0.0: dependencies: @@ -20455,9 +20185,7 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss@4.3.0: {} - - tailwindcss@4.3.2: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -20697,17 +20425,17 @@ snapshots: universalify@2.0.1: {} - uniwind@1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.3): dependencies: - '@tailwindcss/node': 4.3.2 - '@tailwindcss/oxide': 4.3.2 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 culori: 4.0.2 - lightningcss: 1.30.1 + lightningcss: 1.33.0 metro: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-cache: 0.84.5 react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - tailwindcss: 4.3.0 + tailwindcss: 4.3.3 optionalDependencies: '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6) metro-transform-worker: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff38222efb1..91d14d961d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,10 +43,15 @@ catalog: "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.10 + "@tailwindcss/node": 4.3.3 + "@tailwindcss/oxide": 4.3.3 + "@tailwindcss/vite": 4.3.3 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 effect: 4.0.0-beta.103 jose: 6.2.2 + lightningcss: 1.33.0 + tailwindcss: 4.3.3 typescript: ~6.0.3 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 vite-plus: 0.2.2 @@ -121,11 +126,18 @@ overrides: "@expo/metro-config": 57.0.12 expo-constants: 57.0.16 "@pierre/diffs>@shikijs/transformers": ^4.2.0 + # Keep one copy of these native-binary packages. lightningcss ships 11 platform binaries per + # version, and the workspaces' tailwindcss ^4 ranges drift into two 4.3.x copies of the family. + "@tailwindcss/node": "catalog:" + "@tailwindcss/oxide": "catalog:" + "@tailwindcss/vite": "catalog:" "@types/node": "catalog:" effect: "catalog:" expo-router: 57.0.17 "expo-sharing>@expo/config-plugins": 57.0.9 "expo-sharing>@expo/config-types": 57.0.2 + lightningcss: "catalog:" + tailwindcss: "catalog:" vite: "catalog:" yaml: "catalog:" From f5fbb1bcb0db378c61addd5b49ef2d95bd888168 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 20:40:31 -0700 Subject: [PATCH 14/46] chore: upgrade vite-plus to 0.3.0 (#9327) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- .../src/backend/DesktopBackendManager.ts | 16 +- .../src/features/shortcuts/appShortcuts.ts | 20 +- .../terminal/ThreadTerminalRouteScreen.tsx | 18 +- .../src/features/threads/threadListV2.ts | 34 +- .../mobile/src/persistence/mobile-database.ts | 15 +- apps/server/src/auth/EnvironmentAuth.ts | 10 +- apps/server/src/cloud/CliTokenManager.ts | 7 +- .../src/diagnostics/ProcessDiagnostics.ts | 28 +- apps/server/src/mcp/McpHttpServer.ts | 47 +- .../Layers/ProjectionSnapshotQuery.ts | 110 +- apps/server/src/preview/Manager.ts | 16 +- apps/server/src/processRunner.ts | 12 +- .../src/provider/Layers/ClaudeAdapter.ts | 7 +- .../src/provider/Layers/CodexAdapter.test.ts | 26 +- .../provider/Layers/ProviderService.test.ts | 27 +- .../src/provider/providerMaintenanceRunner.ts | 10 +- .../AzureDevOpsPullRequestProvider.ts | 48 +- .../BitbucketPullRequestProvider.ts | 20 +- .../pullRequest/GitHubPullRequestProvider.ts | 162 ++- .../pullRequest/GitLabPullRequestProvider.ts | 70 +- .../src/pullRequest/PullRequestService.ts | 205 ++-- .../src/pullRequest/gitHubPullRequestJson.ts | 46 +- .../DesktopTelemetryReceiver.ts | 36 +- .../resourceTelemetry/ResourceTelemetry.ts | 10 +- apps/web/src/lib/attachmentUploadQueue.ts | 2 +- .../src/connection/supervisor.ts | 60 +- .../client-runtime/src/rpc/session.test.ts | 13 +- .../src/_generated/schema.gen.ts | 45 +- packages/shared/src/qrCode.ts | 2 +- pnpm-lock.yaml | 985 +++++++++++------- pnpm-workspace.yaml | 4 +- 31 files changed, 1137 insertions(+), 974 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 60bfe780ad6..436c0c08e4e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -663,15 +663,13 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Ref.update(state, withActiveRun(runId, f)); const snapshot = Ref.get(state).pipe( - Effect.map( - (current): DesktopBackendSnapshot => ({ - desiredRunning: current.desiredRunning, - ready: current.ready, - activePid: activePid(current.active), - restartAttempt: current.restartAttempt, - restartScheduled: Option.isSome(current.restartFiber), - }), - ), + Effect.map((current): DesktopBackendSnapshot => ({ + desiredRunning: current.desiredRunning, + ready: current.ready, + activePid: activePid(current.active), + restartAttempt: current.restartAttempt, + restartScheduled: Option.isSome(current.restartFiber), + })), ); const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); diff --git a/apps/mobile/src/features/shortcuts/appShortcuts.ts b/apps/mobile/src/features/shortcuts/appShortcuts.ts index 49e9d0999af..3d2d9614db4 100644 --- a/apps/mobile/src/features/shortcuts/appShortcuts.ts +++ b/apps/mobile/src/features/shortcuts/appShortcuts.ts @@ -122,16 +122,14 @@ export function buildShortcutActions(recents: ReadonlyArray ({ - // The encoded href doubles as the launcher id: URI-encoding makes the - // env/thread join unambiguous (a plain `-` join lets different pairs - // collide and overwrite each other's launcher slots). - id: `thread:${threadShortcutHref(thread)}`, - title: threadShortcutLabel(thread), - icon: SHORTCUT_ICON, - params: { href: threadShortcutHref(thread) }, - }), - ), + ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map((thread): Action => ({ + // The encoded href doubles as the launcher id: URI-encoding makes the + // env/thread join unambiguous (a plain `-` join lets different pairs + // collide and overwrite each other's launcher slots). + id: `thread:${threadShortcutHref(thread)}`, + title: threadShortcutLabel(thread), + icon: SHORTCUT_ICON, + params: { href: threadShortcutHref(thread) }, + })), ]; } diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f370401e8ec..a51e084efc9 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -948,16 +948,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, ], }, - ...terminalMenuSessions.map( - (session): MenuAction => ({ - id: `terminal-session:${session.terminalId}`, - title: session.displayLabel, - subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] - .filter(Boolean) - .join(" · "), - state: session.terminalId === terminalId ? ("on" as const) : undefined, - }), - ), + ...terminalMenuSessions.map((session): MenuAction => ({ + id: `terminal-session:${session.terminalId}`, + title: session.displayLabel, + subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] + .filter(Boolean) + .join(" · "), + state: session.terminalId === terminalId ? ("on" as const) : undefined, + })), { id: "terminal-new", title: "Open new terminal", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f17b46b1d18..2b44851f930 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -255,25 +255,21 @@ export function buildThreadListV2ListItems(input: { readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string; }): ThreadListV2ListItem[] { - const threadItems = input.items.map( - (item): ThreadListV2ListItem => ({ - type: "v2-thread", - key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, - item, - snoozeWakeLabelText: - item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined - ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) - : undefined, - }), - ); - const pendingItems = input.pendingTasks.map( - (pendingTask, index): ThreadListV2ListItem => ({ - type: "v2-pending", - key: `v2-pending:${pendingTask.message.messageId}`, - pendingTask, - showPendingDivider: index === 0, - }), - ); + const threadItems = input.items.map((item): ThreadListV2ListItem => ({ + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + snoozeWakeLabelText: + item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined + ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) + : undefined, + })); + const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ + type: "v2-pending", + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + showPendingDivider: index === 0, + })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 3d50a2dbc55..71876932b78 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -365,14 +365,13 @@ const makeAvailable = Effect.gen(function* () { }).pipe( Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)), Effect.mapError(databaseError("inspect-caches")), - Effect.map( - (rows): ReadonlyArray => - rows.map((row) => ({ - environmentId: row.environmentId as EnvironmentId, - kind: row.kind, - recordCount: row.recordCount, - payloadBytes: row.payloadBytes, - })), + Effect.map((rows): ReadonlyArray => + rows.map((row) => ({ + environmentId: row.environmentId as EnvironmentId, + kind: row.kind, + recordCount: row.recordCount, + payloadBytes: row.payloadBytes, + })), ), ), loadPreferencesJson: Effect.tryPromise({ diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 08838cb7b78..2d0f02274de 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -923,12 +923,10 @@ export const make = Effect.gen(function* () { const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => - clientSessions.map( - (clientSession): AuthClientSession => ({ - ...clientSession, - current: clientSession.sessionId === currentSessionId, - }), - ), + clientSessions.map((clientSession): AuthClientSession => ({ + ...clientSession, + current: clientSession.sessionId === currentSessionId, + })), ), Effect.withSpan("EnvironmentAuth.listClientSessions"), ); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index b0867b62f6c..c4443a7301c 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -89,9 +89,10 @@ export const waitForLoopbackAuthorization = Effect.fn( while (true) { const result = yield* Effect.raceFirst( input.callback.pipe( - Effect.map( - (code): LoopbackAuthorizationResult => ({ _tag: "AuthorizationCode", code }), - ), + Effect.map((code): LoopbackAuthorizationResult => ({ + _tag: "AuthorizationCode", + code, + })), ), readLoopbackAuthorizationAction(terminalInput), ); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 8aeb7ba2471..fecf457046d 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -61,21 +61,19 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { Effect.map((snapshot) => { const processes = snapshot.processes .filter((entry) => canSignalCategory(entry.category)) - .map( - (entry): ServerProcessDiagnosticsEntry => ({ - pid: entry.identity.pid, - startTimeMs: entry.identity.startTimeMs, - ppid: entry.ppid, - pgid: Option.none(), - status: entry.status || "Unknown", - cpuPercent: entry.cpuPercent, - rssBytes: entry.residentBytes, - elapsed: formatElapsed(entry.runTimeMs), - command: entry.command || entry.name || "unknown", - depth: Math.max(0, entry.depth - 1), - childPids: entry.childPids, - }), - ); + .map((entry): ServerProcessDiagnosticsEntry => ({ + pid: entry.identity.pid, + startTimeMs: entry.identity.startTimeMs, + ppid: entry.ppid, + pgid: Option.none(), + status: entry.status || "Unknown", + cpuPercent: entry.cpuPercent, + rssBytes: entry.residentBytes, + elapsed: formatElapsed(entry.runTimeMs), + command: entry.command || entry.name || "unknown", + depth: Math.max(0, entry.depth - 1), + childPids: entry.childPids, + })); return { serverPid: process.pid, readAt: snapshot.readAt, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 87975a49de2..44ca928e63b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -64,30 +64,29 @@ export const normalizeMcpHttpResponse = ( }; const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( - Effect.map( - (registry): McpAuthMiddleware => - Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { - const request = yield* HttpServerRequest.HttpServerRequest; - const authorization = request.headers.authorization; - const token = - authorization?.startsWith("Bearer ") === true - ? authorization.slice("Bearer ".length).trim() - : ""; - const invocation = yield* registry.resolve(token); - if (!invocation) { - // Without this the only symptom of a dead credential is the agent - // quietly losing the whole `t3-code` toolkit for the rest of its - // session, with nothing on the server to explain why. - yield* Effect.logWarning("rejected MCP request with an unusable credential", { - reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", - }); - return unauthorized; - } - return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.map(normalizeMcpHttpResponse), - ); - }), + Effect.map((registry): McpAuthMiddleware => + Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) { + // Without this the only symptom of a dead credential is the agent + // quietly losing the whole `t3-code` toolkit for the rest of its + // session, with nothing on the server to explain why. + yield* Effect.logWarning("rejected MCP request with an unusable credential", { + reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", + }); + return unauthorized; + } + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }), ), Effect.withSpan("McpHttpServer.makeAuthMiddleware"), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c9d356a973d..ee173dd2d24 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -2391,42 +2391,40 @@ pending_approval_requests AS ( ) : Result.failVoid, ), - threads: threadRows.map( - (row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - }), - ), + threads: threadRows.map((row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -2470,12 +2468,10 @@ pending_approval_requests AS ( "ProjectionSnapshotQuery.getCounts:decodeRow", ), ), - Effect.map( - (row): ProjectionSnapshotCounts => ({ - projectCount: row.projectCount, - threadCount: row.threadCount, - }), - ), + Effect.map((row): ProjectionSnapshotCounts => ({ + projectCount: row.projectCount, + threadCount: row.threadCount, + })), ); const getEventReplayStats: ProjectionSnapshotQueryShape["getEventReplayStats"] = (input) => @@ -2486,12 +2482,10 @@ pending_approval_requests AS ( "ProjectionSnapshotQuery.getEventReplayStats:decodeRow", ), ), - Effect.map( - (row): ProjectionEventReplayStats => ({ - eventCount: row.eventCount, - payloadBytes: row.payloadBytes, - }), - ), + Effect.map((row): ProjectionEventReplayStats => ({ + eventCount: row.eventCount, + payloadBytes: row.payloadBytes, + })), ); const searchThreads: ProjectionSnapshotQueryShape["searchThreads"] = Effect.fn( @@ -2616,17 +2610,15 @@ pending_approval_requests AS ( projectId: threadRow.value.projectId, workspaceRoot: threadRow.value.workspaceRoot, worktreePath: threadRow.value.worktreePath, - checkpoints: checkpointRows.map( - (row): OrchestrationCheckpointSummary => ({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }), - ), + checkpoints: checkpointRows.map((row): OrchestrationCheckpointSummary => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + })), }); }); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index a5b1f4da8db..3c0169eba40 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -433,15 +433,13 @@ export const make = Effect.gen(function* PreviewManagerMake() { const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")( function* (input) { return yield* SynchronizedRef.get(stateRef).pipe( - Effect.map( - (state): PreviewListResult => ({ - sessions: sessionsForThread(state, input.threadId) - .map((s) => s.snapshot) - .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), - serverEpoch, - revision: state.revision, - }), - ), + Effect.map((state): PreviewListResult => ({ + sessions: sessionsForThread(state, input.threadId) + .map((s) => s.snapshot) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + serverEpoch, + revision: state.revision, + })), ); }, ); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 16b5625d469..c245b041152 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -239,13 +239,11 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { }); }, ), - Effect.map( - (state): CollectedUint8StreamText => ({ - ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), - bytes: state.bytes, - truncated: false, - }), - ), + Effect.map((state): CollectedUint8StreamText => ({ + ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), + bytes: state.bytes, + truncated: false, + })), ); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 07739146b07..18b5e996395 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -186,9 +186,10 @@ function toSessionPermissionUpdates( toolName: string, suggestions: ReadonlyArray | undefined, ): Array { - const sessionScoped = (suggestions ?? []).map( - (suggestion): PermissionUpdate => ({ ...suggestion, destination: "session" }), - ); + const sessionScoped = (suggestions ?? []).map((suggestion): PermissionUpdate => ({ + ...suggestion, + destination: "session", + })); if (sessionScoped.length > 0) { return sessionScoped; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index f01192f8d70..9809ed767be 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -84,24 +84,22 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); - public readonly interruptTurnImpl = vi.fn( - (_turnId?: TurnId): Promise => Promise.resolve(undefined), + public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise => + Promise.resolve(undefined), ); - public readonly readThreadImpl = vi.fn( - (): Promise => - Promise.resolve({ - threadId: "provider-thread-1", - turns: [], - }), + public readonly readThreadImpl = vi.fn((): Promise => + Promise.resolve({ + threadId: "provider-thread-1", + turns: [], + }), ); - public readonly rollbackThreadImpl = vi.fn( - (_numTurns: number): Promise => - Promise.resolve({ - threadId: "provider-thread-1", - turns: [], - }), + public readonly rollbackThreadImpl = vi.fn((_numTurns: number): Promise => + Promise.resolve({ + threadId: "provider-thread-1", + turns: [], + }), ); public readonly uploadFeedbackImpl = vi.fn((_reason?: string) => diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 83f22d475b4..a81528e1814 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -188,20 +188,18 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { ): Effect.Effect => Effect.void, ); - const stopSession = vi.fn( - (threadId: ThreadId): Effect.Effect => - Effect.sync(() => { - sessions.delete(threadId); - }), + const stopSession = vi.fn((threadId: ThreadId): Effect.Effect => + Effect.sync(() => { + sessions.delete(threadId); + }), ); - const listSessions = vi.fn( - (): Effect.Effect> => - Effect.sync(() => Array.from(sessions.values())), + const listSessions = vi.fn((): Effect.Effect> => + Effect.sync(() => Array.from(sessions.values())), ); - const hasSession = vi.fn( - (threadId: ThreadId): Effect.Effect => Effect.succeed(sessions.has(threadId)), + const hasSession = vi.fn((threadId: ThreadId): Effect.Effect => + Effect.succeed(sessions.has(threadId)), ); const readThread = vi.fn( @@ -235,11 +233,10 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), ); - const stopAll = vi.fn( - (): Effect.Effect => - Effect.sync(() => { - sessions.clear(); - }), + const stopAll = vi.fn((): Effect.Effect => + Effect.sync(() => { + sessions.clear(); + }), ); const adapter: ProviderAdapterShape = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 3c114dd83d8..4ac4401ffe2 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -263,12 +263,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { concurrency: "unbounded", }, ).pipe( - Effect.map( - (verifiedProviders): VerifiedProviderRefresh => ({ - providers, - verifiedProviders, - }), - ), + Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ + providers, + verifiedProviders, + })), Effect.catchCause((cause) => Effect.logWarning("Provider post-update version verification failed", { provider, diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 0062e58a4c8..8461e57d568 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -157,23 +157,21 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - (pullRequest): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), - body: pullRequest.body, - changedFiles: 0, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - reviewers: pullRequest.reviewers, - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: false }, - viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, - autoMergeEnabled: pullRequest.autoMergeEnabled, - ...(pullRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: pullRequest.autoMergeMethod }), - }), - ), + Effect.map((pullRequest): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles: 0, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, + ...(pullRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: pullRequest.autoMergeMethod }), + })), ), getChangeRequestActivity: (input) => @@ -187,15 +185,13 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => ({ comments: [], truncated: true })), ) ).pipe( - Effect.map( - (conversation): ProviderChangeRequestActivity => ({ - comments: conversation.comments, - commentCount: conversation.comments.length, - commentsTruncated: conversation.truncated, - reviewThreads: [], - commits: [], - }), - ), + Effect.map((conversation): ProviderChangeRequestActivity => ({ + comments: conversation.comments, + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + reviewThreads: [], + commits: [], + })), ), ), ), diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 70c6184ada0..47d41eeee6d 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -203,17 +203,15 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ - comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commentCount: comments.comments.length + pullRequest.reviews.length, - commentsTruncated: comments.truncated, - reviewThreads: comments.threads, - commits, - }), - ), + Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + })), ); }, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5315dd2ed5d..e75ef3547c0 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -144,14 +144,12 @@ function withWorkflowApprovals( } const approvalChecks = runs .filter((run) => !representedRunIds.has(run.id)) - .map( - (run): PullRequestCheck => ({ - name: run.name, - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: run.url, - }), - ); + .map((run): PullRequestCheck => ({ + name: run.name, + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: run.url, + })); return [ ...checks, ...approvalChecks, @@ -363,38 +361,34 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ - ...detail.pullRequest, - checks: withWorkflowApprovals( - detail.pullRequest.checks, - detail.workflowApprovals.runs, - detail.workflowApprovals.unavailable, - ), - ...(detail.workflowApprovals.unavailable - ? {} - : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), - reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ - login, - name: null, - avatarUrl: null, - })), - mergeCapabilities: repository.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...viewerAccess, - canUpdateBranch: detail.comparison?.viewerCanUpdate === true, - }), - baseComparison: - detail.comparison === null || detail.comparison.behindBy === null - ? "unknown" - : detail.comparison.behindBy > 0 - ? "behind" - : "up-to-date", - ...(detail.comparison?.behindBy == null - ? {} - : { behindBy: detail.comparison.behindBy }), + Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...detail.pullRequest, + checks: withWorkflowApprovals( + detail.pullRequest.checks, + detail.workflowApprovals.runs, + detail.workflowApprovals.unavailable, + ), + ...(detail.workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), + reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: repository.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...viewerAccess, + canUpdateBranch: detail.comparison?.viewerCanUpdate === true, }), - ), + baseComparison: + detail.comparison === null || detail.comparison.behindBy === null + ? "unknown" + : detail.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), + })), ), getChangeRequestActivity: (input) => @@ -426,53 +420,51 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ - author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), - reviewers: reviewThreads.reviewers, - reactions: reviewThreads.reactions, - commits: (reviewThreads.commits.length > 0 - ? reviewThreads.commits - : pullRequest.commits - ).map((commit) => ({ - ...commit, - ...reviewThreads.commitStats.get(commit.oid), - authors: commit.authors?.map( - (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, - ), - })), - comments: [...pullRequest.comments, ...reviewThreads.comments] - .map((comment) => ({ - ...comment, - // GitHub keeps the dismissal reason on the timeline event, not on the review, - // so a dismissed review with nothing visible of its own reads its words from - // there. "Visible" and not "empty": bot reviews often carry only an HTML - // marker comment, which markdown renders as nothing. - body: - comment.kind === "review" && - comment.reviewState?.toUpperCase() === "DISMISSED" && - rendersEmpty(comment.body) - ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) - : comment.body, - author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), - // A comment out of `gh pr view --json` carries none of its own: that read - // reports no reaction at all, so they arrive from the GraphQL page by node id. - reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], - })) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two - // are always whole and only the thread walk can stop short of the host. - commentCount: pullRequest.comments.length + reviewThreads.commentCount, - commentsTruncated: reviewThreads.truncated, - reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), - })), + Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ + author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), + reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, + commits: (reviewThreads.commits.length > 0 + ? reviewThreads.commits + : pullRequest.commits + ).map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, + ), + })), + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), })), - }), - ), + })), + })), ), getReviewThreadComments: (input) => diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 701ef53b08e..46fce288427 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -147,24 +147,22 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ - ...mergeRequest, - mergeCapabilities, - viewerPermissions: gitLabViewerPermissions(mergeRequest), - // A GitLab too old to count the divergence says nothing here rather than "up to - // date": the banner is worth missing, and a wrong all-clear is not worth showing. - baseComparison: - mergeRequest.divergedCommits === undefined - ? "unknown" - : mergeRequest.divergedCommits > 0 - ? "behind" - : "up-to-date", - ...(mergeRequest.divergedCommits === undefined - ? {} - : { behindBy: mergeRequest.divergedCommits }), - }), - ), + Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + mergeCapabilities, + viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), + })), ), getChangeRequestActivity: (input) => @@ -189,28 +187,26 @@ export const make = Effect.gen(function* () { { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ - reactions: awards.reactions, - comments: notes.comments.map((comment) => ({ + Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ ...comment, reactions: awards.reactionsByNoteId.get(comment.id) ?? [], })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. - commentCount: notes.comments.length, - commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), - commits, - }), - ), + })), + commits, + })), ), // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 116087eb8f7..509127fc9cf 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1035,21 +1035,19 @@ export const make = Effect.gen(function* () { }), // One unreachable repository must not blank the page. A host-level failure is // already reported through `providers`, so it degrades the same way here. - Effect.orElseSucceed( - (): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - }), - ), + Effect.orElseSucceed((): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + })), ); } }; @@ -1230,20 +1228,18 @@ export const make = Effect.gen(function* () { : project.api.getChangeRequestSummary(providerInput); return read.pipe( Effect.mapError(toPullRequestError("summary")), - Effect.map( - (changeRequest): PullRequestSummary => ({ - provider: project.api.kind, - projectId: project.project.id, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - url: changeRequest.url, - state: changeRequest.state, - headBranch: changeRequest.headBranch, - baseBranch: changeRequest.baseBranch, - updatedAt: changeRequest.updatedAt, - }), - ), + Effect.map((changeRequest): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + updatedAt: changeRequest.updatedAt, + })), ); }), ); @@ -1265,55 +1261,53 @@ export const make = Effect.gen(function* () { ], { concurrency: 2 }, ).pipe( - Effect.map( - ([changeRequest, viewer]): PullRequestDetail => ({ - provider: project.api.kind, - capabilities: project.api.capabilities, - projectId: project.project.id, - projectTitle: project.project.title, - workspaceRoot: project.project.workspaceRoot, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - body: changeRequest.body, - url: changeRequest.url, - author: changeRequest.author, - state: changeRequest.state, - isDraft: changeRequest.isDraft, - mergeability: changeRequest.mergeability, - additions: changeRequest.additions, - deletions: changeRequest.deletions, - changedFiles: changeRequest.changedFiles, - headBranch: changeRequest.headBranch, - ...(changeRequest.headRepositoryNameWithOwner === undefined - ? {} - : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), - baseBranch: changeRequest.baseBranch, - createdAt: changeRequest.createdAt, - updatedAt: changeRequest.updatedAt, - mergedAt: changeRequest.mergedAt, - closedAt: changeRequest.closedAt, - reviewers: changeRequest.reviewers, - labels: changeRequest.labels, - checks: changeRequest.checks, - mergeCapabilities: changeRequest.mergeCapabilities, - viewerPermissions: changeRequest.viewerPermissions, - ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), - ...(changeRequest.baseComparison === undefined - ? {} - : { baseComparison: changeRequest.baseComparison }), - ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), - ...(changeRequest.autoMergeEnabled === undefined - ? {} - : { autoMergeEnabled: changeRequest.autoMergeEnabled }), - ...(changeRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: changeRequest.autoMergeMethod }), - ...(changeRequest.workflowApprovalsRequired === undefined - ? {} - : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), - }), - ), + Effect.map(([changeRequest, viewer]): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + ...(changeRequest.headRepositoryNameWithOwner === undefined + ? {} + : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), + ...(changeRequest.baseComparison === undefined + ? {} + : { baseComparison: changeRequest.baseComparison }), + ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), + ...(changeRequest.autoMergeEnabled === undefined + ? {} + : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + ...(changeRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.workflowApprovalsRequired === undefined + ? {} + : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), + })), ), ), ); @@ -1330,18 +1324,16 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(toPullRequestError("activity")), - Effect.map( - (activity): PullRequestActivity => ({ - ...(activity.author === undefined ? {} : { author: activity.author }), - ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), - comments: activity.comments, - commentCount: activity.commentCount, - commentsTruncated: activity.commentsTruncated, - reviewThreads: activity.reviewThreads, - commits: activity.commits, - ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), - }), - ), + Effect.map((activity): PullRequestActivity => ({ + ...(activity.author === undefined ? {} : { author: activity.author }), + ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), + comments: activity.comments, + commentCount: activity.commentCount, + commentsTruncated: activity.commentsTruncated, + reviewThreads: activity.reviewThreads, + commits: activity.commits, + ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), + })), ), ), ); @@ -1906,23 +1898,22 @@ export const make = Effect.gen(function* () { ); } return viewerPermissionsOf(project, input, "setLabels").pipe( - Effect.flatMap( - (viewer): Effect.Effect => - viewer.labels === false - ? 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"))), + Effect.flatMap((viewer): Effect.Effect => + viewer.labels === false + ? 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"))), ), ); }), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 12fb376d3ed..7887a81617d 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1339,18 +1339,16 @@ function toComments(raw: { readonly comments?: ReadonlyArray> | undefined; readonly reviews?: ReadonlyArray> | undefined; }): ReadonlyArray { - const issueComments = (raw.comments ?? []).map( - (comment): PullRequestComment => ({ - id: comment.id, - kind: "issue-comment", - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - url: trimmed(comment.url), - path: null, - reviewState: null, - }), - ); + const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + })); // A review with no body is kept only when its state is the event itself — an approval, a // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the // container for line comments, and those comments are read from the review threads, so @@ -1743,19 +1741,17 @@ export function reviewThreadConversation( threads: ReadonlyArray, ): ReadonlyArray { return threads.flatMap((thread) => - thread.comments.map( - (comment): PullRequestComment => ({ - id: comment.id, - kind: "review-comment", - author: comment.author, - body: comment.body, - createdAt: comment.createdAt, - url: comment.url, - path: thread.path, - reviewState: null, - reactions: comment.reactions ?? [], - }), - ), + thread.comments.map((comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + url: comment.url, + path: thread.path, + reviewState: null, + reactions: comment.reactions ?? [], + })), ); } diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 0e4b99f9f1e..1297b3bdf76 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -518,13 +518,11 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") if (message.type === "desktopTelemetryHello") { return recordContact.pipe( Effect.andThen( - updateHealth( - (current): DesktopTelemetryReceiverHealth => ({ - ...current, - status: "healthy", - lastError: Option.none(), - }), - ), + updateHealth((current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "healthy", + lastError: Option.none(), + })), ), ); } @@ -549,22 +547,18 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); }), Effect.andThen( - updateHealth( - (current): DesktopTelemetryReceiverHealth => ({ - ...current, - status: "stopped", - lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), - }), - ), + updateHealth((current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "stopped", + lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), + })), ), Effect.catch((error) => - updateHealth( - (current): DesktopTelemetryReceiverHealth => ({ - ...current, - status: "degraded", - lastError: Option.some(error.message), - }), - ), + updateHealth((current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "degraded", + lastError: Option.some(error.message), + })), ), Effect.forkScoped, ); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 4dd7e721d47..4184854aa26 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -491,12 +491,10 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi validateProcessIdentity, retry: nativeClient.retry.pipe( Effect.zip(Ref.get(state)), - Effect.map( - ([accepted, current]): ResourceTelemetryRetryResult => ({ - accepted, - snapshot: current.latest, - }), - ), + Effect.map(([accepted, current]): ResourceTelemetryRetryResult => ({ + accepted, + snapshot: current.latest, + })), ), }); }); diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index b2556bb4c41..79c1092f94d 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -333,7 +333,7 @@ async function runUpload(job: UploadJob): Promise { } function pumpUploads(): void { - for (let index = 0; index < queue.length; ) { + for (let index = 0; index < queue.length;) { const job = queue[index]!; const active = activeUploadsByEnvironment.get(job.environmentId) ?? 0; if (active >= MAX_UPLOADS_PER_ENVIRONMENT) { diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index a2dafa3ca65..6766d28f88b 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -317,12 +317,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }), }); const lease = yield* effect.pipe( - Effect.mapError( - (error): TracedAttemptFailure => ({ - error, - attemptSpan: Option.some(attemptSpan), - }), - ), + Effect.mapError((error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.some(attemptSpan), + })), ); return { attemptSpan: Option.some(attemptSpan), lease }; }).pipe(Effect.withSpan("relay.connection.attempt", { root: true })); @@ -358,12 +356,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( attemptSpan: Option.none(), lease, })), - Effect.mapError( - (error): TracedAttemptFailure => ({ - error, - attemptSpan: Option.none(), - }), - ), + Effect.mapError((error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.none(), + })), ); }); @@ -514,20 +510,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( exitUnlessInterrupted( establishTracedConnection(attempt, generation, lastFailure, pendingRetry), ).pipe( - Effect.map( - (exit): EstablishmentEvent => ({ - _tag: "Completed", - exit, - }), - ), + Effect.map((exit): EstablishmentEvent => ({ + _tag: "Completed", + exit, + })), ), waitForEstablishmentInterrupt().pipe( - Effect.map( - (resetRetry): EstablishmentEvent => ({ - _tag: "Interrupted", - resetRetry, - }), - ), + Effect.map((resetRetry): EstablishmentEvent => ({ + _tag: "Interrupted", + resetRetry, + })), ), Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( Effect.as({ _tag: "TimedOut" }), @@ -602,20 +594,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const connectedExit = yield* Effect.raceAllFirst([ active.lease.session.closed.pipe( - Effect.mapError( - (error): TracedAttemptFailure => ({ - error, - attemptSpan: active.attemptSpan, - }), - ), + Effect.mapError((error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + })), ), monitorConnectedLease(active.lease).pipe( - Effect.mapError( - (error): TracedAttemptFailure => ({ - error, - attemptSpan: active.attemptSpan, - }), - ), + Effect.mapError((error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + })), ), waitForAuthorizationRefresh(active.lease.prepared), ]).pipe(exitUnlessInterrupted); diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index aedd85c5de4..f7e94519702 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -607,14 +607,11 @@ describe("RpcSessionFactory", () => { payload: { themes: [] }, }, ]; - const settingsEvents = Array.from( - { length: 65 }, - (): ServerConfigStreamEventType => ({ - version: 1, - type: "settingsUpdated", - payload: { settings: DEFAULT_SERVER_SETTINGS }, - }), - ); + const settingsEvents = Array.from({ length: 65 }, (): ServerConfigStreamEventType => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + })); const allEvents = [...themeEvents, ...settingsEvents]; const observedByFastSubscriber = yield* Queue.unbounded(); yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 34df10eb03b..a26c98f44e6 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -3831,7 +3831,10 @@ export type V2ConfigWriteResponse__WriteStatus = "ok" | "okOverridden"; export const V2ConfigWriteResponse__WriteStatus = Schema.Literals(["ok", "okOverridden"]); export type V2ConsumeAccountRateLimitResetCreditResponse__ConsumeAccountRateLimitResetCreditOutcome = - "reset" | "nothingToReset" | "noCredit" | "alreadyRedeemed"; + | "reset" + | "nothingToReset" + | "noCredit" + | "alreadyRedeemed"; export const V2ConsumeAccountRateLimitResetCreditResponse__ConsumeAccountRateLimitResetCreditOutcome = Schema.Literals(["reset", "nothingToReset", "noCredit", "alreadyRedeemed"]); @@ -3960,16 +3963,16 @@ export const V2ExternalAgentConfigDetectResponse__SubagentMigration = Schema.Str }); export type V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType = - | "AGENTS_MD" - | "CONFIG" - | "SKILLS" - | "PLUGINS" - | "MCP_SERVER_CONFIG" - | "SUBAGENTS" - | "HOOKS" - | "COMMANDS" - | "MEMORY" - | "SESSIONS"; + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "MEMORY" + | "SESSIONS"; export const V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType = Schema.Literals([ "AGENTS_MD", @@ -3985,16 +3988,16 @@ export const V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConf ]); export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType = - | "AGENTS_MD" - | "CONFIG" - | "SKILLS" - | "PLUGINS" - | "MCP_SERVER_CONFIG" - | "SUBAGENTS" - | "HOOKS" - | "COMMANDS" - | "MEMORY" - | "SESSIONS"; + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "MEMORY" + | "SESSIONS"; export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType = Schema.Literals([ "AGENTS_MD", diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts index 490e11fa04f..273224a9a05 100644 --- a/packages/shared/src/qrCode.ts +++ b/packages/shared/src/qrCode.ts @@ -778,7 +778,7 @@ export class QrSegment { if (!QrSegment.isNumeric(digits)) throw new RangeError("String contains non-numeric characters"); let bb: Array = []; - for (let i = 0; i < digits.length; ) { + for (let i = 0; i < digits.length;) { // Consume up to 3 digits per iteration const n: int = Math.min(digits.length - i, 3); appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab8d6d2500d..1085cbb120f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ catalogs: specifier: ~6.0.3 version: 6.0.3 vite-plus: - specifier: 0.2.2 - version: 0.2.2 + specifier: 0.3.0 + version: 0.3.0 overrides: '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-arm64': '-' @@ -81,7 +81,7 @@ overrides: expo-sharing>@expo/config-types: 57.0.2 lightningcss: 1.33.0 tailwindcss: 4.3.3 - vite: npm:@voidzero-dev/vite-plus-core@0.2.2 + vite: npm:@voidzero-dev/vite-plus-core@0.3.0 yaml: ^2.9.0 packageExtensionsChecksum: sha256-k/dT9NFDl5hihRPaoFKeY11hzyutMFs5psfZLFiKJic= @@ -126,7 +126,7 @@ importers: version: 7.0.0-dev.20260604.1 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -193,7 +193,7 @@ importers: version: 4.3.3 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -535,7 +535,7 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -659,13 +659,13 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@rolldown/plugin-babel': specifier: ^0.2.0 - version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) + version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) '@tailwindcss/vite': specifier: 4.3.3 - version: 4.3.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + version: 4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.161.0 - version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -686,7 +686,7 @@ importers: version: 0.3.0 '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0) + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -697,11 +697,11 @@ importers: specifier: 4.3.3 version: 4.3.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.3.0 + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -728,7 +728,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(00c448ade6580e73d10ccfe1b32cee97) + version: 2.0.0-beta.65(2233d007cbd93ff91712c637e233494f) drizzle-orm: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -752,11 +752,11 @@ importers: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.3.0 + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -775,7 +775,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -812,7 +812,7 @@ importers: version: 2.0.2 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -825,7 +825,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -847,7 +847,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -869,7 +869,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -903,7 +903,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -928,7 +928,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -947,7 +947,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -981,7 +981,7 @@ importers: version: 6.0.5 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -3289,289 +3289,289 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - '@oxc-project/runtime@0.138.0': - resolution: {integrity: sha512-yHhoXsN8tYxgdJCdD91PbySNjEEaBX/tH2OQRDXJpsQv5b184oC4/qVbU7qlblvfil/JP15Lh2HW7+HN5DS90Q==} + '@oxc-project/runtime@0.146.0': + resolution: {integrity: sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxfmt/binding-android-arm-eabi@0.57.0': - resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@oxfmt/binding-android-arm-eabi@0.64.0': + resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.57.0': - resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} + '@oxfmt/binding-android-arm64@0.64.0': + resolution: {integrity: sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.57.0': - resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} + '@oxfmt/binding-darwin-arm64@0.64.0': + resolution: {integrity: sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.57.0': - resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} + '@oxfmt/binding-darwin-x64@0.64.0': + resolution: {integrity: sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.57.0': - resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} + '@oxfmt/binding-freebsd-x64@0.64.0': + resolution: {integrity: sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': - resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} + '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': + resolution: {integrity: sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.57.0': - resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.64.0': + resolution: {integrity: sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.57.0': - resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} + '@oxfmt/binding-linux-arm64-gnu@0.64.0': + resolution: {integrity: sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.57.0': - resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} + '@oxfmt/binding-linux-arm64-musl@0.64.0': + resolution: {integrity: sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.57.0': - resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} + '@oxfmt/binding-linux-ppc64-gnu@0.64.0': + resolution: {integrity: sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.57.0': - resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} + '@oxfmt/binding-linux-riscv64-gnu@0.64.0': + resolution: {integrity: sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.57.0': - resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} + '@oxfmt/binding-linux-riscv64-musl@0.64.0': + resolution: {integrity: sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.57.0': - resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} + '@oxfmt/binding-linux-s390x-gnu@0.64.0': + resolution: {integrity: sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.57.0': - resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} + '@oxfmt/binding-linux-x64-gnu@0.64.0': + resolution: {integrity: sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.57.0': - resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} + '@oxfmt/binding-linux-x64-musl@0.64.0': + resolution: {integrity: sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.57.0': - resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} + '@oxfmt/binding-openharmony-arm64@0.64.0': + resolution: {integrity: sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.57.0': - resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} + '@oxfmt/binding-win32-arm64-msvc@0.64.0': + resolution: {integrity: sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.57.0': - resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} + '@oxfmt/binding-win32-ia32-msvc@0.64.0': + resolution: {integrity: sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.57.0': - resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} + '@oxfmt/binding-win32-x64-msvc@0.64.0': + resolution: {integrity: sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.24.0': - resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.24.0': - resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.24.0': - resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.24.0': - resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.24.0': - resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.24.0': - resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.72.0': - resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + '@oxlint/binding-android-arm-eabi@1.79.0': + resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.72.0': - resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + '@oxlint/binding-android-arm64@1.79.0': + resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.72.0': - resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + '@oxlint/binding-darwin-arm64@1.79.0': + resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.72.0': - resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + '@oxlint/binding-darwin-x64@1.79.0': + resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.72.0': - resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + '@oxlint/binding-freebsd-x64@1.79.0': + resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': - resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.72.0': - resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + '@oxlint/binding-linux-arm-musleabihf@1.79.0': + resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.72.0': - resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + '@oxlint/binding-linux-arm64-gnu@1.79.0': + resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.72.0': - resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + '@oxlint/binding-linux-arm64-musl@1.79.0': + resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.72.0': - resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + '@oxlint/binding-linux-ppc64-gnu@1.79.0': + resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.72.0': - resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + '@oxlint/binding-linux-riscv64-gnu@1.79.0': + resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.72.0': - resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + '@oxlint/binding-linux-riscv64-musl@1.79.0': + resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.72.0': - resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + '@oxlint/binding-linux-s390x-gnu@1.79.0': + resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.72.0': - resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + '@oxlint/binding-linux-x64-gnu@1.79.0': + resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.72.0': - resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + '@oxlint/binding-linux-x64-musl@1.79.0': + resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.72.0': - resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + '@oxlint/binding-openharmony-arm64@1.79.0': + resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.72.0': - resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + '@oxlint/binding-win32-arm64-msvc@1.79.0': + resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.72.0': - resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + '@oxlint/binding-win32-ia32-msvc@1.79.0': + resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.72.0': - resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + '@oxlint/binding-win32-x64-msvc@1.79.0': + resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3580,6 +3580,10 @@ packages: resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@oxlint/plugins@1.79.0': + resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} @@ -4859,21 +4863,21 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/browser-preview@4.1.9': - resolution: {integrity: sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow==} + '@vitest/browser-preview@4.1.11': + resolution: {integrity: sha512-iPKSE6Ibayey6HFgK1V1/aHgyhx7HSRk1YMi+lnBZGmlIiNV5Uc7xRkD9Su8RDylTxDECK23t7kTHdRKoqSYDQ==} peerDependencies: - vitest: 4.1.9 + vitest: 4.1.11 - '@vitest/browser@4.1.9': - resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==} + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} peerDependencies: - vitest: 4.1.9 + vitest: 4.1.11 - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4883,28 +4887,28 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - '@voidzero-dev/vite-plus-core@0.2.2': - resolution: {integrity: sha512-yAbKexF3npOGjg1N5EtXxun+7vdM/0x6QE5jucO/dv0LFhCAIzSN3UvLVCeamJt/Bz3jt7DLqQHEgXXrjy8drA==} + '@voidzero-dev/vite-plus-core@0.3.0': + resolution: {integrity: sha512-aOqoqIWaF+Q/geDU48pC2rVFEVSvLV1GGj/NdvhUiBhCZntoFNbwI+hjUeG8BMaPG67sOV6ey+/sgkdmGmKqaw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 '@types/node': 24.12.4 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -4915,7 +4919,7 @@ packages: sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 - typescript: ^5.0.0 || ^6.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 unplugin-unused: ^0.5.0 unrun: '*' yaml: ^2.9.0 @@ -4955,54 +4959,54 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.2.2': - resolution: {integrity: sha512-Wy0Shx3Waa2cQZGSrPm0cpO1Y5oNyKyC1jarv12bBcgV+4uoEBKX+ep2Nh7zwjfd8Ja4QMiePE7wciOSXxu8oQ==} + '@voidzero-dev/vite-plus-darwin-arm64@0.3.0': + resolution: {integrity: sha512-9ADr1egZ8T4tJOqrpQLhoDl95Y74R95+bsvjmin0gy1C0eQVhpmcNnBfb07KFNhJioJp9MMO7F7Dx4fQL5SKsw==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.2.2': - resolution: {integrity: sha512-09xcW67OvsQItVPzmF8UckI+glM3DzyQO3A98deNQ4QtUF7Mt+4/cYKKcLKg2ExRWWXGNDnVG/j7/hiLjZzynw==} + '@voidzero-dev/vite-plus-darwin-x64@0.3.0': + resolution: {integrity: sha512-GegasVCwNeDOkNyvhLOuwU1+T2JkjY/Tq+SOvwphUpVcqQ6OOAUq9LlpoXviO2QL/Kq2NbMYjiAfPKVSTLUFQw==} engines: {node: '>=20.0.0'} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.2': - resolution: {integrity: sha512-bR6287UFNwulMiQRhbtXF8GYs9a8EjvefXf+Glm7AzbePUXnamW9cwYIj2j4Dgoje0yC4gA52UePEFjXZnJcjA==} + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.3.0': + resolution: {integrity: sha512-nYI3KNYXkXjRPsSdR4Lr7J2xMxfR1+TplWlG/dV37qVXWAjbyHpoAlbULjZBAVJMyXRNlcADhBrEwXe4g6s48A==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.2': - resolution: {integrity: sha512-YGtvTHT7qP4c5pZmM4kLL78/d8hj2NS150R92cR2SVOW/l9Ilq5R5WrEiMA4k5Ea3B++IJWZT5MRFI0tW9qlcg==} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.3.0': + resolution: {integrity: sha512-HRlVA3AOcuGXmOdHhQ+Zv5XAaKbYF9si5rRHoOsKl0UyBo4txA3OoJfmP0WjanfLUNmu85JyO2dO1ptL4C6wgg==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.2': - resolution: {integrity: sha512-FvaMI/vsy4PVM+Qd73K+KM8blfCAfaoZaGaGWNrrlMryhyPThXPnHoB1AQcrKbEAWb+z2fc4zLS4sH+8uI65fw==} + '@voidzero-dev/vite-plus-linux-x64-gnu@0.3.0': + resolution: {integrity: sha512-9A+dFScPfwcrzF/rRR0zH8++2hOf6xtFmN/5LyzyfUywtw9MILXcC72IMcOeL6QRJwKUMsudi1rFeDE59azNvw==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.2.2': - resolution: {integrity: sha512-ZsMochHqXqxj2sGTJNJzz3vabppbe4BFgZAjJfsnVzkwR7jv6c5p1BM71LFWxP4qd5LL0TJT7lbeRhALlI44RQ==} + '@voidzero-dev/vite-plus-linux-x64-musl@0.3.0': + resolution: {integrity: sha512-KfIV3qaPdaOOE8JQMRHRE34FtZocl9O86XLTP6JMjDUlcx8FPgf8/fz/HFqJ8g232vM+JsgLI/YTVeXP8LkTKw==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.2': - resolution: {integrity: sha512-noBNyJufux0cf18eDpQLOQUZ1Kybfx9zlr+yQ6gAnxMEsQXSvYqZgWymfRDesBE3G/0XB5bg+AUtWijp3TwVcw==} + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0': + resolution: {integrity: sha512-KRhdy5K13AYx9KBfCVHRrK7zSZU+bMW9CL6gTai+UkJgAmDJi1kjdSNboZOjO8mrzUnTCrELgMI2tnstcxSTuA==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.2': - resolution: {integrity: sha512-+VUui1OIaFX0tqdUAXjmoKVlujEtWVdcsFDw2Jff+D6b4LUTQaOMAaaic8nNdfZL6wEjweRREQLZi/icZAXtNQ==} + '@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0': + resolution: {integrity: sha512-7+G+GxGmxdpQO0zjiGnkZFXKGqm0CrVduebRsJd6ccuOuxCQYPxLcoHq4WOaGrh56SrAGS7XjhnQCrXRkzKUVQ==} engines: {node: '>=20.0.0'} cpu: [x64] os: [win32] @@ -5043,6 +5047,131 @@ packages: engines: {node: '>=14.6'} deprecated: this version has critical issues, please update to the latest version + '@yuku-codegen/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.5.48': + resolution: {integrity: sha512-aRCTw0EZC4bVosmw//0OMYP5tGWFE0Cu5yUBFkUbhXx/iBzvORcJ2xPNlOp/vtCCo9Ys4vp8b0DigJV6uOVb2g==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.5.48': + resolution: {integrity: sha512-DuSQlk8bH4gpmW3/00P0NLagAcMv8jOxjT40cQmxKRkktr+SUOALCfkT89tdDq3qtY95NR2GXOZ7AjNh7KKqCw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.5.48': + resolution: {integrity: sha512-bxj4Ee+wlaJcWJwft2ReJXWw5sfl1qavDz6+dlRdU1xfTEtjPSNiAWhiCHnJR0R4Ygd57DnzSQmAVGvFv6RcGw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': + resolution: {integrity: sha512-mk5JVWh+0JOe5ue8k17kbYX8uGBoKt3ZqoCyxNh4nYAAcX7+X1tFUiU7jbjctu4vHeejCBFSTdQ021+V31cUCQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.5.48': + resolution: {integrity: sha512-4q3vkrNghbllyxOm2KesFLxCPKHF7r3JyQ7BWZccY1j2Y05yKoIFhoWCqIuQ2W/dpte9RI0+OVfwyxnrKg6fkA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.5.48': + resolution: {integrity: sha512-csd4M1EVrGaohM8acM6gq1zpUA/Rwe2ulUMBKUcwQXm/k6n7cq1A++qdew78SOVb4do3JH1WE+WFwoGQAcWc1w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.5.48': + resolution: {integrity: sha512-KcDuEOT+GFoVKdvAWOv1v9iYjwnmvMZlO+j1Rw+5PYdeFLGWGzv/DD11y4SAAdwXIFcil4T0hibeIaF82WStMg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-HI8qNrI8dWM5BuqIMKsqornRvTNFrE6sm5zToIJ9YIa9zt5+29P7fJ7Nr39EVf6dAWSb6q7JSpScJnRsQ+FgZA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.5.48': + resolution: {integrity: sha512-X5YWJLO6EfBZpeBqO0AYESnUizbpFDWArcvVD61w0PEWQ3CaFRLnbQXs+kpM4ZZfGMfIE22zfA08QSY67q7TNQ==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.5.48': + resolution: {integrity: sha512-EimvPXfspzxf1K11eB6tCW5oiQEXB8g84T2wP1TwzQagdDKo33bkmmVF0B32vTIpXnk/Ifu5IB61izZ1MylljA==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-0GcUMrumLHheThY9r5Tp46gaZYzn0irWPS1Zba6WY+vVQfhUtzGiWgXxI6tuXX0N32kEaaEVRpkKctvo6Kx3aQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.5.48': + resolution: {integrity: sha512-8S5T5wjCC73dmmpQeZ49aYsSunIUM3D4Fc6rdK96c+Ayg/p3FmeSPF3xuLZHejcTmqJIIvnbfPlUF+rB6DITjQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.5.48': + resolution: {integrity: sha512-tTmbxvnUHcK2/crS9547vk2SMmsajH1yqJ8ltXhIuHJgqR1v+d9n9KT+kSayo/5CS76LegeYxhMFjEivBH2hFA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': + resolution: {integrity: sha512-KGYCBMqI2zfwyhgq5tpPVNe7jpUeYTBm8DhjdS+zqWNumde/PEC170QE5RHxcOAlsirIDeIUk0jqx+r/axoFSw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.5.48': + resolution: {integrity: sha512-2wTSMsCSXLTc2lZUjMAuU5X4cje55u205WJqfV5NWNF6j9pW/tXyxr15dJeekj8ziLqBXzIsj4DbRh4sY/WcjA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.5.48': + resolution: {integrity: sha512-d/6v9UnGglVu1WC2JQyv/5aWSi5fXZeGSlidCfmHp4+N65N1GDKUnFtys5MK5eAPeAjTgSHGGtOc/yCcKTlv3A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.5.48': + resolution: {integrity: sha512-gX19gw6u4ApPy7SYMPKfFlEkrtj6WlORvrTKK3sBQqjyV+8+mUAkQgxXNjHw4RnOiAmVYg7TOlZcg8d+Qqod9A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.5.48': + resolution: {integrity: sha512-4gO0HmG7fzFxrw1rs0dUdnnaY9YgennjETqDWrTSp7x9fmTUOAoN4VsMfP7YyliQeG1WJJHc55O+rOhmsLppow==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.5.43': + resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} engines: {node: '>= 12'} @@ -8267,8 +8396,8 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - oxfmt@0.57.0: - resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} + oxfmt@0.64.0: + resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8280,16 +8409,16 @@ packages: vite-plus: optional: true - oxlint-tsgolint@0.24.0: - resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.72.0: - resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + oxlint@1.79.0: + resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.22.1' + oxlint-tsgolint: '>=7.0.2001' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -9881,13 +10010,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-plus@0.2.2: - resolution: {integrity: sha512-bXO3O0F2/uxtvX9Ck0o67stTErH/Zh0GEcCMd9pAh22tTHABCNTDPrRMWVo733e7Ux3h0Y7HanJ7neOV/nid4g==} + vite-plus@0.3.0: + resolution: {integrity: sha512-GNWbWuWD37frCSFrz6MLzUo62bTv5IOJozHEgZYOkxsLkuQtTwm4TowzpfoGrSsfwhAAtfPd/sK1Y0+v1SwhZA==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} hasBin: true peerDependencies: - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 peerDependenciesMeta: '@vitest/browser-playwright': optional: true @@ -9902,20 +10031,20 @@ packages: vite: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': 24.12.4 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -10233,6 +10362,12 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + yuku-codegen@0.5.48: + resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} + + yuku-parser@0.5.48: + resolution: {integrity: sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -11420,14 +11555,14 @@ snapshots: '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': + '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) magic-string: 0.30.21 unenv: 2.0.0-rc.24 optionalDependencies: rolldown: 1.1.5 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - workerd @@ -11441,13 +11576,13 @@ snapshots: '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(f97c3167f1a1990dddb83bff73e575e5)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(86e3ed6000e5955518fd9c0dea8322a9)': dependencies: '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -13149,149 +13284,151 @@ snapshots: '@oslojs/encoding@1.1.0': {} - '@oxc-project/runtime@0.138.0': {} + '@oxc-project/runtime@0.146.0': {} '@oxc-project/types@0.127.0': optional: true - '@oxc-project/types@0.138.0': {} - '@oxc-project/types@0.139.0': {} - '@oxfmt/binding-android-arm-eabi@0.57.0': + '@oxc-project/types@0.146.0': {} + + '@oxfmt/binding-android-arm-eabi@0.64.0': optional: true - '@oxfmt/binding-android-arm64@0.57.0': + '@oxfmt/binding-android-arm64@0.64.0': optional: true - '@oxfmt/binding-darwin-arm64@0.57.0': + '@oxfmt/binding-darwin-arm64@0.64.0': optional: true - '@oxfmt/binding-darwin-x64@0.57.0': + '@oxfmt/binding-darwin-x64@0.64.0': optional: true - '@oxfmt/binding-freebsd-x64@0.57.0': + '@oxfmt/binding-freebsd-x64@0.64.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + '@oxfmt/binding-linux-arm-musleabihf@0.64.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.57.0': + '@oxfmt/binding-linux-arm64-gnu@0.64.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.57.0': + '@oxfmt/binding-linux-arm64-musl@0.64.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + '@oxfmt/binding-linux-ppc64-gnu@0.64.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + '@oxfmt/binding-linux-riscv64-gnu@0.64.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.57.0': + '@oxfmt/binding-linux-riscv64-musl@0.64.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.57.0': + '@oxfmt/binding-linux-s390x-gnu@0.64.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.57.0': + '@oxfmt/binding-linux-x64-gnu@0.64.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.57.0': + '@oxfmt/binding-linux-x64-musl@0.64.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.57.0': + '@oxfmt/binding-openharmony-arm64@0.64.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.57.0': + '@oxfmt/binding-win32-arm64-msvc@0.64.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.57.0': + '@oxfmt/binding-win32-ia32-msvc@0.64.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.57.0': + '@oxfmt/binding-win32-x64-msvc@0.64.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.24.0': + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/darwin-x64@0.24.0': + '@oxlint-tsgolint/darwin-x64@7.0.2001': optional: true - '@oxlint-tsgolint/linux-arm64@0.24.0': + '@oxlint-tsgolint/linux-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/linux-x64@0.24.0': + '@oxlint-tsgolint/linux-x64@7.0.2001': optional: true - '@oxlint-tsgolint/win32-arm64@0.24.0': + '@oxlint-tsgolint/win32-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/win32-x64@0.24.0': + '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.72.0': + '@oxlint/binding-android-arm-eabi@1.79.0': optional: true - '@oxlint/binding-android-arm64@1.72.0': + '@oxlint/binding-android-arm64@1.79.0': optional: true - '@oxlint/binding-darwin-arm64@1.72.0': + '@oxlint/binding-darwin-arm64@1.79.0': optional: true - '@oxlint/binding-darwin-x64@1.72.0': + '@oxlint/binding-darwin-x64@1.79.0': optional: true - '@oxlint/binding-freebsd-x64@1.72.0': + '@oxlint/binding-freebsd-x64@1.79.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.72.0': + '@oxlint/binding-linux-arm-musleabihf@1.79.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.72.0': + '@oxlint/binding-linux-arm64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.72.0': + '@oxlint/binding-linux-arm64-musl@1.79.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.72.0': + '@oxlint/binding-linux-ppc64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.72.0': + '@oxlint/binding-linux-riscv64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.72.0': + '@oxlint/binding-linux-riscv64-musl@1.79.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.72.0': + '@oxlint/binding-linux-s390x-gnu@1.79.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.72.0': + '@oxlint/binding-linux-x64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-x64-musl@1.72.0': + '@oxlint/binding-linux-x64-musl@1.79.0': optional: true - '@oxlint/binding-openharmony-arm64@1.72.0': + '@oxlint/binding-openharmony-arm64@1.79.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.72.0': + '@oxlint/binding-win32-arm64-msvc@1.79.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.72.0': + '@oxlint/binding-win32-ia32-msvc@1.79.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.72.0': + '@oxlint/binding-win32-x64-msvc@1.79.0': optional: true '@oxlint/plugins@1.68.0': {} + '@oxlint/plugins@1.79.0': {} + '@peculiar/asn1-schema@2.8.0': dependencies: '@peculiar/utils': 2.0.3 @@ -13850,7 +13987,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.4 @@ -13858,7 +13995,7 @@ snapshots: optionalDependencies: '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) '@babel/runtime': 7.29.7 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@rolldown/pluginutils@1.0.0-rc.17': optional: true @@ -14169,12 +14306,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.3': {} @@ -14237,7 +14374,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -14254,7 +14391,7 @@ snapshots: zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - supports-color @@ -14528,36 +14665,36 @@ snapshots: optionalDependencies: ajv: 6.15.0 - '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': + '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': + '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - '@vitest/utils': 4.1.9 + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -14565,56 +14702,66 @@ snapshots: - utf-8-validate - vite - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': + '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: - '@oxc-project/runtime': 0.138.0 - '@oxc-project/types': 0.138.0 + '@oxc-project/runtime': 0.146.0 + '@oxc-project/types': 0.146.0 lightningcss: 1.33.0 postcss: 8.5.15 + yuku-codegen: 0.5.48 + yuku-parser: 0.5.48 optionalDependencies: '@types/node': 24.12.4 + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 @@ -14623,28 +14770,28 @@ snapshots: unrun: 0.2.39 yaml: 2.9.0 - '@voidzero-dev/vite-plus-darwin-arm64@0.2.2': + '@voidzero-dev/vite-plus-darwin-arm64@0.3.0': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.2.2': + '@voidzero-dev/vite-plus-darwin-x64@0.3.0': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.2': + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.3.0': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.2': + '@voidzero-dev/vite-plus-linux-arm64-musl@0.3.0': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.2': + '@voidzero-dev/vite-plus-linux-x64-gnu@0.3.0': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.2.2': + '@voidzero-dev/vite-plus-linux-x64-musl@0.3.0': optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.2': + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.2': + '@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0': optional: true '@volar/kit@2.4.28(typescript@6.0.3)': @@ -14701,6 +14848,74 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@yuku-codegen/binding-darwin-arm64@0.5.48': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.48': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.48': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.48': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.48': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.48': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.48': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.48': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.48': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.48': + optional: true + + '@yuku-toolchain/types@0.5.43': {} + '@yuuang/ffi-rs-android-arm64@1.3.2': optional: true @@ -14803,7 +15018,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(00c448ade6580e73d10ccfe1b32cee97): + alchemy@2.0.0-beta.65(2233d007cbd93ff91712c637e233494f): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 @@ -14811,9 +15026,9 @@ snapshots: '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(f97c3167f1a1990dddb83bff73e575e5) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(86e3ed6000e5955518fd9c0dea8322a9) '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) @@ -14849,7 +15064,7 @@ snapshots: '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) drizzle-kit: 1.0.0-rc.4 drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@mongodb-js/zstd' @@ -15037,8 +15252,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) vfile: 6.0.3 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -18701,63 +18916,63 @@ snapshots: outvariant@1.4.3: optional: true - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.57.0 - '@oxfmt/binding-android-arm64': 0.57.0 - '@oxfmt/binding-darwin-arm64': 0.57.0 - '@oxfmt/binding-darwin-x64': 0.57.0 - '@oxfmt/binding-freebsd-x64': 0.57.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 - '@oxfmt/binding-linux-arm64-gnu': 0.57.0 - '@oxfmt/binding-linux-arm64-musl': 0.57.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-musl': 0.57.0 - '@oxfmt/binding-linux-s390x-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-musl': 0.57.0 - '@oxfmt/binding-openharmony-arm64': 0.57.0 - '@oxfmt/binding-win32-arm64-msvc': 0.57.0 - '@oxfmt/binding-win32-ia32-msvc': 0.57.0 - '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - - oxlint-tsgolint@0.24.0: + '@oxfmt/binding-android-arm-eabi': 0.64.0 + '@oxfmt/binding-android-arm64': 0.64.0 + '@oxfmt/binding-darwin-arm64': 0.64.0 + '@oxfmt/binding-darwin-x64': 0.64.0 + '@oxfmt/binding-freebsd-x64': 0.64.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.64.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.64.0 + '@oxfmt/binding-linux-arm64-gnu': 0.64.0 + '@oxfmt/binding-linux-arm64-musl': 0.64.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-musl': 0.64.0 + '@oxfmt/binding-linux-s390x-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-musl': 0.64.0 + '@oxfmt/binding-openharmony-arm64': 0.64.0 + '@oxfmt/binding-win32-arm64-msvc': 0.64.0 + '@oxfmt/binding-win32-ia32-msvc': 0.64.0 + '@oxfmt/binding-win32-x64-msvc': 0.64.0 + vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + oxlint-tsgolint@7.0.2001: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.24.0 - '@oxlint-tsgolint/darwin-x64': 0.24.0 - '@oxlint-tsgolint/linux-arm64': 0.24.0 - '@oxlint-tsgolint/linux-x64': 0.24.0 - '@oxlint-tsgolint/win32-arm64': 0.24.0 - '@oxlint-tsgolint/win32-x64': 0.24.0 - - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.72.0 - '@oxlint/binding-android-arm64': 1.72.0 - '@oxlint/binding-darwin-arm64': 1.72.0 - '@oxlint/binding-darwin-x64': 1.72.0 - '@oxlint/binding-freebsd-x64': 1.72.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 - '@oxlint/binding-linux-arm-musleabihf': 1.72.0 - '@oxlint/binding-linux-arm64-gnu': 1.72.0 - '@oxlint/binding-linux-arm64-musl': 1.72.0 - '@oxlint/binding-linux-ppc64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-musl': 1.72.0 - '@oxlint/binding-linux-s390x-gnu': 1.72.0 - '@oxlint/binding-linux-x64-gnu': 1.72.0 - '@oxlint/binding-linux-x64-musl': 1.72.0 - '@oxlint/binding-openharmony-arm64': 1.72.0 - '@oxlint/binding-win32-arm64-msvc': 1.72.0 - '@oxlint/binding-win32-ia32-msvc': 1.72.0 - '@oxlint/binding-win32-x64-msvc': 1.72.0 - oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@oxlint/binding-android-arm-eabi': 1.79.0 + '@oxlint/binding-android-arm64': 1.79.0 + '@oxlint/binding-darwin-arm64': 1.79.0 + '@oxlint/binding-darwin-x64': 1.79.0 + '@oxlint/binding-freebsd-x64': 1.79.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 + '@oxlint/binding-linux-arm-musleabihf': 1.79.0 + '@oxlint/binding-linux-arm64-gnu': 1.79.0 + '@oxlint/binding-linux-arm64-musl': 1.79.0 + '@oxlint/binding-linux-ppc64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-musl': 1.79.0 + '@oxlint/binding-linux-s390x-gnu': 1.79.0 + '@oxlint/binding-linux-x64-gnu': 1.79.0 + '@oxlint/binding-linux-x64-musl': 1.79.0 + '@oxlint/binding-openharmony-arm64': 1.79.0 + '@oxlint/binding-win32-arm64-msvc': 1.79.0 + '@oxlint/binding-win32-ia32-msvc': 1.79.0 + '@oxlint/binding-win32-x64-msvc': 1.79.0 + oxlint-tsgolint: 7.0.2001 + vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -20560,34 +20775,34 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): - dependencies: - '@oxc-project/types': 0.138.0 - '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + dependencies: + '@oxc-project/types': 0.146.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint-tsgolint: 7.0.2001 + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 - '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.2 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.2 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.2 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.2 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.2 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.2 + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -20618,19 +20833,19 @@ snapshots: - utf-8-validate - yaml - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.1.0 expect-type: 1.4.0 magic-string: 0.30.21 @@ -20642,11 +20857,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) transitivePeerDependencies: - msw @@ -20924,6 +21139,38 @@ snapshots: yoga-layout@3.2.1: {} + yuku-codegen@0.5.48: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.48 + '@yuku-codegen/binding-darwin-x64': 0.5.48 + '@yuku-codegen/binding-freebsd-x64': 0.5.48 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.48 + '@yuku-codegen/binding-linux-arm-musl': 0.5.48 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.48 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.48 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.48 + '@yuku-codegen/binding-linux-x64-musl': 0.5.48 + '@yuku-codegen/binding-win32-arm64': 0.5.48 + '@yuku-codegen/binding-win32-x64': 0.5.48 + + yuku-parser@0.5.48: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.48 + '@yuku-parser/binding-darwin-x64': 0.5.48 + '@yuku-parser/binding-freebsd-x64': 0.5.48 + '@yuku-parser/binding-linux-arm-gnu': 0.5.48 + '@yuku-parser/binding-linux-arm-musl': 0.5.48 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.48 + '@yuku-parser/binding-linux-arm64-musl': 0.5.48 + '@yuku-parser/binding-linux-x64-gnu': 0.5.48 + '@yuku-parser/binding-linux-x64-musl': 0.5.48 + '@yuku-parser/binding-win32-arm64': 0.5.48 + '@yuku-parser/binding-win32-x64': 0.5.48 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 91d14d961d7..7d2f9f99861 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,8 +53,8 @@ catalog: lightningcss: 1.33.0 tailwindcss: 4.3.3 typescript: ~6.0.3 - vite: npm:@voidzero-dev/vite-plus-core@0.2.2 - vite-plus: 0.2.2 + vite: npm:@voidzero-dev/vite-plus-core@0.3.0 + vite-plus: 0.3.0 yaml: ^2.9.0 minimumReleaseAgeExclude: From 1aa44a071f66bdfd9430356ab824b5a6985fb459 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 20:58:10 -0700 Subject: [PATCH 15/46] feat(web): add a file tree to the diff panel and pull request code tab (#9330) Co-authored-by: Julius Marminge Co-authored-by: Shpetim <32248437+ShpetimA@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- apps/web/src/components/DiffPanel.tsx | 246 +++++++++++------- .../web/src/components/diffs/DiffFileTree.tsx | 186 +++++++++++++ .../diffs/diffFileTree.logic.test.ts | 69 +++++ .../components/diffs/diffFileTree.logic.ts | 93 +++++++ .../src/components/files/FileBrowserPanel.tsx | 20 +- .../pullRequest/PullRequestCodeTab.tsx | 215 ++++++++++----- apps/web/src/pierre-tree-theme.ts | 22 ++ docs/user/source-control.md | 3 + 8 files changed, 686 insertions(+), 168 deletions(-) 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/pierre-tree-theme.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c55eaa09478..c773617a7cf 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -15,12 +15,14 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + FolderTreeIcon, PilcrowIcon, RefreshCwIcon, Rows3Icon, SearchIcon, TextWrapIcon, } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; @@ -28,6 +30,7 @@ import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffContentVersion, @@ -50,6 +53,8 @@ import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; +import { DiffFileTree } from "./diffs/DiffFileTree"; +import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; @@ -82,6 +87,7 @@ import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; +const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen"; interface CollapsedDiffFilesState { readonly scopeKey: string | null; @@ -112,6 +118,11 @@ export default function DiffPanel({ const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( + DIFF_FILE_TREE_STORAGE_KEY, + false, + Schema.Boolean, + ); const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ scopeKey: null, @@ -431,6 +442,7 @@ export default function DiffPanel({ const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); + const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]); const selectedDiffFileKey = selectedFilePath ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null) : null; @@ -440,6 +452,29 @@ export default function DiffPanel({ codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); + // Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling + // in the same tick would land on the folded header's position. + const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null); + useEffect(() => { + if (treeReveal === null) return; + codeViewRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" }); + }, [treeReveal]); + const revealDiffFile = useCallback( + (filePath: string) => { + const file = codeViewFiles.find((candidate) => candidate.filePath === filePath); + if (!file) return; + if (file.collapsed) { + setCollapsedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + next.delete(file.fileKey); + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + } + setTreeReveal((current) => ({ fileKey: file.fileKey, id: (current?.id ?? 0) + 1 })); + }, + [codeViewFiles, collapseScopeKey], + ); + const openDiffFile = useCallback( (filePath: string) => { openDiffFilePrimaryAction({ @@ -825,6 +860,26 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {codeViewFiles.length > 0 && ( + + setFileTreeOpen(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + + )}
); @@ -878,97 +933,114 @@ export default function DiffPanel({
) ) : 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); - }} - > - ( - - )} - renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { - 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: diffLayout === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - preferredHighlighter: PREFERRED_HIGHLIGHTER, - themeType: resolvedTheme as DiffThemeType, - stickyHeaders: true, - ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), - }} - /> + > + ( + + )} + renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { + const filePath = resolveFileDiffPath(fileDiff); + return ( + + { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + ); + }} + options={{ + diffStyle: diffLayout === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, + themeType: resolvedTheme as DiffThemeType, + stickyHeaders: true, + ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), + }} + /> +
+ {fileTreeOpen ? ( + + ) : null}
) : (
diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx new file mode 100644 index 00000000000..3715b62ca15 --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -0,0 +1,186 @@ +import type { GitStatusEntry } from "@pierre/trees"; +import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, type ReactNode } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; + +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + buildDiffFileTreeUpdates, + collectDirectoryPaths, + type DiffFileTreeEntry, +} from "./diffFileTree.logic"; + +export type { DiffFileTreeEntry } from "./diffFileTree.logic"; + +interface DiffFileTreeProps { + readonly entries: ReadonlyArray; + /** Called with the file's path when the reader picks a file row. */ + readonly onSelectFile: (path: string) => void; + /** + * The file the diff is currently showing, kept selected in the tree. Bump `revealRequestId` to + * scroll the tree to the same path again. + */ + readonly selectedPath?: string | null; + readonly revealRequestId?: number; + readonly ariaLabel: string; + /** Right-aligned content in the header row, after the file count. */ + readonly headerAccessory?: ReactNode; + /** Rendered under the tree, for a host that still has files to fetch. */ + readonly footer?: ReactNode; + readonly className?: string; +} + +/** + * A directory tree of the files in a diff. Every directory starts open: a diff is a short list + * compared to a workspace, and the reader came for the files, not the folders. + */ +export function DiffFileTree({ + entries, + onSelectFile, + selectedPath = null, + revealRequestId = 0, + ariaLabel, + headerAccessory, + footer, + className, +}: DiffFileTreeProps) { + const { resolvedTheme } = useTheme(); + const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); + const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); + const gitStatus = useMemo>( + () => entries.map((entry) => ({ path: entry.path, status: entry.status })), + [entries], + ); + const filePathsRef = useRef>(new Set(paths)); + const onSelectFileRef = useRef(onSelectFile); + // Selection driven by `selectedPath` below is an echo of a file already on screen, not a + // request to scroll to it again. + const syncingSelectionRef = useRef(false); + const handledRevealRef = useRef<{ path: string; revealRequestId: number } | null>(null); + const mountedPathsRef = useRef | null>(null); + + useEffect(() => { + filePathsRef.current = new Set(paths); + onSelectFileRef.current = onSelectFile; + }, [onSelectFile, paths]); + + const { model } = useFileTree({ + density: "compact", + flattenEmptyDirectories: true, + initialExpansion: "open", + icons: T3_PIERRE_ICONS, + onSelectionChange: (selectedPaths) => { + if (syncingSelectionRef.current) return; + const path = selectedPaths.at(-1)?.replace(/\/$/, ""); + if (path && filePathsRef.current.has(path)) onSelectFileRef.current(path); + }, + paths: [], + search: false, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, + }); + const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => + areAllDirectoriesExpanded(currentModel, directoryPaths), + ); + + useEffect(() => { + const mountedPaths = mountedPathsRef.current; + if (mountedPaths === paths) return; + mountedPathsRef.current = paths; + if (mountedPaths === null) { + model.resetPaths(paths); + } else { + const updates = buildDiffFileTreeUpdates(mountedPaths, paths); + if (updates.length > 0) model.batch(updates); + } + model.setGitStatus(gitStatus); + }, [gitStatus, model, paths]); + + useEffect(() => { + if (selectedPath === null) { + handledRevealRef.current = null; + return; + } + // A path list that changes under an already-revealed file (a refresh, a later slice) must + // not pull the tree back to it over whatever the reader has picked since. + const item = model.getItem(selectedPath); + if (item === null || item.isDirectory()) { + // A file that left the diff has to be revealed again when it comes back. + handledRevealRef.current = null; + return; + } + const handled = handledRevealRef.current; + if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) return; + handledRevealRef.current = { path: selectedPath, revealRequestId }; + syncingSelectionRef.current = true; + for (const path of model.getSelectedPaths()) { + if (path !== selectedPath) model.getItem(path)?.deselect(); + } + let ancestor = ""; + for (const segment of selectedPath.split("/").slice(0, -1)) { + ancestor += `${segment}/`; + const directory = model.getItem(ancestor); + if (directory !== null && "expand" in directory) directory.expand(); + } + item.select(); + model.scrollToPath(selectedPath, { offset: "nearest" }); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); + // `paths` is a dependency so a file that arrives after it was asked for is still revealed. + }, [model, paths, revealRequestId, selectedPath]); + + return ( +
+
+ Files + {entries.length} + {headerAccessory} + {directoryPaths.length > 0 ? ( + + + setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded) + } + /> + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} +
+ + {footer} +
+ ); +} diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts new file mode 100644 index 00000000000..d8e24968dce --- /dev/null +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -0,0 +1,69 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDiffFileTreeUpdates, + collectDirectoryPaths, + diffFileTreeEntries, +} from "./diffFileTree.logic"; + +function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata { + return { type, name: `b/${name}`, prevName: `a/${prevName}` } as FileDiffMetadata; +} + +describe("diffFileTreeEntries", () => { + it("maps each change type to its git status under the file's current path", () => { + expect( + diffFileTreeEntries([ + file("new", "src/a.ts"), + file("deleted", "src/b.ts"), + file("rename-pure", "src/c.ts", "src/old-c.ts"), + file("rename-changed", "src/d.ts", "src/old-d.ts"), + file("change", "README.md"), + ]), + ).toEqual([ + { path: "src/a.ts", status: "added" }, + { path: "src/b.ts", status: "deleted" }, + { path: "src/c.ts", status: "renamed" }, + { path: "src/d.ts", status: "renamed" }, + { path: "README.md", status: "modified" }, + ]); + }); +}); + +describe("collectDirectoryPaths", () => { + it("lists every ancestor once, parents first, with Pierre's trailing slash", () => { + expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([ + "apps/", + "apps/web/", + "apps/web/src/", + ]); + }); +}); + +describe("buildDiffFileTreeUpdates", () => { + it("adds a new file's directories before the file", () => { + expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ + { type: "add", path: "src/" }, + { type: "add", path: "src/lib/" }, + { type: "add", path: "src/lib/a.ts" }, + ]); + }); + + it("removes files before their now-empty directories, deepest first", () => { + expect(buildDiffFileTreeUpdates(["src/lib/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([ + { type: "remove", path: "src/lib/a.ts" }, + { type: "remove", path: "src/lib/", recursive: true }, + ]); + }); + + it("keeps a directory that still holds a file", () => { + expect(buildDiffFileTreeUpdates(["src/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([ + { type: "remove", path: "src/a.ts" }, + ]); + }); + + it("produces nothing when the paths are unchanged", () => { + expect(buildDiffFileTreeUpdates(["src/a.ts"], ["src/a.ts"])).toEqual([]); + }); +}); diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts new file mode 100644 index 00000000000..4535ece8b14 --- /dev/null +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -0,0 +1,93 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { FileTreeBatchOperation, GitStatus } from "@pierre/trees"; + +import { resolveFileDiffPath } from "~/lib/diffRendering"; + +/** One changed file as the tree shows it: its current path and how it changed. */ +export interface DiffFileTreeEntry { + readonly path: string; + readonly status: GitStatus; +} + +function toGitStatus(file: FileDiffMetadata): GitStatus { + switch (file.type) { + case "new": + return "added"; + case "deleted": + return "deleted"; + case "rename-pure": + case "rename-changed": + return "renamed"; + case "change": + return "modified"; + } +} + +/** Maps parsed diff files to tree entries, keeping the diff's own order. */ +export function diffFileTreeEntries( + files: ReadonlyArray, +): ReadonlyArray { + return files.map((file) => ({ path: resolveFileDiffPath(file), status: toGitStatus(file) })); +} + +/** + * Every directory on the way to each file, registered with the trailing slash Pierre uses for + * directory ids. Parents come before children so the tree can add them in order. + */ +export function collectDirectoryPaths(paths: ReadonlyArray): ReadonlyArray { + const directories = new Set(); + for (const path of paths) { + const segments = path.split("/"); + let directory = ""; + for (const segment of segments.slice(0, -1)) { + directory += `${segment}/`; + directories.add(directory); + } + } + return [...directories]; +} + +function pathDepth(path: string): number { + return path.split("/").filter(Boolean).length; +} + +/** + * The adds and removes that turn one set of file paths into another, so a diff that changes + * under the reader (a new slice, a refresh after an agent edit) keeps the directories they + * have already opened or closed instead of rebuilding the tree from scratch. + * + * Directories are removed only once no file needs them; a directory that gains its first file + * is added before that file. + */ +export function buildDiffFileTreeUpdates( + previousPaths: ReadonlyArray, + nextPaths: ReadonlyArray, +): FileTreeBatchOperation[] { + const previousDirectories = new Set(collectDirectoryPaths(previousPaths)); + const nextDirectories = new Set(collectDirectoryPaths(nextPaths)); + const previous = new Set(previousPaths); + const next = new Set(nextPaths); + const updates: FileTreeBatchOperation[] = []; + + for (const path of previousPaths) { + if (!next.has(path)) updates.push({ type: "remove", path }); + } + // Deepest first: a directory can only go once everything under it has. + const removedDirectories = [...previousDirectories] + .filter((directory) => !nextDirectories.has(directory)) + .toSorted((left, right) => pathDepth(right) - pathDepth(left)); + for (const directory of removedDirectories) { + updates.push({ type: "remove", path: directory, recursive: true }); + } + + // Shallowest first: a file's directory has to exist before the file does. + const addedDirectories = [...nextDirectories] + .filter((directory) => !previousDirectories.has(directory)) + .toSorted((left, right) => pathDepth(left) - pathDepth(right)); + for (const directory of addedDirectories) updates.push({ type: "add", path: directory }); + for (const path of nextPaths) { + if (!previous.has(path)) updates.push({ type: "add", path }); + } + + return updates; +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 5e4ce19335a..dc3cfcef013 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -19,6 +19,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; @@ -38,18 +39,6 @@ interface FileBrowserPanelProps { workspaceMutationId: string | null; } -const TREE_UNSAFE_CSS = ` - :host { - --trees-bg-override: transparent; - --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); - --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); - --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); - --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; - } - button[data-type='item'] { border-radius: 5px; } -`; - function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -255,7 +244,7 @@ export default function FileBrowserPanel({ }, paths: [], search: false, - unsafeCSS: TREE_UNSAFE_CSS, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => @@ -429,10 +418,7 @@ export default function FileBrowserPanel({ model={model} aria-label={`${projectName} files`} className="min-h-0 flex-1 overflow-hidden" - style={{ - colorScheme: resolvedTheme, - ["--trees-fg-override" as string]: "var(--contrast-foreground)", - }} + style={pierreTreeStyle(resolvedTheme)} /> )}
diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index ebae8847bf3..cc9bf0f6188 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1,5 +1,5 @@ import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs"; -import type { CodeViewDiffItem } from "@pierre/diffs/react"; +import type { CodeViewDiffItem, CodeViewHandle } from "@pierre/diffs/react"; import type { EnvironmentId, PullRequestDetailView, @@ -16,6 +16,7 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + FolderTreeIcon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, @@ -24,8 +25,10 @@ import { XIcon, } from "lucide-react"; import { useAtomRefresh } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; @@ -57,6 +60,8 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; +import { DiffFileTree } from "../diffs/DiffFileTree"; +import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; @@ -97,6 +102,8 @@ type ReviewAnnotation = DiffLineAnnotation; /** Commits per press of "Show more" in the scope menu. */ const COMMIT_PAGE_SIZE = 10; +const PULL_REQUEST_FILE_TREE_STORAGE_KEY = "t3code.pullRequestFileTreeOpen"; + /** One answer from the host: a whole number of files, and where the next one carries on. */ interface DiffSlice { /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */ @@ -220,6 +227,11 @@ export function PullRequestCodeTab({ const diffLayout = settings.diffLayout; const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( + PULL_REQUEST_FILE_TREE_STORAGE_KEY, + false, + Schema.Boolean, + ); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -238,6 +250,7 @@ export function PullRequestCodeTab({ readonly slices: ReadonlyArray; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map()); + const viewerRef = useRef | null>(null); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; @@ -544,34 +557,35 @@ export function PullRequestCodeTab({ [items], ); const allFilesCollapsed = areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys); + const fileTreeEntries = useMemo(() => diffFileTreeEntries(files), [files]); + + // A failed slice must not be asked for again on its own. The files already loaded keep the + // sentinel on screen, so re-arming it after a failure would request the same slice forever. + const canLoadNextSlice = + nextCursor !== null && + nextCursor !== cursor && + !diffQuery.isPending && + diffQuery.error === null; + const loadNextSlice = useCallback(() => { + if (nextCursor === null) return; + setSliceState((previous) => ({ ...previous, cursor: nextCursor })); + }, [nextCursor]); // The sentinel is held as state rather than a ref because the viewer mounts its own footer: // an effect reading a ref could run before that node exists and would never arm the observer. const [sentinel, setSentinel] = useState(null); useEffect(() => { - // A failed slice must stop the observer. The files already loaded keep the sentinel on - // screen, so re-arming it after a failure would ask for the same slice again, forever. - if ( - sentinel === null || - nextCursor === null || - nextCursor === cursor || - diffQuery.isPending || - diffQuery.error !== null - ) { - return; - } + if (sentinel === null || !canLoadNextSlice) return; const observer = new IntersectionObserver( (observed) => { - if (observed.some((entry) => entry.isIntersecting)) { - setSliceState((previous) => ({ ...previous, cursor: nextCursor })); - } + if (observed.some((entry) => entry.isIntersecting)) loadNextSlice(); }, // Start the next slice slightly before the sentinel is on screen. { rootMargin: "240px" }, ); observer.observe(sentinel); return () => observer.disconnect(); - }, [cursor, diffQuery.error, diffQuery.isPending, nextCursor, sentinel]); + }, [canLoadNextSlice, loadNextSlice, sentinel]); // A stable identity: the viewer's SlotPortals memoizes each file's header/annotation portal on // these render props, so a fresh function here would recreate every visible file's portal on @@ -589,6 +603,23 @@ export function PullRequestCodeTab({ [], ); + // Held as state so the scroll runs after a folded file has been drawn open; scrolling in the + // same tick would land on the folded header's position. + const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null); + useEffect(() => { + if (treeReveal === null) return; + viewerRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" }); + }, [treeReveal]); + const revealFile = useCallback( + (path: string) => { + const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path); + if (item === undefined) return; + if (item.collapsed === true) toggleFile(item.id); + setTreeReveal((current) => ({ fileKey: item.id, id: (current?.id ?? 0) + 1 })); + }, + [items, toggleFile], + ); + const toggleAllFiles = () => { // Held as an override of the default rather than as the file keys on screen: a diff that is // still paging would otherwise bring its next slice in folded, moments after the reader @@ -1150,6 +1181,26 @@ export function PullRequestCodeTab({ {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {fileKeys.length > 0 ? ( + + setFileTreeOpen(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + + ) : null}
); @@ -1305,58 +1356,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} + + // 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]" + viewerRef={viewerRef} + 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/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts new file mode 100644 index 00000000000..1c7bd7f2d53 --- /dev/null +++ b/apps/web/src/pierre-tree-theme.ts @@ -0,0 +1,22 @@ +import type { CSSProperties } from "react"; + +/** Shadow-root overrides that make a Pierre file tree read as part of the app chrome. */ +export const PIERRE_TREE_UNSAFE_CSS = ` + :host { + --trees-bg-override: transparent; + --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); + --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); + --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); + --trees-font-family-override: var(--font-sans); + --trees-font-size-override: 12px; + } + button[data-type='item'] { border-radius: 5px; } +`; + +/** Host styles that keep a Pierre tree on the active color scheme and foreground. */ +export function pierreTreeStyle(colorScheme: "light" | "dark"): CSSProperties { + return { + colorScheme, + ["--trees-fg-override" as string]: "var(--contrast-foreground)", + }; +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 10d1995fa66..937cf91c903 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -56,6 +56,9 @@ T3 Code works with the platforms your team already uses: brought in from the base branch - While working in a thread, open linked reviews in the same compact right-panel tabs without leaving the conversation +- Show a file tree next to a review's **Code** tab, or a thread's **Diff** panel, to browse the + changed files as folders and jump straight to any of them. The toolbar toggle remembers your + choice. - Enable **Settings → General → Proactive panels** to open a newly linked review automatically and switch to the completed turn's diff when agent work finishes - Open the review directly in your browser with one click From 5f84efa1ec2fb3cd6f6c54545cc34bb77ac1ddb8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 2 Sep 2026 23:12:15 -0500 Subject: [PATCH 16/46] feat(web): add PageUp/PageDown chat navigation (#9315) --- apps/web/src/components/ChatView.tsx | 37 +++ .../src/components/ComposerPromptEditor.tsx | 53 +++ apps/web/src/components/chat/ChatComposer.tsx | 9 + .../chat/pageScrollController.test.ts | 279 ++++++++++++++++ .../components/chat/pageScrollController.ts | 307 ++++++++++++++++++ 5 files changed, 685 insertions(+) create mode 100644 apps/web/src/components/chat/pageScrollController.test.ts create mode 100644 apps/web/src/components/chat/pageScrollController.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e7f1a23bbc4..660c0baea71 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,6 +63,7 @@ import { Suspense, useCallback, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -291,6 +292,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -4314,6 +4316,38 @@ function ChatViewContent(props: ChatViewProps) { }, [composerOverlayHeight], ); + const pageScrollControllerRef = useRef | null>( + null, + ); + const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { + if (key === "PageUp" && timelineRealContentOverflowsViewport()) { + cancelTimelineLiveFollowForUserNavigation(); + } + }); + useEffect(() => { + const controller = createPageScrollController({ + getContainer: () => legendListRef.current?.getScrollableNode() ?? null, + getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, + onScrollStart: handlePageScrollStart, + }); + pageScrollControllerRef.current = controller; + + return () => { + controller.dispose(); + if (pageScrollControllerRef.current === controller) { + pageScrollControllerRef.current = null; + } + }; + }, [composerOverlayElement]); + const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => { + pageScrollControllerRef.current?.handleKeyDown(key); + }, []); + const onComposerPageScrollKeyUp = useCallback((key: string) => { + pageScrollControllerRef.current?.handleKeyUp(key); + }, []); + const onComposerPageScrollRelease = useCallback(() => { + pageScrollControllerRef.current?.releaseActiveKey(); + }, []); // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -7637,6 +7671,9 @@ function ChatViewContent(props: ChatViewProps) { composerFilesRef={composerFilesRef} composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} + onPageScrollKeyDown={onComposerPageScrollKeyDown} + onPageScrollKeyUp={onComposerPageScrollKeyUp} + onPageScrollRelease={onComposerPageScrollRelease} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index efec8a440fe..a9c9a5adb9c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -83,6 +83,7 @@ import { } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; @@ -916,6 +917,9 @@ interface ComposerPromptEditorProps { key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; + onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; + onPageScrollKeyUp?: (key: string) => void; + onPageScrollRelease?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -1557,6 +1561,9 @@ function ComposerPromptEditorInner({ onRemoveTerminalContext, onChange, onCommandKeyDown, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1829,6 +1836,46 @@ function ComposerPromptEditorInner({ data-testid="composer-editor" aria-placeholder={placeholder} placeholder={} + onKeyDown={(event) => { + if ( + event.key === "Control" || + event.key === "Meta" || + event.key === "Alt" || + event.key === "Shift" + ) { + onPageScrollRelease?.(); + } + + if (event.key !== "PageUp" && event.key !== "PageDown") { + return; + } + + const pageScrollKey = getTimelinePageScrollKey({ + altKey: event.altKey, + clientHeight: event.currentTarget.clientHeight, + ctrlKey: event.ctrlKey, + defaultPrevented: event.defaultPrevented, + isComposing: event.nativeEvent.isComposing, + key: event.key, + keyCode: event.keyCode, + metaKey: event.metaKey, + scrollHeight: event.currentTarget.scrollHeight, + scrollTop: event.currentTarget.scrollTop, + shiftKey: event.shiftKey, + }); + if (!pageScrollKey) { + onPageScrollRelease?.(); + return; + } + if (!onPageScrollKeyDown) { + return; + } + + event.preventDefault(); + onPageScrollKeyDown(pageScrollKey); + }} + onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} + onBlur={onPageScrollRelease} onPaste={onPaste} /> } @@ -1868,6 +1915,9 @@ export function ComposerPromptEditor({ onRemoveTerminalContext, onChange, onCommandKeyDown, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1912,6 +1962,9 @@ export function ComposerPromptEditor({ onPaste={onPaste} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} + {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} + {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})} + {...(onPageScrollRelease ? { onPageScrollRelease } : {})} {...(className ? { className } : {})} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 349faeac072..f7ca8de57d3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -710,6 +710,9 @@ export interface ChatComposerProps { composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; + onPageScrollKeyDown: (key: "PageUp" | "PageDown") => void; + onPageScrollKeyUp: (key: string) => void; + onPageScrollRelease: () => void; // Callbacks onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; @@ -801,6 +804,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerFilesRef, composerTerminalContextsRef, composerElementContextsRef, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, onSend, onInterrupt, onImplementPlanInNewThread, @@ -4187,6 +4193,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} + onPageScrollKeyDown={onPageScrollKeyDown} + onPageScrollKeyUp={onPageScrollKeyUp} + onPageScrollRelease={onPageScrollRelease} onPaste={onComposerPaste} placeholder={ isComposerApprovalState diff --git a/apps/web/src/components/chat/pageScrollController.test.ts b/apps/web/src/components/chat/pageScrollController.test.ts new file mode 100644 index 00000000000..9fe399f50d3 --- /dev/null +++ b/apps/web/src/components/chat/pageScrollController.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from "vite-plus/test"; + +import { + createPageScrollController, + getTimelinePageScrollKey, + getPageScrollDistancePx, + getPageScrollMultiplier, + getPageScrollVelocityPxPerMs, + PAGE_SCROLL_ACCELERATION_MS, + PAGE_SCROLL_ANIMATION_MS, + PAGE_SCROLL_MAX_MULTIPLIER, +} from "./pageScrollController"; + +class TestClock { + private currentTime = 0; + private nextHandle = 1; + private animationFrames = new Map(); + private timeouts = new Map void }>(); + + readonly env = { + now: () => this.currentTime, + requestAnimationFrame: (callback: FrameRequestCallback) => { + const handle = this.nextHandle; + this.nextHandle += 1; + this.animationFrames.set(handle, callback); + return handle; + }, + cancelAnimationFrame: (handle: number) => { + this.animationFrames.delete(handle); + }, + setTimeout: (callback: () => void, delay: number) => { + const handle = this.nextHandle; + this.nextHandle += 1; + this.timeouts.set(handle, { at: this.currentTime + delay, callback }); + return handle; + }, + clearTimeout: (handle: number) => { + this.timeouts.delete(handle); + }, + }; + + advanceBy(ms: number, frameMs = 16) { + const target = this.currentTime + ms; + + while (this.currentTime < target) { + this.currentTime = Math.min(target, this.currentTime + frameMs); + this.flushTimeouts(); + this.flushAnimationFrames(); + } + } + + private flushTimeouts() { + let hasDueTimeouts = true; + while (hasDueTimeouts) { + hasDueTimeouts = false; + + for (const [handle, timeout] of this.timeouts) { + if (timeout.at > this.currentTime) { + continue; + } + + this.timeouts.delete(handle); + timeout.callback(); + hasDueTimeouts = true; + } + } + } + + private flushAnimationFrames() { + if (this.animationFrames.size === 0) { + return; + } + + const frames = [...this.animationFrames.values()]; + this.animationFrames.clear(); + + for (const callback of frames) { + callback(this.currentTime); + } + } +} + +describe("page scroll helpers", () => { + const composerPageScrollEvent = ( + overrides: Partial[0]> = {}, + ) => ({ + altKey: false, + clientHeight: 200, + ctrlKey: false, + defaultPrevented: false, + isComposing: false, + key: "PageDown", + keyCode: 34, + metaKey: false, + scrollHeight: 200, + scrollTop: 0, + shiftKey: false, + ...overrides, + }); + + test("leaves page keys to IME composition", () => { + expect(getTimelinePageScrollKey(composerPageScrollEvent({ isComposing: true }))).toBeNull(); + expect(getTimelinePageScrollKey(composerPageScrollEvent({ keyCode: 229 }))).toBeNull(); + }); + + test("leaves page keys to an overflowing composer until it reaches the boundary", () => { + expect( + getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 0 })), + ).toBeNull(); + expect( + getTimelinePageScrollKey( + composerPageScrollEvent({ + key: "PageUp", + keyCode: 33, + scrollHeight: 600, + scrollTop: 400, + }), + ), + ).toBeNull(); + + expect( + getTimelinePageScrollKey( + composerPageScrollEvent({ + key: "PageUp", + keyCode: 33, + scrollHeight: 600, + scrollTop: 0, + }), + ), + ).toBe("PageUp"); + expect( + getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 400 })), + ).toBe("PageDown"); + }); + + test("hands off page keys within a fractional pixel of the composer boundary", () => { + expect( + getTimelinePageScrollKey( + composerPageScrollEvent({ + key: "PageUp", + keyCode: 33, + scrollHeight: 600, + scrollTop: 0.5, + }), + ), + ).toBe("PageUp"); + expect( + getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 399.5 })), + ).toBe("PageDown"); + }); + + test("ramps multiplier over time and caps at the max velocity", () => { + expect(getPageScrollMultiplier(0)).toBe(1); + expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS / 2)).toBeCloseTo(1.5); + expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS * 5)).toBe( + PAGE_SCROLL_MAX_MULTIPLIER, + ); + }); + + test("derives the hold velocity from page size and acceleration", () => { + expect( + getPageScrollVelocityPxPerMs({ + holdElapsedMs: 0, + pageScrollDistancePx: 600, + }), + ).toBeCloseTo(4); + expect( + getPageScrollVelocityPxPerMs({ + holdElapsedMs: PAGE_SCROLL_ACCELERATION_MS * 5, + pageScrollDistancePx: 600, + }), + ).toBeCloseTo(8); + }); +}); + +describe("createPageScrollController", () => { + test("keeps a single page scroll when the key is tapped", () => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 1_800, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + env: clock.env, + }); + + controller.handleKeyDown("PageDown"); + controller.handleKeyUp("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); + + expect(container.scrollTop).toBeCloseTo( + getPageScrollDistancePx({ + containerHeightPx: 600, + scrollPaddingBottomPx: 24, + }), + 5, + ); + }); + + test("continues scrolling on hold without repeated keydown events and stops on keyup", () => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 4_000, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + env: clock.env, + }); + controller.handleKeyDown("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50); + + const afterHoldStarts = container.scrollTop; + clock.advanceBy(200); + + expect(container.scrollTop).toBeGreaterThan(afterHoldStarts); + + const stoppedAt = container.scrollTop; + controller.handleKeyUp("PageDown"); + clock.advanceBy(250); + + expect(container.scrollTop).toBe(stoppedAt); + }); + + test("notifies once when a page scroll starts", () => { + const clock = new TestClock(); + const started: string[] = []; + const controller = createPageScrollController({ + getContainer: () => ({ + clientHeight: 600, + scrollHeight: 1_800, + scrollTop: 600, + getBoundingClientRect: () => ({ height: 600 }), + }), + getScrollPaddingBottomPx: () => 24, + onScrollStart: (key) => started.push(key), + env: clock.env, + }); + + controller.handleKeyDown("PageUp"); + controller.handleKeyDown("PageUp"); + + expect(started).toEqual(["PageUp"]); + }); + + test("does not start a page scroll at the timeline boundary", () => { + const clock = new TestClock(); + const started: string[] = []; + const container = { + clientHeight: 600, + scrollHeight: 1_800, + scrollTop: 0.5, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + onScrollStart: (key) => started.push(key), + env: clock.env, + }); + + controller.handleKeyDown("PageUp"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2); + + container.scrollTop = container.scrollHeight - container.clientHeight - 0.5; + controller.handleKeyDown("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2); + + expect(started).toEqual([]); + expect(container.scrollTop).toBe(1_199.5); + }); +}); diff --git a/apps/web/src/components/chat/pageScrollController.ts b/apps/web/src/components/chat/pageScrollController.ts new file mode 100644 index 00000000000..4c3999b1efc --- /dev/null +++ b/apps/web/src/components/chat/pageScrollController.ts @@ -0,0 +1,307 @@ +export const PAGE_SCROLL_ANIMATION_MS = 150; +export const PAGE_SCROLL_ACCELERATION_MS = 400; +export const PAGE_SCROLL_MAX_MULTIPLIER = 2; + +const PAGE_SCROLL_ALIGNMENT_OFFSET_PX = 36; +const PAGE_SCROLL_BOUNDARY_EPSILON_PX = 1; +const PAGE_SCROLL_HOLD_DELAY_MS = PAGE_SCROLL_ANIMATION_MS; + +export type PageScrollKey = "PageUp" | "PageDown"; + +type PageScrollMetrics = { + clientHeight: number; + scrollHeight: number; + scrollTop: number; +}; + +function canScrollInDirection( + { clientHeight, scrollHeight, scrollTop }: PageScrollMetrics, + key: PageScrollKey, +): boolean { + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const clampedScrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop)); + return key === "PageUp" + ? clampedScrollTop > PAGE_SCROLL_BOUNDARY_EPSILON_PX + : clampedScrollTop < maxScrollTop - PAGE_SCROLL_BOUNDARY_EPSILON_PX; +} + +export function getTimelinePageScrollKey({ + altKey, + clientHeight, + ctrlKey, + defaultPrevented, + isComposing, + key, + keyCode, + metaKey, + scrollHeight, + scrollTop, + shiftKey, +}: { + altKey: boolean; + clientHeight: number; + ctrlKey: boolean; + defaultPrevented: boolean; + isComposing: boolean; + key: string; + keyCode: number; + metaKey: boolean; + scrollHeight: number; + scrollTop: number; + shiftKey: boolean; +}): PageScrollKey | null { + if (key !== "PageUp" && key !== "PageDown") { + return null; + } + if ( + defaultPrevented || + isComposing || + keyCode === 229 || + altKey || + ctrlKey || + metaKey || + shiftKey + ) { + return null; + } + + const editorCanScroll = canScrollInDirection({ clientHeight, scrollHeight, scrollTop }, key); + return editorCanScroll ? null : key; +} + +type PageScrollContainer = PageScrollMetrics & { + getBoundingClientRect: () => { + height: number; + }; +}; + +type PageScrollEnv = { + now: () => number; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (handle: number) => void; + setTimeout: (callback: () => void, delay: number) => number; + clearTimeout: (handle: number) => void; +}; + +function getDefaultEnv(): PageScrollEnv { + return { + now: () => performance.now(), + requestAnimationFrame: (callback) => window.requestAnimationFrame(callback), + cancelAnimationFrame: (handle) => window.cancelAnimationFrame(handle), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + clearTimeout: (handle) => window.clearTimeout(handle), + }; +} + +export function getPageScrollMultiplier(holdElapsedMs: number): number { + const progress = Math.max(0, holdElapsedMs) / PAGE_SCROLL_ACCELERATION_MS; + return 1 + Math.min(progress, 1) * (PAGE_SCROLL_MAX_MULTIPLIER - 1); +} + +export function getPageScrollVelocityPxPerMs({ + holdElapsedMs, + pageScrollDistancePx, +}: { + holdElapsedMs: number; + pageScrollDistancePx: number; +}): number { + return (pageScrollDistancePx * getPageScrollMultiplier(holdElapsedMs)) / PAGE_SCROLL_ANIMATION_MS; +} + +export function getPageScrollDistancePx({ + containerHeightPx, + scrollPaddingBottomPx, +}: { + containerHeightPx: number; + scrollPaddingBottomPx: number; +}): number { + return Math.max(0, containerHeightPx - PAGE_SCROLL_ALIGNMENT_OFFSET_PX - scrollPaddingBottomPx); +} + +function getDirection(key: PageScrollKey): -1 | 1 { + return key === "PageUp" ? -1 : 1; +} + +function easeInOut(progress: number): number { + const ax = 3 * 0.42 - 3 * 0.58 + 1; + const bx = 3 * (0.58 - 2 * 0.42); + const cx = 3 * 0.42; + const ay = -2; + const by = 3; + const x = (value: number) => ((ax * value + bx) * value + cx) * value; + const y = (value: number) => (ay * value + by) * value * value; + + let value = progress; + for (let index = 0; index < 5; index += 1) { + const delta = x(value) - progress; + const derivative = (3 * ax * value + 2 * bx) * value + cx; + if (Math.abs(delta) < 1e-4 || derivative === 0) { + break; + } + value -= delta / derivative; + value = Math.min(1, Math.max(0, value)); + } + + return y(value); +} + +export function createPageScrollController({ + getContainer, + getScrollPaddingBottomPx, + onScrollStart, + env = getDefaultEnv(), +}: { + getContainer: () => PageScrollContainer | null; + getScrollPaddingBottomPx: () => number; + onScrollStart?: (key: PageScrollKey) => void; + env?: PageScrollEnv; +}) { + const state = { + activeKey: null as PageScrollKey | null, + discreteAnimationFrame: 0, + holdDelayTimeout: 0, + holdAnimationFrame: 0, + holdStartTime: 0, + lastFrameTime: 0, + holdActive: false, + }; + + const readPageScrollDistance = (container: PageScrollContainer) => + getPageScrollDistancePx({ + containerHeightPx: container.getBoundingClientRect().height, + scrollPaddingBottomPx: getScrollPaddingBottomPx(), + }); + + const cancelDiscreteAnimation = () => { + if (state.discreteAnimationFrame === 0) { + return; + } + + env.cancelAnimationFrame(state.discreteAnimationFrame); + state.discreteAnimationFrame = 0; + }; + + const stop = ({ + cancelDiscreteAnimation: shouldCancelDiscreteAnimation, + }: { + cancelDiscreteAnimation: boolean; + }) => { + if (state.holdDelayTimeout !== 0) { + env.clearTimeout(state.holdDelayTimeout); + state.holdDelayTimeout = 0; + } + + if (state.holdAnimationFrame !== 0) { + env.cancelAnimationFrame(state.holdAnimationFrame); + state.holdAnimationFrame = 0; + } + + if (shouldCancelDiscreteAnimation) { + cancelDiscreteAnimation(); + } + + state.activeKey = null; + state.holdStartTime = 0; + state.lastFrameTime = 0; + state.holdActive = false; + }; + + const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => { + cancelDiscreteAnimation(); + + const startScrollTop = container.scrollTop; + const startTime = env.now(); + + const step = (now: number) => { + const progress = Math.min(1, (now - startTime) / PAGE_SCROLL_ANIMATION_MS); + container.scrollTop = startScrollTop + deltaY * easeInOut(progress); + + if (progress < 1) { + state.discreteAnimationFrame = env.requestAnimationFrame(step); + return; + } + + state.discreteAnimationFrame = 0; + }; + + state.discreteAnimationFrame = env.requestAnimationFrame(step); + }; + + const startHoldScroll = (key: PageScrollKey, container: PageScrollContainer) => { + cancelDiscreteAnimation(); + state.holdActive = true; + state.holdStartTime = env.now(); + state.lastFrameTime = state.holdStartTime; + + const step = (now: number) => { + if (state.activeKey !== key) { + state.holdAnimationFrame = 0; + return; + } + + const deltaMs = now - state.lastFrameTime; + state.lastFrameTime = now; + + const velocityPxPerMs = getPageScrollVelocityPxPerMs({ + holdElapsedMs: now - state.holdStartTime, + pageScrollDistancePx: readPageScrollDistance(container), + }); + const previousScrollTop = container.scrollTop; + container.scrollTop = previousScrollTop + velocityPxPerMs * deltaMs * getDirection(key); + + if (deltaMs > 0 && container.scrollTop === previousScrollTop) { + stop({ cancelDiscreteAnimation: true }); + return; + } + + state.holdAnimationFrame = env.requestAnimationFrame(step); + }; + + state.holdAnimationFrame = env.requestAnimationFrame(step); + }; + + return { + handleKeyDown(key: PageScrollKey) { + const container = getContainer(); + if (!container) { + return; + } + + if (!canScrollInDirection(container, key)) { + return; + } + + if (state.activeKey === key) { + return; + } + + stop({ cancelDiscreteAnimation: true }); + state.activeKey = key; + onScrollStart?.(key); + + smoothScrollBy(container, readPageScrollDistance(container) * getDirection(key)); + + state.holdDelayTimeout = env.setTimeout(() => { + state.holdDelayTimeout = 0; + if (state.activeKey !== key) { + return; + } + + startHoldScroll(key, container); + }, PAGE_SCROLL_HOLD_DELAY_MS); + }, + handleKeyUp(key: string) { + if (state.activeKey !== key) { + return; + } + + stop({ cancelDiscreteAnimation: state.holdActive }); + }, + releaseActiveKey() { + stop({ cancelDiscreteAnimation: state.holdActive }); + }, + dispose() { + stop({ cancelDiscreteAnimation: true }); + }, + }; +} From 2a3cfe456375fd34b906f849b04706109dc74170 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 00:21:27 -0400 Subject: [PATCH 17/46] fix(web): collapse PR header actions to icons when narrow (#9334) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestDetailPanel.tsx | 233 ++++++++++++++---- 1 file changed, 179 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e289db62593..adbf5342a7d 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1296,8 +1296,8 @@ export function PullRequestDetailPanel({ return (
-
-
+
+
-
+
{detail ? ( <> {/* Checking a pull request out is the reason to open one here at all, so it is a @@ -1415,9 +1415,17 @@ export function PullRequestDetailPanel({ + } @@ -1453,17 +1461,39 @@ export function PullRequestDetailPanel({ ) : null} {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( - + + + + + } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : "Approve workflows to run"} + + ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} @@ -1471,62 +1501,150 @@ export function PullRequestDetailPanel({ + - {armedAutoMergeLabel} + {armedAutoMergeLabel} } /> - The host will merge this on its own once its requirements are met + {armedAutoMergeLabel}: the host will merge this on its own once its requirements + are met ) : null} {primaryAction === "resolve" ? ( - + + + + + } + /> + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + ) : primaryAction === "ready" ? ( - + + + + + } + /> + Ready for review + ) : primaryAction === "enable-auto-merge" ? ( - + + + + + } + /> + + {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} + + ) : primaryAction === "auto-merge-armed" ? ( - - {armedAutoMergeLabel} + + + {armedAutoMergeLabel} } /> - The host will merge this on its own once its requirements are met + {armedAutoMergeLabel}: the host will merge this on its own once its requirements + are met ) : primaryAction === "merge" ? ( - + + + + + } + /> + + {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + + ) : (primaryAction === "merged" || primaryAction === "closed") && statePresentation !== null ? ( @@ -1857,10 +1975,17 @@ export function PullRequestDetailPanel({ {detail ? (
{titleDraft === null ? ( -
-

- {detail.title} -

+
+ + + {detail.title} + + } + /> + {detail.title} + {canEditPullRequestChangeRequest(detail) ? ( @@ -1004,6 +1003,7 @@ export default function GitActionsControl({ [activeThreadRef], ); const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); + const openLink = useOpenLink(activeThreadRef); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -1238,15 +1238,6 @@ export default function GitActionsControl({ onOpenPullRequest(openPr.number); return; } - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - data: threadToastData, - }); - return; - } const prUrl = openPr?.url ?? null; if (!prUrl) { toastManager.add({ @@ -1256,7 +1247,7 @@ export default function GitActionsControl({ }); return; } - void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + void openLink(prUrl).catch((err: unknown) => { console.error(err); toastManager.add( stackedThreadToast({ @@ -1267,7 +1258,7 @@ export default function GitActionsControl({ }), ); }); - }, [gitStatusForActions, onOpenPullRequest, threadToastData]); + }, [gitStatusForActions, onOpenPullRequest, openLink, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ @@ -2010,6 +2001,7 @@ export default function GitActionsControl({ open={isPublishDialogOpen} onOpenChange={setIsPublishDialogOpen} environmentId={activeEnvironmentId} + threadRef={activeThreadRef} gitCwd={gitCwd} /> diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index bdcd1626548..1c64818c579 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -728,10 +728,8 @@ export function TerminalViewport({ }; void openTerminalLinkInPreview({ url: text, - position: { x: event.clientX, y: event.clientY }, threadRef, openPreview, - localApi, fallbackToBrowser, }); return; diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 9a5656d76a1..46dd33f7beb 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -1,11 +1,10 @@ -import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { openTerminalLinkInPreview, - TerminalLinkContextMenuShowError, TerminalLinkPreviewOpenError, } from "./openTerminalLinkInPreview"; @@ -30,6 +29,15 @@ vi.mock("~/browser/browserDefaults", () => ({ browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId, })); +const linkTargetMocks = vi.hoisted(() => ({ + preference: vi.fn<() => "system" | "app">(), +})); + +vi.mock("~/browser/browserLinkTarget", () => ({ + resolveBrowserLinkTargetPreference: async () => linkTargetMocks.preference(), + isWebUrl: (url: string) => /^https?:/u.test(url), +})); + const hydratedDefaults = { viewport: { _tag: "fixed", width: 1280, height: 720 } as const, profileId: "work", @@ -50,7 +58,9 @@ const snapshot: PreviewSessionSnapshot = { }; beforeEach(() => { + browserDefaultsMocks.resolve.mockReset(); browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); + linkTargetMocks.preference.mockReturnValue("app"); }); afterEach(() => { @@ -58,6 +68,37 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it("opens in the system browser while that is the configured target", async () => { + linkTargetMocks.preference.mockReturnValue("system"); + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openTerminalLinkInPreview({ + url: "http://localhost:3000/", + threadRef, + openPreview, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + }); + + it("opens public URLs in-app too, not only local servers", async () => { + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }); + + expect(openPreview).toHaveBeenCalledOnce(); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + }); + it("waits for hydrated viewport and profile defaults before opening", async () => { let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined; browserDefaultsMocks.resolve.mockImplementationOnce( @@ -70,14 +111,8 @@ describe("openTerminalLinkInPreview", () => { const opening = openTerminalLinkInPreview({ url: "http://localhost:3000/", - position: { x: 12, y: 34 }, threadRef, openPreview, - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser: vi.fn(), }); @@ -97,42 +132,6 @@ describe("openTerminalLinkInPreview", () => { }); }); - it("preserves context-menu failures with terminal link context before falling back", async () => { - const cause = new Error("menu unavailable"); - const fallbackToBrowser = vi.fn(); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); - - await openTerminalLinkInPreview({ - url: "http://localhost:3000/path?token=secret", - position: { x: 12, y: 34 }, - threadRef, - openPreview, - localApi: { - contextMenu: { - show: vi.fn(async () => { - throw cause; - }), - }, - } as unknown as LocalApi, - fallbackToBrowser, - }); - - expect(fallbackToBrowser).toHaveBeenCalledOnce(); - expect(openPreview).not.toHaveBeenCalled(); - expect(reportError).toHaveBeenCalledOnce(); - const error = reportError.mock.calls[0]?.[0]; - expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); - expect(error).toMatchObject({ - environmentId: "local", - threadId: "thread-1", - targetOrigin: "http://localhost:3000", - cause, - }); - expect(error.message).not.toContain("menu unavailable"); - expect(error.targetOrigin).not.toContain("secret"); - }); - it("preserves the complete preview failure cause before falling back", async () => { const rpcError = new Error("preview unavailable"); const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); @@ -141,14 +140,8 @@ describe("openTerminalLinkInPreview", () => { await openTerminalLinkInPreview({ url: "http://127.0.0.1:5173/", - position: { x: 12, y: 34 }, threadRef, openPreview: async () => AsyncResult.failure(cause), - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser, }); @@ -171,14 +164,8 @@ describe("openTerminalLinkInPreview", () => { await openTerminalLinkInPreview({ url: "http://localhost:5173/", - position: { x: 12, y: 34 }, threadRef, openPreview: async () => AsyncResult.failure(Cause.interrupt()), - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser, }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index f5725fc2acf..4a48403c7e5 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,6 +1,5 @@ -import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; -import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; import { @@ -8,6 +7,7 @@ import { browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -20,15 +20,6 @@ const terminalLinkErrorContext = { cause: Schema.Defect(), }; -export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( - "TerminalLinkContextMenuShowError", - terminalLinkErrorContext, -) { - override get message(): string { - return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; - } -} - export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( "TerminalLinkPreviewOpenError", terminalLinkErrorContext, @@ -40,20 +31,26 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass { readonly url: string; - readonly position: { x: number; y: number }; readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; - readonly localApi: LocalApi; readonly fallbackToBrowser: () => void; } +/** + * Opens a terminal hyperlink where the "Open links in" setting says. Terminal + * links are activated with the platform modifier already held, so unlike chat + * links the modifier cannot double as the system-browser override; the setting + * alone decides, and the system browser is the fallback whenever the in-app + * one cannot take the URL. + */ export async function openTerminalLinkInPreview( input: OpenTerminalLinkInPreviewInput, ): Promise { const supportsPreview = - isPreviewableUrl(input.url) && + isWebUrl(input.url) && isPreviewSupportedInRuntime() && - input.threadRef.threadId.length > 0; + input.threadRef.threadId.length > 0 && + (await resolveBrowserLinkTargetPreference()) === "app"; if (!supportsPreview) { input.fallbackToBrowser(); @@ -66,59 +63,32 @@ export async function openTerminalLinkInPreview( targetOrigin: new URL(input.url).origin, }; - let choice: "open-in-preview" | "open-in-browser" | null; - try { - choice = await input.localApi.contextMenu.show( - [ - { id: "open-in-preview", label: "Open in preview" }, - { id: "open-in-browser", label: "Open in browser" }, - ], - input.position, - ); - } catch (cause) { + const defaults = await resolveBrowserDefaults(); + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Same reason as `openUrlInPreview`: this path handles its own result + // mapping, so the configured defaults are applied explicitly. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } console.error( - new TerminalLinkContextMenuShowError({ + new TerminalLinkPreviewOpenError({ ...errorContext, - cause, + cause: result.cause, }), ); input.fallbackToBrowser(); return; } - - if (choice === "open-in-preview") { - const defaults = await resolveBrowserDefaults(); - const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { - threadId: input.threadRef.threadId, - url: input.url, - // Same reason as `openUrlInPreview`: this path handles its own result - // mapping, so the configured defaults are applied explicitly. - viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), - }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - console.error( - new TerminalLinkPreviewOpenError({ - ...errorContext, - cause: result.cause, - }), - ); - input.fallbackToBrowser(); - return; - } - recordVisitForThread(input.threadRef, input.url); - applyPreviewServerSnapshot(input.threadRef, result.value); - useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); - return; - } - - if (choice === "open-in-browser") { - input.fallbackToBrowser(); - } + recordVisitForThread(input.threadRef, input.url); + applyPreviewServerSnapshot(input.threadRef, result.value); + useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); } diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index 300d3e3062e..20100339ecb 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -3,15 +3,17 @@ import type { PullRequestCheck, PullRequestChecksState, PullRequestRef, + ScopedThreadRef, } from "@t3tools/contracts"; -import { readLocalApi } from "~/localApi"; +import { useOpenLink } from "~/browser/useOpenLink"; import { cn } from "~/lib/utils"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import { PullRequestCheckStatusIcon, pullRequestCheckStatusLabel, @@ -27,9 +29,11 @@ import { function LazyChecksBody({ environmentId, reference, + threadRef, }: { environmentId: EnvironmentId; reference: PullRequestRef; + threadRef: ScopedThreadRef | null; }) { const detailQuery = useEnvironmentQuery( pullRequestEnvironment.detail({ environmentId, input: reference }), @@ -44,10 +48,17 @@ function LazyChecksBody({

); } - return ; + return ; } -function ChecksBody({ checks }: { checks: ReadonlyArray }) { +function ChecksBody({ + checks, + threadRef, +}: { + checks: ReadonlyArray; + threadRef: ScopedThreadRef | null; +}) { + const openLink = useOpenLink(threadRef); if (checks.length === 0) { return

No checks reported

; } @@ -71,7 +82,13 @@ function ChecksBody({ checks }: { checks: ReadonlyArray }) { @@ -94,6 +111,7 @@ export function PullRequestChecksPopover({ checks, environmentId, reference, + threadRef = null, className, }: { checksState: PullRequestChecksState; @@ -101,6 +119,8 @@ export function PullRequestChecksPopover({ checks?: ReadonlyArray; environmentId?: EnvironmentId; reference?: PullRequestRef; + /** Thread the popover sits beside; a listing row has none. */ + threadRef?: ScopedThreadRef | null; className?: string; }) { const presentation = pullRequestChecksStatePresentation(checksState); @@ -129,9 +149,13 @@ export function PullRequestChecksPopover({

{presentation.label}

{summary === null ? null :

{summary}

} {checks !== undefined ? ( - + ) : environmentId !== undefined && reference !== undefined ? ( - + ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index adbf5342a7d..b318744e3dd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -445,6 +445,7 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, + threadRef = null, reference, refreshToken: forcedRefreshToken = 0, onActed, @@ -454,6 +455,13 @@ export function PullRequestDetailPanel({ composerDraftTarget, }: { environmentId: EnvironmentId; + /** + * The thread this panel sits beside, if any. Links that are not the pull + * request itself (check details, host permalinks) can open in that thread's + * in-app browser when the user has asked for it; the page has no thread, so + * there they always go to the system browser. + */ + threadRef?: ScopedThreadRef | null; reference: PullRequestRef; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read @@ -2152,7 +2160,11 @@ export function PullRequestDetailPanel({ aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} > {checksState !== null ? ( - + ) : ( )} @@ -2276,6 +2288,7 @@ export function PullRequestDetailPanel({
diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index d5d2ee0a475..5fbffdd08d3 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -19,6 +19,7 @@ export function PullRequestMarkdownEditor({ value, cwd, environmentId, + threadRef = null, placeholder, label, saving, @@ -30,6 +31,8 @@ export function PullRequestMarkdownEditor({ readonly value: string; readonly cwd: string; readonly environmentId: EnvironmentId; + /** Thread the editor sits beside, so links in its preview follow the link target setting. */ + readonly threadRef?: ScopedThreadRef | null; readonly placeholder?: string | undefined; readonly label: string; readonly saving: boolean; @@ -84,7 +87,12 @@ export function PullRequestMarkdownEditor({ {empty ? (

Nothing to preview.

) : ( - + )}
) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index eccd9de9b1f..663618b188e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -4,6 +4,7 @@ import type { PullRequestComment, PullRequestDetailView, PullRequestRef, + ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -23,7 +24,7 @@ import { useRef, useState, type ReactNode } from "react"; import { useAtomCommand } from "~/state/use-atom-command"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { cn } from "~/lib/utils"; -import { readLocalApi } from "~/localApi"; +import { useOpenLink } from "~/browser/useOpenLink"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button } from "../ui/button"; @@ -93,6 +94,7 @@ function reviewStateLabel(state: string): string { interface CommentEditing { readonly cwd: string; readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef | null; readonly canEdit: (comment: PullRequestComment) => boolean; readonly editingId: string | null; readonly saving: boolean; @@ -120,6 +122,7 @@ function CommentBody({ value={comment.body} cwd={editing.cwd} environmentId={editing.environmentId} + threadRef={editing.threadRef} label="Edit comment" saving={editing.saving} onSave={(body) => editing.onSave(comment, body)} @@ -134,6 +137,7 @@ function CommentBody({ text={comment.body} cwd={editing.cwd} environmentId={editing.environmentId} + threadRef={editing.threadRef} /> {editing.canEdit(comment) ? (
@@ -538,6 +551,7 @@ function ReviewVerdictEvent({ export function PullRequestTimelineTab({ detail, environmentId, + threadRef = null, reference, order, onOpenCommit, @@ -545,6 +559,7 @@ export function PullRequestTimelineTab({ }: { detail: PullRequestDetailView; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | null; reference: PullRequestRef; order: "newest" | "oldest"; onOpenCommit: (oid: string) => void; @@ -555,6 +570,7 @@ export function PullRequestTimelineTab({ const reactions: ReactionSurface = { canReact: detail.capabilities.reactions === true, environmentId, + threadRef, reference, onRefresh, }; diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 62a17b9368e..803e694c061 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -8,12 +8,14 @@ */ import { BROWSER_PROFILE_MAX_COUNT, + type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_BROWSER_LINK_TARGET, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, @@ -465,6 +467,53 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo ); } +const LINK_TARGET_LABELS: Readonly> = { + system: "Your default browser", + app: "T3 Code", +}; + +function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) { + const linkTarget = useClientSettings((settings) => settings.browserLinkTarget); + const updateSettings = useUpdatePrimarySettings(); + + return ( + updateSettings({ browserLinkTarget: DEFAULT_BROWSER_LINK_TARGET })} + /> + ) : null + } + control={ + + } + /> + ); +} + function AgentBrowserAccessSetting() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -868,6 +917,7 @@ export function IntegrationsSettingsPanel() { + ); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 490d248595f..b99c69ee331 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -269,6 +269,7 @@ describe("getChangedBrowserSettingLabels", () => { browserDefaultZoomFactor: 1.5, browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, + browserLinkTarget: "app", browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, }), ).toEqual([ @@ -276,6 +277,7 @@ describe("getChangedBrowserSettingLabels", () => { "Browser zoom", "Browser appearance", "Recording frame rate", + "Open links in", "Floating preview", ]); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 331391dbc46..3ac6bbaa001 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -115,6 +115,7 @@ export type BrowserDefaultSettings = Pick< | "browserDefaultZoomFactor" | "browserDefaultAppearance" | "browserRecordingFrameRate" + | "browserLinkTarget" | "browserAutoShowFloatingPreview" >; @@ -155,6 +156,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings) ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate ? ["Recording frame rate"] : []), + ...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget + ? ["Open links in"] + : []), ...(settings.browserAutoShowFloatingPreview !== DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview ? ["Floating preview"] diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7fc8153a24f..253bf7de473 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -589,6 +589,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserDefaultZoomFactor, settings.browserDefaultAppearance, settings.browserRecordingFrameRate, + settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, settings.enableAgentBrowserAccess, @@ -735,6 +736,7 @@ export function useSettingsRestore(onRestored?: () => void) { browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor, browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance, browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate, + browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget, browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, // Re-granted like any other default. The confirmation dialog lists it by // name, so a user restoring defaults is told the agent regains access diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 362fe52738e..c9e64ccf981 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -207,4 +207,12 @@ describe("searchSettings", () => { }); expect(result).not.toHaveProperty("targetId"); }); + + it("routes where links open to integrations", () => { + expect(searchSettings("open links in")[0]).toMatchObject({ + id: "browser-link-target", + to: "/settings/integrations", + }); + expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" }); + }); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1bbd4ea9ed3..baad45f0525 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -364,6 +364,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Browser recording frame rate", to: "/settings/integrations", }, + { + id: "browser-link-target", + title: "Open links in", + to: "/settings/integrations", + searchTerms: ["links default browser in-app browser external open"], + }, { id: "browser-auto-show-floating-preview", title: "Auto-show floating preview", diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 810956c5279..785305fcd28 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -11,8 +11,8 @@ import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { useOpenLink } from "../browser/useOpenLink"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { readLocalApi } from "../localApi"; import { useRightPanelStore } from "../rightPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; @@ -328,6 +328,7 @@ export function useOpenChangeRequestLink( export function useOpenPrLink(threadRef?: ScopedThreadRef) { const openChangeRequest = useOpenChangeRequestLink(threadRef); + const openLink = useOpenLink(threadRef); return useCallback( (event: MouseEvent, prUrl: string, targetThreadRef?: ScopedThreadRef) => { event.stopPropagation(); @@ -342,16 +343,9 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) { event.preventDefault(); if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true; - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - }); - return false; - } - - void openPullRequestLink(api.shell, prUrl).catch((error) => { + // No project to show it in, so it is an ordinary link and follows the + // "Open links in" setting; the modifier still forces the system browser. + void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => { console.error(error); toastManager.add( stackedThreadToast({ @@ -363,6 +357,6 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) { }); return false; }, - [openChangeRequest], + [openChangeRequest, openLink], ); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fd3b3f35dd5..bd594f1a308 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -188,6 +188,14 @@ export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const; export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES); export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type; export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30; +/** + * Where a clicked link goes: the OS default browser, or a tab in the in-app + * browser beside the thread. "system" is the default because that is what + * every link did before the setting existed. + */ +export const BrowserLinkTarget = Schema.Literals(["system", "app"]); +export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; +export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; export const ClientSettingsSchema = Schema.Struct({ appearanceContrast: AppearanceContrast.pipe( @@ -210,6 +218,13 @@ export const ClientSettingsSchema = Schema.Struct({ browserRecordingFrameRate: BrowserRecordingFrameRate.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)), ), + /** + * Where links clicked in a thread (chat markdown, terminal output) open. + * Only the desktop app has an in-app browser, so other clients ignore "app". + */ + browserLinkTarget: BrowserLinkTarget.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)), + ), /** * Whether an agent opening a preview pops the floating mini player into * view. Only applies when the agent didn't ask either way — an explicit @@ -1003,6 +1018,7 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), + browserLinkTarget: Schema.optionalKey(BrowserLinkTarget), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), From 6a5a18cb1bc9ea878afa1146950439b5d4744110 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 01:16:46 -0400 Subject: [PATCH 20/46] fix(web): add press feedback to buttons (#9349) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ui/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 257aaa051f8..726b436f1bb 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -8,7 +8,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; const buttonVariants = cva( - "[--control-icon-color:currentColor] [&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--control-radius)] border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "[--control-icon-color:currentColor] [&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--control-radius)] border font-medium text-base outline-none transition-[box-shadow,scale] active:scale-[0.97] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { defaultVariants: { size: "default", From f6c04c552c203350705f9ab1e47773ea736af245 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 3 Sep 2026 15:17:38 +1000 Subject: [PATCH 21/46] feat(web): add customizable project icons (#9137) Co-authored-by: maria Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../Layers/ProjectionPipeline.test.ts | 6 +- .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 9 + .../decider.projectScripts.test.ts | 8 +- apps/server/src/orchestration/decider.ts | 2 + apps/server/src/orchestration/projector.ts | 4 + .../persistence/Layers/ProjectionProjects.ts | 8 +- apps/server/src/persistence/Migrations.ts | 2 + .../047_ProjectionProjectIcon.test.ts | 28 +++ .../Migrations/047_ProjectionProjectIcon.ts | 16 ++ .../Services/ProjectionProjects.ts | 2 + apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/CommandPalette.tsx | 18 +- apps/web/src/components/LegacySidebar.tsx | 14 +- .../src/components/ProjectFavicon.test.tsx | 63 +++++ apps/web/src/components/ProjectFavicon.tsx | 172 +++++++++++++- apps/web/src/components/Sidebar.tsx | 56 ++++- .../src/components/ThreadCommandSubtitle.tsx | 3 + apps/web/src/components/chat/ChatHeader.tsx | 4 + .../pullRequest/PullRequestListFilters.tsx | 15 +- .../settings/ProjectIconPickerDialog.test.tsx | 51 ++++ .../settings/ProjectIconPickerDialog.tsx | 205 ++++++++++++++++ .../settings/ProjectSettingsPanel.tsx | 56 ++++- .../components/settings/SettingsPanels.tsx | 3 + apps/web/src/projectIconColors.ts | 125 ++++++++++ apps/web/src/projectIconModel.test.ts | 37 +++ apps/web/src/projectIconModel.ts | 221 ++++++++++++++++++ apps/web/src/projectIconOptions.test.ts | 24 ++ apps/web/src/projectIconOptions.ts | 81 +++++++ apps/web/src/routes/_chat.pull-requests.tsx | 2 + docs/user/project-settings.md | 13 +- packages/contracts/src/orchestration.test.ts | 30 +++ packages/contracts/src/orchestration.ts | 47 ++++ 34 files changed, 1294 insertions(+), 38 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts create mode 100644 apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.ts create mode 100644 apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx create mode 100644 apps/web/src/components/settings/ProjectIconPickerDialog.tsx create mode 100644 apps/web/src/projectIconColors.ts create mode 100644 apps/web/src/projectIconModel.test.ts create mode 100644 apps/web/src/projectIconModel.ts create mode 100644 apps/web/src/projectIconOptions.test.ts create mode 100644 apps/web/src/projectIconOptions.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 504fa7c5254..260e3567c24 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -3423,17 +3423,20 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { model: "gpt-5", }, faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🚀" }, }); const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModelSelection: string; readonly faviconPath: string | null; + readonly projectIcon: string | null; }>` SELECT scripts_json AS "scriptsJson", default_model_selection_json AS "defaultModelSelection", - favicon_path AS "faviconPath" + favicon_path AS "faviconPath", + project_icon_json AS "projectIcon" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -3443,6 +3446,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', faviconPath: "brand/icon.svg", + projectIcon: '{"kind":"emoji","emoji":"🚀"}', }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3de33474d20..48b7169d102 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -517,6 +517,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti defaultThreadEnvMode: null, autoPull: false, faviconPath: event.payload.faviconPath ?? null, + projectIcon: event.payload.projectIcon ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -547,6 +548,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.faviconPath !== undefined ? { faviconPath: event.payload.faviconPath } : {}), + ...(event.payload.projectIcon !== undefined + ? { projectIcon: event.payload.projectIcon } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 3c9501fded8..682b280bf4d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -280,6 +280,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, autoPull: false, faviconPath: null, + projectIcon: null, scripts: [ { id: "script-1", @@ -408,6 +409,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, autoPull: false, faviconPath: null, + projectIcon: null, scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index ee173dd2d24..24c7003204e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,6 +12,7 @@ import { OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, + ProjectIconOverride, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -84,6 +85,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), autoPull: Schema.Number, + projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -350,6 +352,7 @@ function mapProjectShellRow( defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -443,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -919,6 +923,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -944,6 +949,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1885,6 +1891,7 @@ pending_approval_requests AS ( defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2021,6 +2028,7 @@ pending_approval_requests AS ( defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2537,6 +2545,7 @@ pending_approval_requests AS ( defaultThreadEnvMode: option.value.defaultThreadEnvMode, autoPull: option.value.autoPull === 1, faviconPath: option.value.faviconPath ?? null, + projectIcon: option.value.projectIcon ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index bf5c509fa16..22ff884d5f4 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,7 +94,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); - it.effect("propagates a project favicon path in project.meta.update", () => + it.effect("propagates project icon metadata in project.meta.update", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; const readModel = yield* projectEvent(createEmptyReadModel(now), { @@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { commandId: CommandId.make("cmd-project-update-favicon"), projectId: asProjectId("project-favicon"), faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, }, readModel, }); @@ -132,6 +133,11 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { const event = Array.isArray(result) ? result[0] : result; expect(event.type).toBe("project.meta-updated"); expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg"); + expect((event.payload as { projectIcon?: unknown }).projectIcon).toEqual({ + kind: "lucide", + name: "alarm-clock", + color: "violet", + }); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 89d5138f16f..279413f669c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -224,6 +224,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // explicit project default. defaultModelSelection: null, faviconPath: null, + projectIcon: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -266,6 +267,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), + ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index dab3f8d52f1..fbef8e40b50 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -217,6 +217,7 @@ export function projectEvent( defaultThreadEnvMode: null, autoPull: false, faviconPath: payload.faviconPath ?? null, + projectIcon: payload.projectIcon ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -256,6 +257,9 @@ export function projectEvent( ...(payload.faviconPath !== undefined ? { faviconPath: payload.faviconPath } : {}), + ...(payload.projectIcon !== undefined + ? { projectIcon: payload.projectIcon } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7dcec817f9b..f34f8129412 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -20,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), autoPull: Schema.Number, + projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -40,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode, auto_pull, favicon_path, + project_icon_json, scripts_json, created_at, updated_at, @@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.defaultThreadEnvMode}, ${row.autoPull ? 1 : 0}, ${row.faviconPath ?? null}, + ${row.projectIcon ? JSON.stringify(row.projectIcon) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -66,6 +69,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode = excluded.default_thread_env_mode, auto_pull = excluded.auto_pull, favicon_path = excluded.favicon_path, + project_icon_json = excluded.project_icon_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -86,6 +90,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -108,6 +113,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 69e02c1d51f..92dc1829105 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -58,6 +58,7 @@ import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; +import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; /** * Migration loader with all migrations defined inline. @@ -116,6 +117,7 @@ export const migrationEntries = [ [44, "ClearAutomaticProjectModelDefaults", Migration0044], [45, "ProjectionProjectsAutoPull", Migration0045], [46, "RepairAutomaticSettlementTimestamps", Migration0046], + [47, "ProjectionProjectIcon", Migration0047], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts new file mode 100644 index 00000000000..da3f7cd366d --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("047_ProjectionProjectIcon", (it) => { + it.effect("adds the nullable project icon JSON to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 46 }); + yield* runMigrations({ toMigrationInclusive: 47 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const projectIcon = columns.find((column) => column.name === "project_icon_json"); + + assert.equal(projectIcon?.name, "project_icon_json"); + assert.equal(projectIcon?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.ts b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.ts new file mode 100644 index 00000000000..0523a47b1c5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "project_icon_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN project_icon_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 8510dc6e7c2..e5d1f6ba1f1 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + ProjectIconOverride, ProjectId, ProjectScript, ThreadEnvMode, @@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({ defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, faviconPath: Schema.optional(Schema.NullOr(Schema.String)), + projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 88c781146d6..d15122abccf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7436,6 +7436,7 @@ function ChatViewContent(props: ChatViewProps) { activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} + activeProjectIcon={activeProject?.projectIcon ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4c94c3cb0c2..2148a6bfa02 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -181,8 +181,10 @@ function projectFavicon(project: Project) { ); } @@ -939,6 +941,16 @@ function OpenCommandPaletteDialog(props: { () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), [projects], ); + const projectIconByKey = useMemo( + () => + new Map( + projects.map( + (project) => + [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const, + ), + ), + [projects], + ); const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.title])), [projects], @@ -1170,6 +1182,9 @@ function OpenCommandPaletteDialog(props: { environmentId={thread.environmentId} projectCwd={projectCwdById.get(thread.projectId) ?? null} projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null} + projectIcon={ + projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null + } projectTitle={projectTitle ?? null} branch={thread.branch} worktreePath={thread.worktreePath} @@ -1209,6 +1224,7 @@ function OpenCommandPaletteDialog(props: { navigate, projectCwdById, projectFaviconPathById, + projectIconByKey, projectTitleById, providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 370c4eaed74..4c8515246c8 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2340,11 +2340,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + + + {project.displayName} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bbeeda4bc7f..557f4d722ad 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -1,6 +1,7 @@ import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentId } from "@t3tools/contracts"; +import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", @@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => { }); vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("lucide-react/dynamic", () => ({ + DynamicIcon: "dynamic-icon", + iconNames: ["alarm-clock", "folder-code"], +})); vi.mock("../assets/assetUrls", () => ({ useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.lastResource = resource; @@ -86,6 +91,7 @@ function resolveImageComponent(): { const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace-test", + projectName: "workspace-test", }) as ReactElement; hooks.reset(); @@ -106,6 +112,62 @@ function renderImage( describe("ProjectFavicon", () => { beforeEach(() => { hooks.reset(); + testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; + }); + + it("shows a project-name emoji when no favicon exists", () => { + testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/analytics-db", + projectName: "analytics-db", + }) as ReactElement<{ readonly emoji?: string }>; + + expect(element.props.emoji).toBe("🗄️"); + }); + + it("chooses a deterministic semantic emoji", () => { + testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/agent-runtime", + projectName: "agent-runtime", + }) as ReactElement<{ readonly emoji?: string }>; + + expect(element.props.emoji).toBe("🤖"); + }); + + it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/test", + projectName: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + }) as ReactElement<{ + readonly children: ReactElement<{ + readonly children: ReactElement<{ readonly name: string; readonly className: string }>; + }>; + readonly className: string; + }>; + + expect(element.props.children.props.children.props.name).toBe("alarm-clock"); + expect(element.props.className).toContain("text-violet-600"); + expect(element.props.children.props.children.props.className).toContain("text-violet-600"); + }); + + it("renders a saved emoji ahead of an uploaded favicon", () => { + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/test", + projectName: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🦄" }, + }) as ReactElement<{ readonly emoji: string }>; + + expect(element.props.emoji).toBe("🦄"); }); it("falls back when the displayed favicon fails without discarding a valid older image early", () => { @@ -134,6 +196,7 @@ describe("ProjectFavicon", () => { ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace-test", + projectName: "workspace-test", faviconPath: "brand/icon.svg", }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf37001..2ebc6e26744 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,29 +1,153 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { FolderIcon } from "lucide-react"; +import { + BotIcon, + BookOpenIcon, + BracesIcon, + CircuitBoardIcon, + CloudCogIcon, + Code2Icon, + DatabaseIcon, + FlaskConicalIcon, + FolderCodeIcon, + Gamepad2Icon, + Globe2Icon, + ImageIcon, + Layers3Icon, + MonitorIcon, + MusicIcon, + PackageIcon, + ServerIcon, + ShieldCheckIcon, + ShoppingBagIcon, + SmartphoneIcon, + TerminalIcon, + VideoIcon, +} from "lucide-react"; +import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useAssetUrlState } from "../assets/assetUrls"; +import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; +import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); +const DynamicIcon = lazy(() => + import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), +); + +function DynamicProjectIconFallback() { + return ; +} + +const PROJECT_ICONS: Record> = { + ai: BotIcon, + book: BookOpenIcon, + braces: BracesIcon, + circuit: CircuitBoardIcon, + cloud: CloudCogIcon, + code: Code2Icon, + database: DatabaseIcon, + desktop: MonitorIcon, + "folder-code": FolderCodeIcon, + game: Gamepad2Icon, + image: ImageIcon, + layers: Layers3Icon, + mobile: SmartphoneIcon, + music: MusicIcon, + package: PackageIcon, + security: ShieldCheckIcon, + server: ServerIcon, + shopping: ShoppingBagIcon, + terminal: TerminalIcon, + test: FlaskConicalIcon, + video: VideoIcon, + web: Globe2Icon, +}; + +const PROJECT_ICON_COLOR_BY_NAME: Record = { + ai: "violet", + book: "amber", + braces: "purple", + circuit: "teal", + cloud: "sky", + code: "blue", + database: "cyan", + desktop: "indigo", + "folder-code": "orange", + game: "emerald", + image: "pink", + layers: "fuchsia", + mobile: "lime", + music: "fuchsia", + package: "orange", + security: "teal", + server: "blue", + shopping: "rose", + terminal: "green", + test: "yellow", + video: "red", + web: "sky", +}; export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + projectName: string; faviconPath?: string | null | undefined; + projectIcon?: ProjectIconOverride | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { const state = useProjectFaviconAsset(input); const src = state._tag === "Success" ? state.url : null; - const FallbackIcon = input.fallbackIcon ?? FolderIcon; + if (input.projectIcon?.kind === "emoji") { + return ; + } + if (input.projectIcon?.kind === "lucide") { + const colorClassName = projectIconColorClassName(input.projectIcon.color); + const iconClassName = cn( + "inline-flex size-3.5 shrink-0 items-center justify-center", + colorClassName, + input.className, + ); + return ( + + ); + } + const automaticIconName = input.fallbackIcon + ? null + : selectProjectIcon(input.projectName, input.cwd); + const FallbackIcon = + input.fallbackIcon ?? + (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); + const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; + const fallbackColorClassName = + automaticIconName?.kind === "lucide" + ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) + : undefined; if (!src || isProjectFaviconFallbackUrl(src)) { - return ; + return ( + + ); } const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); @@ -35,6 +159,8 @@ export function ProjectFavicon(input: { src={src} className={input.className} fallbackIcon={FallbackIcon} + fallbackEmoji={fallbackEmoji} + fallbackColorClassName={fallbackColorClassName} /> ); } @@ -53,12 +179,31 @@ export function useProjectFaviconAsset(input: { function ProjectFaviconFallback({ className, + colorClassName, icon: Icon, + emoji, }: { readonly className?: string | undefined; - readonly icon: ComponentType<{ className?: string }>; + readonly colorClassName?: string | undefined; + readonly icon?: ComponentType<{ className?: string }> | undefined; + readonly emoji?: string | undefined; }) { - return ; + if (emoji) { + return ( + + ); + } + + if (!Icon) return null; + return ; } function ProjectFaviconImage({ @@ -66,11 +211,15 @@ function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, + fallbackEmoji, + fallbackColorClassName, }: { readonly cacheKey: string; readonly src: string; readonly className?: string | undefined; - readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; + readonly fallbackEmoji?: string | undefined; + readonly fallbackColorClassName?: string | undefined; }) { const [displayedSrc, setDisplayedSrc] = useState( () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, @@ -86,7 +235,12 @@ function ProjectFaviconImage({ return ( <> {displayedSrc === null ? ( - + ) : null} {displayedSrc ? (
{projectTitle}
@@ -495,6 +500,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { projectTitle: string | null; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; @@ -560,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { @@ -605,6 +613,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; + projectIconByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; @@ -701,6 +710,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} + projectIcon={props.projectIconByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} onDiscard={handleDiscard} @@ -748,6 +758,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { environmentMachine: EnvironmentMachineKind; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; @@ -986,6 +997,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectTitle={props.projectTitle} projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} + projectIcon={props.projectIcon} environmentLabel={props.environmentLabel} environmentMachine={props.environmentMachine} providerEntry={providerEntry} @@ -1293,7 +1305,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { @@ -1448,7 +1462,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.projectTitle ? ( @@ -1653,6 +1669,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; projectTitle: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; @@ -1736,7 +1753,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { @@ -1750,6 +1769,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectTitle={props.projectTitle} projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} + projectIcon={props.projectIcon} environmentLabel={props.environmentLabel} environmentMachine={props.environmentMachine} providerEntry={providerEntry} @@ -1971,6 +1991,13 @@ export default function Sidebar() { ), [projects], ); + const projectIconByKey = useMemo( + () => + new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]), + ), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -3603,12 +3630,16 @@ export default function Sidebar() { } > {scopedProjectGroup ? ( - + + + ) : ( )} @@ -3661,7 +3692,9 @@ export default function Sidebar() { ) : ( @@ -3745,6 +3778,10 @@ export default function Sidebar() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectIcon={ + projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, @@ -3863,6 +3900,10 @@ export default function Sidebar() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectIcon={ + projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, @@ -3906,6 +3947,7 @@ export default function Sidebar() { projectDisplayNameByKey={projectDisplayNameByKey} projectCwdByKey={projectCwdByKey} projectFaviconPathByKey={projectFaviconPathByKey} + projectIconByKey={projectIconByKey} scopedProjectKeys={scopedProjectKeys} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 015b15c5ea0..cd507451871 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -38,6 +38,7 @@ export function ThreadCommandSubtitle(props: { environmentId: EnvironmentId; projectCwd: string | null; projectFaviconPath?: string | null; + projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null; projectTitle: string | null; branch: string | null; worktreePath: string | null; @@ -72,7 +73,9 @@ export function ThreadCommandSubtitle(props: { ) : null} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dde6fd0dade..1dcf9887704 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -56,6 +56,7 @@ interface ChatHeaderProps { activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; + activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; @@ -126,6 +127,7 @@ export const ChatHeader = memo(function ChatHeader({ activeProjectName, activeProjectCwd, activeProjectFaviconPath, + activeProjectIcon, openInCwd, activeProjectScripts, preferredScriptId, @@ -325,7 +327,9 @@ export const ChatHeader = memo(function ChatHeader({ {activeProjectName} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index d550decff89..473aadcffbf 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -2,6 +2,7 @@ import type { EnvironmentId, ProjectId, PullRequestInvolvement, + ProjectIconOverride, PullRequestListFilters, PullRequestListState, SourceControlProviderKind, @@ -58,6 +59,8 @@ export interface PullRequestFilterOption { readonly favicon?: { readonly environmentId: EnvironmentId; readonly cwd: string; + readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; }; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; @@ -72,6 +75,9 @@ export function PullRequestFilterOptionIcon({ @@ -450,6 +456,8 @@ export function PullRequestFiltersMenu({ readonly environmentId: EnvironmentId; readonly title: string; readonly workspaceRoot: string; + readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; }>; projectId: ProjectId | undefined; /** @@ -505,7 +513,12 @@ export function PullRequestFiltersMenu({ value: pullRequestProjectKey(project), label: project.title, Icon: FolderGit2Icon, - favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + favicon: { + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath ?? null, + projectIcon: project.projectIcon ?? null, + }, ...(unavailable.has(pullRequestProjectKey(project)) ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } : {}), diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx new file mode 100644 index 00000000000..9098b359d1e --- /dev/null +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../ui/button", () => ({ + Button: ({ children }: { readonly children?: ReactNode }) => , +})); + +vi.mock("../ui/dialog", () => { + const Container = ({ children }: { readonly children?: ReactNode }) =>
{children}
; + return { + Dialog: Container, + DialogDescription: Container, + DialogFooter: Container, + DialogHeader: Container, + DialogPanel: Container, + DialogPopup: Container, + DialogTitle: Container, + }; +}); + +vi.mock("../ui/input", () => ({ Input: () => })); +vi.mock("../ui/scroll-area", () => ({ + ScrollArea: ({ children }: { readonly children?: ReactNode }) =>
{children}
, +})); +vi.mock("../ui/toggle-group", () => ({ + Toggle: ({ children, value }: { readonly children?: ReactNode; readonly value: string }) => ( + + ), + ToggleGroup: ({ + children, + value, + }: { + readonly children?: ReactNode; + readonly value: readonly string[]; + }) =>
{children}
, +})); + +import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; + +describe("ProjectIconPickerDialog", () => { + it("shows emoji first and selects it for an automatic project", () => { + const markup = renderToStaticMarkup( + {}} onSelect={() => {}} />, + ); + + expect(markup).toContain('data-current="emoji"'); + expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<")); + expect(markup).toContain("Or paste any emoji"); + }); +}); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx new file mode 100644 index 00000000000..4ecdb0f653c --- /dev/null +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -0,0 +1,205 @@ +import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import { DynamicIcon, type IconName } from "lucide-react/dynamic"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + filterProjectIconNames, + firstEmoji, + PROJECT_EMOJIS, + PROJECT_ICON_COLORS, + projectIconColorClassName, +} from "../../projectIconOptions"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { ScrollArea } from "../ui/scroll-area"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; + +const DEFAULT_ICON: IconName = "folder-code"; +const DEFAULT_COLOR: ProjectIconColor = "blue"; + +function iconLabel(name: string): string { + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function ProjectIconPickerDialog({ + current, + open, + onOpenChange, + onSelect, +}: { + readonly current: ProjectIconOverride | null; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onSelect: (icon: ProjectIconOverride) => void; +}) { + const [mode, setMode] = useState<"lucide" | "emoji">( + current?.kind === "lucide" ? "lucide" : "emoji", + ); + const [iconName, setIconName] = useState( + current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, + ); + const [color, setColor] = useState( + current?.kind === "lucide" ? current.color : DEFAULT_COLOR, + ); + const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); + const [query, setQuery] = useState(""); + const [customEmoji, setCustomEmoji] = useState(""); + const previousOpenRef = useRef(false); + + useEffect(() => { + if (open && !previousOpenRef.current) { + setMode(current?.kind === "lucide" ? "lucide" : "emoji"); + setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); + setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); + setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); + setQuery(""); + setCustomEmoji(""); + } + previousOpenRef.current = open; + }, [current, open]); + + const icons = useMemo(() => filterProjectIconNames(query), [query]); + const selectedColorClassName = projectIconColorClassName(color); + const save = () => { + onSelect( + mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji }, + ); + onOpenChange(false); + }; + + return ( + + + + Choose project icon + Pick an emoji, or choose any Lucide icon and color. + + + { + const value = next[0]; + if (value === "lucide" || value === "emoji") setMode(value); + }} + > + Emoji + Icons + + + {mode === "lucide" ? ( + <> +
+
Color
+
+ {PROJECT_ICON_COLORS.map((option) => ( + + ))} +
+
+ setQuery(event.currentTarget.value)} + /> + +
+ {icons.map((name) => ( + + ))} +
+
+ {icons.length === 0 ? ( +

No icons found.

+ ) : null} + + ) : ( + <> + +
+ {PROJECT_EMOJIS.map((option) => ( + + ))} +
+
+
+
+ Or paste any emoji +
+ { + const value = event.currentTarget.value; + setCustomEmoji(value); + const nextEmoji = firstEmoji(value); + if (nextEmoji) setEmoji(nextEmoji); + }} + /> +
+ + )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 1f0aba37586..3949d2c3488 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -15,6 +15,7 @@ import { import type { ContextMenuItem, ModelSelection, + ProjectIconOverride, ProviderDriverKind, SidebarProjectGroupingMode, T3ProjectFileScript, @@ -27,6 +28,8 @@ import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; import { + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -118,6 +121,12 @@ import { } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +const ProjectIconPickerDialog = lazy(() => + import("./ProjectIconPickerDialog").then((module) => ({ + default: module.ProjectIconPickerDialog, + })), +); + export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -335,6 +344,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); const faviconPath = representative.faviconPath ?? null; + const projectIcon = representative.projectIcon ?? null; const pickProjectFavicon = typeof window !== "undefined" && group.memberProjects.every( @@ -375,6 +385,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { defaultThreadEnvMode: ThreadEnvMode | null; autoPull: boolean; faviconPath: string | null; + projectIcon: ProjectIconOverride | null; }>, failureTitle: string, ): Promise> => { @@ -474,17 +485,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - // ----- favicon ----- + // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); + const [iconPickerOpen, setIconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); const savingFaviconRef = useRef(false); - const setFaviconPath = useCallback( - async (faviconPath: string | null) => { + const setProjectIcon = useCallback( + async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => { if (savingFaviconRef.current) return; savingFaviconRef.current = true; setIsSavingFavicon(true); try { - await updateAllMembers({ faviconPath }, "Failed to update project icon"); + await updateAllMembers(input, "Failed to update project icon"); } finally { savingFaviconRef.current = false; setIsSavingFavicon(false); @@ -824,13 +836,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> void setFaviconPath(null)} + onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })} /> ) : null } @@ -839,13 +857,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { + + ) : null; // Read from the whole conversation, not the window shown below it: a verdict older than the // last thirty comments still stands. const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); @@ -864,22 +876,7 @@ export function PullRequestSummaryTab({

No comments yet.

) : (
- {hiddenCommentCount > 0 ? ( - // Hundreds of comments are hundreds of markdown renders, and the ones worth - // opening a pull request for are the recent ones. The rest are one press away and - // stay rendered once asked for. - - ) : null} + {commentOrder === "oldest" ? showOldestCommentsButton : null} {visibleComments.map((comment) => { const thread = threadByCommentId.get(comment.id); const body = visibleBody(comment.body); @@ -991,6 +988,7 @@ export function PullRequestSummaryTab({ ); })} + {commentOrder === "newest" ? showOldestCommentsButton : null}
)} From 854541a04e07b7960698b381bcfd2fda73eb276c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 01:47:41 -0400 Subject: [PATCH 26/46] fix(pull-requests): shared state + not settling? (#9332) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../ThreadSettlementReactor.test.ts | 74 ++++++- .../orchestration/ThreadSettlementReactor.ts | 31 ++- .../pullRequest/PullRequestService.test.ts | 64 ++++++ .../src/pullRequest/PullRequestService.ts | 55 +++++- apps/web/src/components/ChatView.tsx | 185 +++++++++++++----- .../components/ThreadStatusIndicators.test.ts | 80 ++++---- .../src/components/ThreadStatusIndicators.tsx | 31 +-- .../pullRequest/PullRequestDetailPanel.tsx | 36 ++-- apps/web/src/state/pullRequests.ts | 48 ++++- 9 files changed, 439 insertions(+), 165 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index e0aabe973e6..382d5812c1a 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -25,7 +25,10 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { GitManager } from "../git/GitManager.ts"; -import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { + PullRequestService, + type PullRequestMergeEvent, +} from "../pullRequest/PullRequestService.ts"; import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const snapshotReads = yield* Queue.unbounded(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsChanges = yield* PubSub.unbounded(); + const mergedPullRequests = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> @@ -168,7 +172,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Ref.update(branchCalls, (calls) => [...calls, input]).pipe( Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); - const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => Effect.gen(function* () { yield* Ref.update(summaryCalls, (calls) => [...calls, input]); @@ -217,7 +220,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ), }), Layer.mock(GitManager)({ branchPullRequest }), - Layer.mock(PullRequestService)({ summary: pullRequestSummary }), + Layer.mock(PullRequestService)({ + summary: pullRequestSummary, + subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch, @@ -239,6 +247,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: summaryCalls, summaryRecovery, updateSettings, + publishMerge: PubSub.publish(mergedPullRequests, { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + mergedAt: NOW, + }), layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), }; }); @@ -373,6 +387,60 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("reevaluates immediately after a pull request merge", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const periodicLookupStarted = yield* Deferred.make(); + const releasePeriodicLookup = yield* Deferred.make(); + const mergedThreadSettled = yield* Deferred.make(); + const branchLookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged-in-app", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("slow-periodic-lookup", { branch: "another-feature" }), + ]), + branchPullRequest: () => + Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) + : Deferred.succeed(periodicLookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePeriodicLookup)), + Effect.as({ state: "open" as const, updatedAt: NOW }), + ), + ), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Deferred.await(periodicLookupStarted); + + yield* fixture.publishMerge; + yield* Deferred.await(mergedThreadSettled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("merged-in-app")], + ); + yield* Deferred.succeed(releasePeriodicLookup, undefined); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4971ae643c0..9dd7cd5e76f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -38,11 +38,22 @@ export const make = Effect.gen(function* () { const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; - const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( + mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, + ) { const snapshot = yield* snapshots.getShellSnapshot(); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const candidates = snapshot.threads.filter( + (thread) => + isAutoSettlementCandidate(thread, now) && + (mergedPullRequest === null || + (thread.linkedPullRequest != null && + thread.linkedPullRequest.projectId === mergedPullRequest.projectId && + thread.linkedPullRequest.repository.toLowerCase() === + mergedPullRequest.repository.toLowerCase() && + thread.linkedPullRequest.number === mergedPullRequest.number)), + ); const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ @@ -66,6 +77,12 @@ export const make = Effect.gen(function* () { thread: (typeof candidates)[number], ) { if (thread.linkedPullRequest != null) { + if (mergedPullRequest !== null) { + return { + state: "merged", + updatedAt: mergedPullRequest.mergedAt, + } satisfies SettlementPullRequest; + } if (!projects.has(thread.linkedPullRequest.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } @@ -145,8 +162,8 @@ export const make = Effect.gen(function* () { ); }); - const worker = yield* makeDrainableWorker(() => - sweep().pipe( + const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) => + sweep(mergedPullRequest).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) @@ -154,13 +171,14 @@ export const make = Effect.gen(function* () { cause: Cause.pretty(cause), }), ), - ), - ); + ); + const worker = yield* makeDrainableWorker(() => runSweep(null)); const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( "ThreadSettlementReactor.start", )(function* () { const settingsChanges = yield* settingsService.subscribeChanges; + const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; @@ -183,6 +201,7 @@ export const make = Effect.gen(function* () { return worker.enqueue(undefined); }), ); + yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep)); }); return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0a4d17280ad..0dc7a928c26 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,7 +1,10 @@ import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, @@ -1002,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () => }), ); +it.effect("publishes a successful merge for immediate settlement", () => + Effect.scoped( + Effect.gen(function* () { + const mergedAt = "2026-09-03T02:00:00.000Z"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + runAction: () => TestClock.setTime(Date.parse(mergedAt)), + }), + ], + }); + const merges = yield* service.subscribeMerges; + const observedMerge = yield* Stream.runHead(merges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* service.runAction({ + ...reference, + repository: " ACME/WEB ", + action: "merge", + mergeMethod: "merge", + }); + + assert.deepStrictEqual(Option.getOrThrow(yield* Fiber.join(observedMerge)), { + ...reference, + mergedAt, + }); + }), + ), +); + it.effect("refuses an action this viewer may not take, and says what access it takes", () => Effect.gen(function* () { let ran: string | null = null; @@ -3192,6 +3230,32 @@ it.effect("does not ask the host again for a linked summary it already holds", ( }), ); +it.effect("reuses an observed merged state for strict settlement reads", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("merged body", 4), + state: "merged", + updatedAt: "2026-07-03T00:00:00Z", + }), + getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), + }), + ], + }); + + yield* service.detail(reference); + + const summary = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(summary.state, "merged"); + assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); + }), +); + it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 509127fc9cf..b27fff3534a 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,11 +1,15 @@ import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -63,6 +67,10 @@ import { } from "./PullRequestProvider.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; +export interface PullRequestMergeEvent extends PullRequestRef { + readonly mergedAt: string; +} + /** * Rows per repository when the client does not ask for a page size, and rows per slice when a * listing is carried on from a cursor. @@ -135,6 +143,11 @@ export class PullRequestService extends Context.Service< input: PullRequestRef, options?: { readonly recoverTransientFailure?: boolean }, ) => Effect.Effect; + readonly subscribeMerges: Effect.Effect< + Stream.Stream, + never, + Scope.Scope + >; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -517,6 +530,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string } export const make = Effect.gen(function* () { + const mergedPullRequests = yield* PubSub.sliding(64); const registry = yield* PullRequestProviderRegistry; const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -1410,9 +1424,9 @@ export const make = Effect.gen(function* () { }), ); - const runAction: PullRequestService["Service"]["runAction"] = (input) => + const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( - Effect.flatMap((project): Effect.Effect => { + Effect.flatMap((project): Effect.Effect => { // 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)) { @@ -1454,7 +1468,7 @@ export const make = Effect.gen(function* () { // have to say yes. The second is asked last, because it costs a request and the checks // above do not. return viewerPermissionsOf(project, input, "runAction").pipe( - Effect.flatMap((viewer): Effect.Effect => { + Effect.flatMap((viewer): Effect.Effect => { if (!viewer.actions.includes(input.action)) { return Effect.fail( new PullRequestOperationError({ @@ -1484,7 +1498,10 @@ export const make = Effect.gen(function* () { ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) - .pipe(Effect.mapError(toPullRequestError("runAction"))); + .pipe( + Effect.mapError(toPullRequestError("runAction")), + Effect.as(project.repository), + ); }), ); }), @@ -2151,9 +2168,13 @@ export const make = Effect.gen(function* () { const summary: PullRequestService["Service"]["summary"] = (input, options) => { const key = refCacheKey(input); const cached = Cache.get(summaryCache, key); - return options?.recoverTransientFailure === false - ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) - : lastGoodSummary.serveHeld(key, cached, "reuse"); + if (options?.recoverTransientFailure !== false) { + return lastGoodSummary.serveHeld(key, cached, "reuse"); + } + const held = lastGoodSummary.peek(key); + return held?.state === "merged" + ? Effect.succeed(held) + : cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))); }; // Keys serialize positionally and parse back in the lookup, so the cache is the only holder @@ -2391,17 +2412,35 @@ export const make = Effect.gen(function* () { }), ), ); + const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( + "PullRequestService.runActionAndInvalidate", + )(function* (input) { + const repository = yield* runAction(input); + bumpRefEpoch({ ...input, repository }); + listingsEpoch = ++epochCounter; + if (input.action === "merge") { + yield* PubSub.publish(mergedPullRequests, { + projectId: input.projectId, + repository, + number: input.number, + mergedAt: DateTime.formatIso(yield* DateTime.now), + }); + } + }); return PullRequestService.of({ list, listStats, summary, + subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), detail, activity, threadComments, diff, diffFileContents, - runAction: invalidatedByMutation(runAction), + runAction: runActionAndInvalidate, update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), updateComment: invalidatedByMutation(updateComment), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d15122abccf..1d76b4ae5d6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -265,7 +265,6 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; -import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -325,7 +324,6 @@ import { } from "./chat/ThreadErrorBanner"; import { resolveDisplayedThreadPr, - threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -1787,6 +1785,8 @@ function ChatViewContent(props: ChatViewProps) { ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const sidebarPrRefreshKeyRef = useRef(null); + const threadPrRelinkKeysRef = useRef(new Map()); + const threadPrRelinkWriteRef = useRef(Promise.resolve()); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( @@ -3645,9 +3645,50 @@ function ChatViewContent(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const linkedThreadPullRequest = isServerThread + const persistedLinkedThreadPullRequest = isServerThread ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) : (activeThread?.linkedPullRequest ?? null); + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( + activeThreadRef?.environmentId ?? null, + persistedLinkedThreadPullRequest, + ); + const replacementLinkedThreadPullRequest = useMemo(() => { + const detected = gitStatusQuery.data?.pr; + const threadBranch = activeThread?.branch; + const projectId = activeProject?.id; + if ( + persistedLinkedThreadPullRequest === null || + (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && + persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || + gitStatusQuery.data?.refName !== threadBranch || + detected?.state !== "open" || + detected.headRef !== threadBranch || + projectId === undefined || + activeProjectRepository === null || + (persistedLinkedThreadPullRequest.projectId === projectId && + persistedLinkedThreadPullRequest.repository.toLowerCase() === + activeProjectRepository.toLowerCase() && + persistedLinkedThreadPullRequest.number === detected.number) + ) { + return null; + } + return { + projectId, + repository: activeProjectRepository, + number: detected.number, + url: detected.url, + }; + }, [ + activeProject?.id, + activeProjectRepository, + activeThread?.branch, + gitStatusQuery.data, + persistedLinkedThreadPullRequest, + persistedLinkedThreadPullRequestStatus?.pr.state, + ]); + const linkedThreadPullRequest = + replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; const linkedThreadPullRequestKey = linkedThreadPullRequest ? JSON.stringify([ linkedThreadPullRequest.projectId, @@ -3655,7 +3696,6 @@ function ChatViewContent(props: ChatViewProps) { linkedThreadPullRequest.number, ]) : null; - const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number) => { @@ -3679,6 +3719,63 @@ function ChatViewContent(props: ChatViewProps) { supportsPullRequests, ], ); + useEffect(() => { + if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { + return; + } + if (replacementLinkedThreadPullRequest === null) { + threadPrRelinkKeysRef.current.delete(activeThreadKey); + return; + } + const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; + if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; + threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); + const openSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if ( + openSurface?.kind === "pull-request" && + persistedLinkedThreadPullRequest !== null && + openSurface.projectId === persistedLinkedThreadPullRequest.projectId && + openSurface.repository.toLowerCase() === + persistedLinkedThreadPullRequest.repository.toLowerCase() && + openSurface.number === persistedLinkedThreadPullRequest.number + ) { + useRightPanelStore + .getState() + .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest); + } + + threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { + if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; + const result = await updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + linkedPullRequest: replacementLinkedThreadPullRequest, + }, + }); + if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; + if (result._tag !== "Failure") return; + threadPrRelinkKeysRef.current.delete(activeThreadKey); + if (isAtomCommandInterrupted(result)) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to update the thread pull request", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + }); + }, [ + activeThreadKey, + activeThreadRef, + isServerThread, + persistedLinkedThreadPullRequest, + replacementLinkedThreadPullRequest, + updateThreadMetadata, + ]); const openProjectPullRequest = useCallback( (number: number) => { if ( @@ -4782,60 +4879,39 @@ function ChatViewContent(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement]); - const linkedPullRequestStatus = useLinkedThreadPullRequest( - activeThreadRef?.environmentId ?? null, - linkedThreadPullRequest, - ); - const activeThreadPr = resolveDisplayedThreadPr({ - threadBranch: activeThread?.branch ?? null, - gitStatus: gitStatusQuery.data ?? null, - snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, - retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, - linkedPullRequest: linkedThreadPullRequest, - linkedPullRequestStatus, - }); + const activeThreadPr = + replacementLinkedThreadPullRequest !== null + ? (gitStatusQuery.data?.pr ?? null) + : resolveDisplayedThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, + linkedPullRequest: linkedThreadPullRequest, + linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, + }); const handlePullRequestTabStatusChange = useCallback( - (status: PullRequestTabStatus) => { - const source = threadPullRequestRefreshSource({ - panel: status, - thread: { - repository: threadRepository, - number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, - state: activeThreadPr?.state ?? null, - linked: linkedThreadPullRequest !== null, - }, - }); - if (source === null) { + (status: Pick) => { + if ( + threadRepository?.toLowerCase() !== status.repository.toLowerCase() || + activeThreadPr?.number !== status.number || + activeThreadPr.state === status.state + ) { sidebarPrRefreshKeyRef.current = null; return; } - const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; + const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; if (sidebarPrRefreshKeyRef.current === refreshKey) return; sidebarPrRefreshKeyRef.current = refreshKey; - - if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { - appAtomRegistry.refresh( - linkedPullRequestDetailAtom({ - environmentId: activeThreadRef.environmentId, - input: { - projectId: linkedThreadPullRequest.projectId, - repository: linkedThreadPullRequest.repository, - number: linkedThreadPullRequest.number, - }, - }), - ); - return; - } - if (source === "vcs" && activeThreadRef && gitCwd !== null) { - void refreshVcsStatus({ - environmentId: activeThreadRef.environmentId, - input: { cwd: gitCwd }, - }).then(() => { - if (sidebarPrRefreshKeyRef.current === refreshKey) { - sidebarPrRefreshKeyRef.current = null; - } - }); - } + if (activeThreadRef === null || gitCwd === null) return; + void refreshVcsStatus({ + environmentId: activeThreadRef.environmentId, + input: { cwd: gitCwd }, + }).then(() => { + if (sidebarPrRefreshKeyRef.current === refreshKey) { + sidebarPrRefreshKeyRef.current = null; + } + }); }, [ activeThreadKey, @@ -4843,7 +4919,6 @@ function ChatViewContent(props: ChatViewProps) { activeThreadPr?.state, activeThreadRef, gitCwd, - linkedThreadPullRequest, refreshVcsStatus, threadRepository, ], @@ -7346,7 +7421,9 @@ function ChatViewContent(props: ChatViewProps) { : "page" } composerDraftTarget={composerDraftTarget} - onStateChange={handlePullRequestTabStatusChange} + {...(linkedThreadPullRequest === null + ? { onStateChange: handlePullRequestTabStatusChange } + : {})} /> ) : renderedRightPanelSurface?.kind === "agents" ? ( = {}): VcsStatusResult { return { @@ -57,58 +57,44 @@ function snapshotFor( return { branch, pr, sourceControlProvider }; } -describe("threadPullRequestRefreshSource", () => { - const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const }; +function pullRequestSummary( + state: PullRequestSummary["state"], + updatedAt: string, +): PullRequestSummary { + return { + provider: "github", + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + state, + headBranch: "feature/current", + baseBranch: "main", + updatedAt, + }; +} - it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, - }), - ).toBe("vcs"); - }); +describe("shared pull request state", () => { + it("shows a panel-observed merge instead of an older sidebar summary", () => { + const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); + const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); - it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true }, - }), - ).toBe("linked-detail"); + expect(newestPullRequestSummary(open, merged)).toBe(merged); }); - it("refreshes when the sidebar has not resolved state yet", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false }, - }), - ).toBe("vcs"); - }); + it("never lets a stale open response regress a merged observation", () => { + const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); + const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); - it("does nothing once sidebar state matches or the panel shows another PR", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false }, - }), - ).toBeNull(); - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false }, - }), - ).toBeNull(); + expect(newestPullRequestSummary(merged, staleOpen)).toBe(merged); }); - it("matches repository identity without case sensitivity", () => { - expect( - threadPullRequestRefreshSource({ - panel: { ...panel, repository: "PingDotGG/T3Code" }, - thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, - }), - ).toBe("vcs"); + it("accepts a newer open state after a closed pull request is reopened", () => { + const closed = pullRequestSummary("closed", "2026-09-03T01:00:00.000Z"); + const reopened = pullRequestSummary("open", "2026-09-03T01:01:00.000Z"); + + expect(newestPullRequestSummary(closed, reopened)).toBe(reopened); }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e879c78b971..6bc5424cfed 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -12,7 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; -import { linkedPullRequestDetailAtom } from "../state/pullRequests"; +import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; @@ -39,32 +39,6 @@ export interface TerminalStatusIndicator { export type ThreadPr = VcsStatusResult["pr"]; -export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs"; - -/** Refresh only when the panel has newer state for this thread's own pull request. */ -export function threadPullRequestRefreshSource(input: { - readonly panel: { - readonly repository: string; - readonly number: number; - readonly state: NonNullable["state"]; - }; - readonly thread: { - readonly repository: string | null; - readonly number: number | null; - readonly state: NonNullable["state"] | null; - readonly linked: boolean; - }; -}): ThreadPullRequestRefreshSource | null { - if ( - input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() || - input.thread.number !== input.panel.number || - input.thread.state === input.panel.state - ) { - return null; - } - return input.thread.linked ? "linked-detail" : "vcs"; -} - export interface LinkedThreadPullRequestStatus { readonly pr: NonNullable; readonly sourceControlProvider: NonNullable; @@ -74,7 +48,7 @@ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, ): LinkedThreadPullRequestStatus | null { - const detail = useEnvironmentQuery( + const queried = useEnvironmentQuery( environmentId === null || linkedPullRequest == null ? null : linkedPullRequestDetailAtom({ @@ -86,6 +60,7 @@ export function useLinkedThreadPullRequest( }, }), ).data; + const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); return useMemo( () => diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index b318744e3dd..59aa1333896 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -63,7 +63,7 @@ import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment } from "~/state/pullRequests"; +import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -476,14 +476,8 @@ export function PullRequestDetailPanel({ onActed?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; - /** Keeps surrounding thread state in step with refreshed host state. */ - onStateChange?: (status: { - projectId: string; - repository: string; - number: number; - state: PullRequestState; - isDraft: boolean; - }) => void; + /** Keeps surrounding inferred thread state in step with refreshed host state. */ + onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; /** * Beside a thread, the checkout affordance disappears: the panel is showing that thread's * own pull request, so the branch is already under the reader's feet — and checking it out @@ -606,11 +600,19 @@ export function PullRequestDetailPanel({ reference.repository, reference.number, ]); - const coreDetail = resolveDisplayedPullRequestDetail({ + const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ live: detailQuery.data, cached: cachedDetail, reference, }); + const sharedSummary = useSharedPullRequestSummary(environmentId, reference, resolvedCoreDetail); + const coreDetail = useMemo( + () => + resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail + ? resolvedCoreDetail + : { ...resolvedCoreDetail, ...sharedSummary }, + [resolvedCoreDetail, sharedSummary], + ); const activity = activityQuery.data; const detail = useMemo( () => @@ -674,16 +676,14 @@ export function PullRequestDetailPanel({ } activityRevision.current = next; }, [activityQuery.refresh, coreDetail, pullRequestKey]); - useEffect(() => { - if (!detail) return; + useLayoutEffect(() => { + if (!resolvedCoreDetail) return; onStateChange?.({ - projectId: detail.projectId, - repository: detail.repository, - number: detail.number, - state: detail.state, - isDraft: detail.isDraft, + repository: resolvedCoreDetail.repository, + number: resolvedCoreDetail.number, + state: resolvedCoreDetail.state, }); - }, [detail, onStateChange]); + }, [onStateChange, resolvedCoreDetail]); // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the // revision effect above reads it only after this same pull request reports a change. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 601b6efa3dc..bde8b4c2d9c 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -7,10 +7,12 @@ import type { EnvironmentId, PullRequestListInput, PullRequestListStatsInput, + PullRequestRef, + PullRequestSummary, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useLayoutEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); +const observedPullRequestSummaryAtom = Atom.family((key: string) => + Atom.make(null).pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`web-pull-requests:observed-summary:${key}`), + ), +); + +export function newestPullRequestSummary( + current: PullRequestSummary | null, + observed: PullRequestSummary | null, +): PullRequestSummary | null { + if (current === null) return observed; + if (observed === null) return current; + if (current.state === "merged") return current; + if (observed.state === "merged") return observed; + return Date.parse(observed.updatedAt) >= Date.parse(current.updatedAt) ? observed : current; +} + +export function useSharedPullRequestSummary( + environmentId: EnvironmentId | null, + reference: PullRequestRef | null, + current: PullRequestSummary | null, +): PullRequestSummary | null { + const key = + environmentId === null || reference === null + ? "none" + : JSON.stringify([ + environmentId, + reference.projectId, + reference.repository.toLowerCase(), + reference.number, + ]); + const atom = observedPullRequestSummaryAtom(key); + const observed = useAtomValue(atom); + useLayoutEffect(() => { + if (environmentId === null || current === null) return; + appAtomRegistry.modify(atom, (previous) => { + const next = newestPullRequestSummary(previous, current); + return next === previous ? [false, previous] : [true, next]; + }); + }, [atom, current, environmentId]); + return newestPullRequestSummary(current, observed); +} + export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; readonly input: Input; From 9f9359bd8132c425493720080149dcc0d3da9436 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 22:59:46 -0700 Subject: [PATCH 27/46] fix(web): stop usage summary requests reporting slow RPCs (#9358) --- apps/web/src/rpc/requestLatencyState.test.ts | 7 +++++++ apps/web/src/rpc/requestLatencyState.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 68433035fd1..1cfae8a6f92 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -59,6 +59,13 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("ignores usage summary requests", () => { + trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + it.each(Object.values(WS_METHODS).filter((method) => method.startsWith("pullRequests.")))( "ignores pull request workspace request %s", (method) => { diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index cb68a090775..9015a3c40b0 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -28,7 +28,10 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); -const untrackedRpcAckMethods = new Set([WS_METHODS.previewAutomationConnect]); +const untrackedRpcAckMethods = new Set([ + WS_METHODS.previewAutomationConnect, + WS_METHODS.serverGetUsageSummary, +]); const longRunningRpcAckMethods = new Set([ WS_METHODS.serverUpdateProvider, WS_METHODS.serverRefreshProviders, From b5f4e8137b3cbd657fbe2b698f9db4e321acbc3d Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:07:50 -0700 Subject: [PATCH 28/46] feat(web): suggest ssh hosts in a dropdown under the host field (#9171) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + .../desktop/src/ipc/methods/sshEnvironment.ts | 10 + apps/desktop/src/preload.ts | 1 + apps/desktop/src/ssh/DesktopSshEnvironment.ts | 9 + .../settings/ConnectionsSettings.tsx | 421 ++++++++++-------- apps/web/src/state/desktopSshHosts.test.ts | 82 +++- apps/web/src/state/desktopSshHosts.ts | 22 + packages/contracts/src/ipc.ts | 2 + 9 files changed, 365 insertions(+), 185 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 33fa5feacaa..124ee5095a6 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -21,6 +21,7 @@ import { fetchSshEnvironmentDescriptor, fetchSshSessionState, issueSshWebSocketTicket, + resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; import { @@ -68,6 +69,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); + yield* ipc.handle(resolveSshHost); yield* ipc.handle(ensureSshEnvironment); yield* ipc.handle(disconnectSshEnvironment); yield* ipc.handle(fetchSshEnvironmentDescriptor); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 90e7add6229..0e966431b06 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -29,6 +29,7 @@ export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 9c9af2a4e2b..cfb993d35cf 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -117,6 +117,16 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ }), }); +export const resolveSshHost = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_SSH_HOST_CHANNEL, + payload: Schema.String, + result: DesktopSshEnvironmentTargetSchema, + handler: Effect.fn("desktop.ipc.sshEnvironment.resolveHost")(function* (alias) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.resolveHost(alias); + }), +}); + export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b91aa5624dc..452f4b851bc 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -60,6 +60,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), + resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 31e84ae995e..2c9ab0c03e4 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -5,6 +5,7 @@ import type { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as SshAuth from "@t3tools/ssh/auth"; +import { resolveSshTarget } from "@t3tools/ssh/command"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -54,6 +55,9 @@ export class DesktopSshEnvironment extends Context.Service< readonly discoverHosts: (input?: { readonly homeDir?: string; }) => Effect.Effect; + readonly resolveHost: ( + alias: string, + ) => Effect.Effect; readonly ensureEnvironment: ( target: DesktopSshEnvironmentTarget, options?: { readonly issuePairingToken?: boolean }, @@ -136,6 +140,11 @@ export const make = Effect.gen(function* () { Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.discoverHosts"), ), + resolveHost: (alias) => + resolveSshTarget(alias.trim()).pipe( + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.resolveHost"), + ), ensureEnvironment: (target, ensureOptions) => manager .ensureEnvironment(target, ensureOptions) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 66f13208df4..6b81a7f70dd 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,12 +1,16 @@ -import { - ChevronsLeftRightEllipsisIcon, - PlusIcon, - QrCodeIcon, - RefreshCwIcon, - TerminalIcon, -} from "lucide-react"; +import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { + type KeyboardEvent, + type ReactNode, + memo, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -56,6 +60,15 @@ import { import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { Input } from "../ui/input"; +import { CommandShortcut } from "../ui/command"; +import { + Autocomplete, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompletePopup, +} from "../ui/autocomplete"; import { Checkbox } from "../ui/checkbox"; import { Dialog, @@ -123,7 +136,7 @@ import { desktopNetworkAccessStateAtom, refreshDesktopNetworkAccessState, } from "~/state/desktopNetworkAccess"; -import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; +import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; import { type EnvironmentPresentation, @@ -131,11 +144,17 @@ import { usePrimaryEnvironment, } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; -import { serverEnvironment } from "~/state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, +} from "../../keybindings"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; @@ -1561,46 +1580,6 @@ function SavedBackendListRow({ ); } -interface DesktopSshHostRowProps { - target: DesktopDiscoveredSshHost; - connectingHostAlias: string | null; - onConnect: (target: DesktopDiscoveredSshHost) => void; -} - -const DesktopSshHostRow = memo(function DesktopSshHostRow({ - target, - connectingHostAlias, - onConnect, -}: DesktopSshHostRowProps) { - const address = formatDesktopSshTarget(target); - const showAddress = address !== target.alias; - const buttonLabel = connectingHostAlias === target.alias ? "Adding…" : "Add environment"; - - return ( -
-
-
-

{target.alias}

- {showAddress ?

{address}

: null} -
-
- -
-
-
- ); -}); - function CloudLinkSwitch({ checked, disabled, @@ -1769,6 +1748,7 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1792,24 +1772,6 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); - const savedDesktopSshEnvironmentsByAlias = useMemo( - () => - savedEnvironments.reduce>( - (accumulator, environment) => { - const profile = environment.entry.profile; - if ( - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(profile) && - profile.value._tag === "SshConnectionProfile" - ) { - accumulator[profile.value.target.alias] = environment; - } - return accumulator; - }, - {}, - ), - [savedEnvironments], - ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); for (const environment of savedEnvironments) { @@ -1827,9 +1789,6 @@ export function ConnectionsSettings() { } return keys; }, [savedEnvironments]); - const [sshConnectionError, setSshConnectionError] = useState(null); - const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); - const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< string | null >(null); @@ -1850,6 +1809,9 @@ export function ConnectionsSettings() { const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); + const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false); + // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text. + const highlightedSshHostRef = useRef(undefined); const [savedBackendError, setSavedBackendError] = useState(null); const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = @@ -1915,11 +1877,17 @@ export function ConnectionsSettings() { const desktopNetworkAccess = useEnvironmentQuery( canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, ); + const isSshDiscoveryActive = + desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh"; const desktopSshHosts = useEnvironmentQuery( - desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh" - ? desktopSshHostsStateAtom - : null, + isSshDiscoveryActive ? desktopSshHostsStateAtom : null, ); + // The discovery atom is kept alive across dialog opens, so re-read SSH config + // each time the SSH tab is shown; stale hosts stay visible while it refreshes. + const refreshDesktopSshHosts = desktopSshHosts.refresh; + useEffect(() => { + if (isSshDiscoveryActive) refreshDesktopSshHosts(); + }, [isSshDiscoveryActive, refreshDesktopSshHosts]); const desktopWsl = useEnvironmentQuery( canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, ); @@ -1938,10 +1906,15 @@ export function ConnectionsSettings() { }), [discoveredSshHosts, savedDesktopSshEnvironmentKeys], ); - const hasLoadedDiscoveredSshHosts = - desktopSshHosts.data !== null || desktopSshHosts.error !== null; - const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending; - const discoveredSshHostsError = sshConnectionError ?? desktopSshHosts.error; + const filteredDiscoveredSshHosts = useMemo( + () => filterDiscoveredSshHosts(unsavedDiscoveredSshHosts, savedBackendSshHost), + [savedBackendSshHost, unsavedDiscoveredSshHosts], + ); + const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null; + const discoveredSshHostsError = desktopSshHosts.error; + const hasSshHostSuggestionContent = + desktopBridge !== undefined && + (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0); const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; const desktopAdvertisedEndpoints = desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; @@ -2165,23 +2138,11 @@ export function ConnectionsSettings() { } }, []); - const handleAddSavedBackend = useCallback(async () => { - if (savedBackendMode === "ssh") { + // Shared by manual SSH submission and discovered-host selection. + const connectSavedBackendSshTarget = useCallback( + async (target: DesktopSshEnvironmentTarget) => { setIsAddingSavedBackend(true); setSavedBackendError(null); - let target: DesktopSshEnvironmentTarget; - try { - target = parseManualDesktopSshTarget({ - host: savedBackendSshHost, - username: savedBackendSshUsername, - port: savedBackendSshPort, - }); - } catch (error) { - setSavedBackendError(formatDesktopSshConnectionError(error)); - setIsAddingSavedBackend(false); - return; - } - const result = await connectSshEnvironment({ target, label: "" }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { @@ -2203,6 +2164,25 @@ export function ConnectionsSettings() { description: `${target.alias} is ready over an SSH-managed tunnel.`, }); setIsAddingSavedBackend(false); + }, + [connectSshEnvironment], + ); + + const handleAddSavedBackend = useCallback(async () => { + if (savedBackendMode === "ssh") { + let target: DesktopSshEnvironmentTarget; + try { + target = parseManualDesktopSshTarget({ + host: savedBackendSshHost, + username: savedBackendSshUsername, + port: savedBackendSshPort, + }); + } catch (error) { + setSavedBackendError(formatDesktopSshConnectionError(error)); + return; + } + + await connectSavedBackendSshTarget(target); return; } @@ -2260,7 +2240,7 @@ export function ConnectionsSettings() { setIsAddingSavedBackend(false); }, [ connectPairing, - connectSshEnvironment, + connectSavedBackendSshTarget, savedBackendHost, savedBackendMode, savedBackendPairingCode, @@ -2269,6 +2249,92 @@ export function ConnectionsSettings() { savedBackendSshUsername, ]); + const handleSavedBackendSshFieldKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) { + event.preventDefault(); + void handleAddSavedBackend(); + } + }, + [handleAddSavedBackend, savedBackendSshHost], + ); + + // Resolves a picked alias before connecting it through the manual SSH flow. + const handleSelectSshHostSuggestion = useCallback( + async (target: DesktopDiscoveredSshHost) => { + if (isAddingSavedBackend || !desktopBridge) return; + + setIsAddingSavedBackend(true); + setSavedBackendError(null); + setSavedBackendSshHost(target.alias); + let resolved: DesktopSshEnvironmentTarget; + try { + resolved = await desktopBridge.resolveSshHost(target.alias); + } catch (error) { + setSavedBackendError(formatDesktopSshConnectionError(error)); + setIsAddingSavedBackend(false); + return; + } + setSavedBackendSshUsername(resolved.username ?? ""); + setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port)); + await connectSavedBackendSshTarget(resolved); + }, + [connectSavedBackendSshTarget, desktopBridge, isAddingSavedBackend], + ); + + const handleSavedBackendSshHostKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + + // The popup only renders when there is content, so an "open" flag alone is not enough. + const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent; + if (isSshHostPopupVisible) { + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false }, + }); + const index = threadJumpIndexFromCommand(command ?? ""); + const target = index === null ? undefined : filteredDiscoveredSshHosts[index]; + if (target) { + event.preventDefault(); + event.stopPropagation(); + setSshHostSuggestionsOpen(false); + void handleSelectSshHostSuggestion(target); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + + // A highlighted row means Enter belongs to the autocomplete, which selects it. + const hasHighlightedSshHost = + isSshHostPopupVisible && highlightedSshHostRef.current !== undefined; + if ( + !event.defaultPrevented && + !hasHighlightedSshHost && + event.key === "Enter" && + savedBackendSshHost.trim().length > 0 + ) { + event.preventDefault(); + void handleAddSavedBackend(); + } + }, + [ + filteredDiscoveredSshHosts, + handleAddSavedBackend, + handleSelectSshHostSuggestion, + hasSshHostSuggestionContent, + keybindings, + savedBackendSshHost, + sshHostSuggestionsOpen, + ], + ); + const handleConnectSavedBackend = useCallback( async (environmentId: EnvironmentId) => { setSavedBackendError(null); @@ -2311,46 +2377,6 @@ export function ConnectionsSettings() { [removeEnvironment], ); - const handleConnectSshHost = useCallback( - async (target: DesktopSshEnvironmentTarget, label?: string) => { - setConnectingSshHostAlias(target.alias); - if (savedBackendMode === "ssh") { - setSavedBackendError(null); - } else { - setSshConnectionError(null); - } - const result = await connectSshEnvironment({ - target, - ...(label === undefined ? {} : { label }), - }); - setConnectingSshHostAlias(null); - if (result._tag === "Success") { - setSavedBackendSshHost(""); - setSavedBackendSshUsername(""); - setSavedBackendSshPort(""); - setAddBackendDialogOpen(false); - toastManager.add({ - type: "success", - title: savedDesktopSshEnvironmentsByAlias[target.alias] - ? "Environment reconnected" - : "Environment connected", - description: `${label?.trim() || target.alias} is ready over an SSH-managed tunnel.`, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - const message = formatDesktopSshConnectionError(error); - if (savedBackendMode === "ssh") { - setSavedBackendError(message); - } else { - setSshConnectionError(message); - } - } - }, - [connectSshEnvironment, savedBackendMode, savedDesktopSshEnvironmentsByAlias], - ); - const visibleDesktopPairingLinks = desktopPairingLinks; const tailscaleHttpsEndpoint = useMemo( () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, @@ -2496,24 +2522,90 @@ export function ConnectionsSettings() { const renderSshFields = () => (
-
); const renderNetworkAccessToggle = () => ( diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts index 83eda60158c..39958ac6e47 100644 --- a/apps/web/src/state/desktopSshHosts.test.ts +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -4,7 +4,7 @@ import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; -import { createDesktopSshHostsStateAtom } from "./desktopSshHosts"; +import { createDesktopSshHostsStateAtom, filterDiscoveredSshHosts } from "./desktopSshHosts"; const hosts: ReadonlyArray = [ { @@ -16,6 +16,86 @@ const hosts: ReadonlyArray = [ }, ]; +describe("filterDiscoveredSshHosts", () => { + const suggestions: ReadonlyArray = [ + { + alias: "grape", + hostname: "grape", + port: null, + source: "known-hosts", + username: null, + }, + { + alias: "Pinot", + hostname: "Pinot", + port: null, + source: "ssh-config", + username: null, + }, + { + alias: "preprod", + hostname: "preprod", + port: 2222, + source: "ssh-config", + username: "deploy", + }, + { + alias: "prod-z", + hostname: "prod-z", + port: null, + source: "ssh-config", + username: null, + }, + { + alias: "prod-a", + hostname: "prod-a", + port: null, + source: "ssh-config", + username: null, + }, + { + alias: "prod-m", + hostname: "prod-m", + port: null, + source: "ssh-config", + username: null, + }, + ]; + + it.each(["", " "])("returns every host for an empty query %j", (query) => { + expect(filterDiscoveredSshHosts(suggestions, query)).toEqual(suggestions); + }); + + it("trims the query", () => { + expect(filterDiscoveredSshHosts(suggestions, " pi ")).toEqual([suggestions[1]]); + }); + + it("ranks alias prefix matches before substring matches", () => { + expect(filterDiscoveredSshHosts(suggestions, "prod")).toEqual([ + suggestions[3], + suggestions[4], + suggestions[5], + suggestions[2], + ]); + }); + + it("preserves the original order within a match tier", () => { + expect(filterDiscoveredSshHosts(suggestions, "prod").slice(0, 3)).toEqual([ + suggestions[3], + suggestions[4], + suggestions[5], + ]); + }); + + it("matches case-insensitively", () => { + expect(filterDiscoveredSshHosts(suggestions, "PINOT")).toEqual([suggestions[1]]); + }); + + it("returns an empty array when no hosts match", () => { + expect(filterDiscoveredSshHosts(suggestions, "merlot")).toEqual([]); + }); +}); + describe("desktopSshHostsState", () => { it("retains discovered hosts when the settings screen remounts", async () => { const discoverSshHosts = vi.fn(async () => hosts); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts index 8e4022cbecf..027ce867684 100644 --- a/apps/web/src/state/desktopSshHosts.ts +++ b/apps/web/src/state/desktopSshHosts.ts @@ -5,6 +5,28 @@ import { Atom } from "effect/unstable/reactivity"; type DesktopSshDiscoveryBridge = Pick; +/** Filters and ranks SSH host suggestions as the user types in the host field. */ +export function filterDiscoveredSshHosts( + hosts: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) return hosts; + + const prefixMatches: Array = []; + const substringMatches: Array = []; + for (const host of hosts) { + const alias = host.alias.toLowerCase(); + if (alias.startsWith(normalizedQuery)) { + prefixMatches.push(host); + } else if (alias.includes(normalizedQuery)) { + substringMatches.push(host); + } + } + + return [...prefixMatches, ...substringMatches]; +} + class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass()( "DesktopSshDiscoveryUnavailableError", {}, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 25b06e866fd..ea276f4ff92 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1066,6 +1066,8 @@ export interface DesktopBridge { setConnectionCatalog?: (catalog: string) => Promise; clearConnectionCatalog?: () => Promise; discoverSshHosts: () => Promise; + /** Resolves a suggested SSH alias before populating the connection form. */ + resolveSshHost: (alias: string) => Promise; ensureSshEnvironment: ( target: DesktopSshEnvironmentTarget, options?: { issuePairingToken?: boolean }, From d4bd8923ad8cab346a854967015b06d9e5cd77f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 23:15:23 -0700 Subject: [PATCH 29/46] feat(web): mod+w closes the active right panel tab before the window (#9363) Co-authored-by: Claude Code --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 11 +++++++++ apps/web/src/keybindings.test.ts | 23 ++++++++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 27 +++++++++++++++++++++ docs/user/keybindings.md | 6 +++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 7 files changed, 70 insertions(+) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 079e7a14eda..6c32235c27f 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -207,6 +207,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); + assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d76b4ae5d6..ca8f85dc315 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5691,6 +5691,16 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "rightPanel.close") { + // Nothing open: leave the event alone so the shortcut keeps its + // native meaning (close window on desktop, close tab in a browser). + if (!activeRightPanelSurface) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); @@ -5779,6 +5789,7 @@ function ChatViewContent(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, + closeRightPanelSurface, requestCloseTerminal, requestClosePanelTerminal, createNewTerminal, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0f3fa825e44..8cbc4596652 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -108,6 +108,11 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.close", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: modShortcut("w"), + command: "rightPanel.close", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("d"), command: "diff.toggle", @@ -751,6 +756,24 @@ describe("resolveShortcutCommand", () => { ); }); + it("routes mod+w to the terminal while focused and to the right panel otherwise", () => { + const closeEvent = event({ key: "w", metaKey: true }); + assert.strictEqual( + resolveShortcutCommand(closeEvent, DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "terminal.close", + ); + assert.strictEqual( + resolveShortcutCommand(closeEvent, DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "rightPanel.close", + ); + }); + it("resolves a custom right panel maximize binding", () => { const keybindings = compile([ { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 912366d14ab..09701423fae 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -11,6 +11,7 @@ import type { PullRequestListState, SourceControlProviderKind, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { ArrowDownUpIcon, @@ -34,6 +35,7 @@ import { import { useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -107,7 +109,10 @@ import { } from "../components/WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../components/WorkspacePageContainer"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; +import { resolveShortcutCommand } from "../keybindings"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -135,6 +140,7 @@ import { } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; export interface PullRequestsSearch extends PullRequestListPreferences { @@ -285,6 +291,7 @@ function PullRequestsRouteView() { const statsPolicy: PullRequestStatsPolicy = sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; const navigate = useNavigate({ from: Route.fullPath }); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); // Every connected environment that has said it can list pull requests. Sorted, so the query // keys, the scope key and the stored snapshot all read the same whichever order the @@ -1888,6 +1895,26 @@ function PullRequestsRouteView() { selectSurfaceInUrl(null); }; + // This page has no ChatView, so the shared panel handles `rightPanel.close` + // itself. With nothing open the event falls through to its native meaning. + const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (activePullRequestSurface === null) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) closeSurface(activePullRequestSurface); + }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || isCommandPaletteOpen()) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { terminalFocus: isTerminalFocused() }, + }); + if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings]); + return (
diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 4668ec64092..fe97eedadbc 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,6 +52,12 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. +`rightPanel.close` closes the active right panel tab and defaults to `mod+w`. Press it again to close +the next tab. With the terminal focused, `mod+w` closes the terminal instead, and with nothing left +to close it closes the desktop window as before. Browsers reserve `mod+w` for closing their own tab +and never pass it to the page, so in a browser rebind this command (and `terminal.close`) to a +shortcut the browser leaves alone, such as `alt+w`. + `thread.copyReference` copies the active thread's pull request link, or its thread ID when no pull request is available. Its default shortcut is `mod+shift+c`, and it does not replace terminal copy while the terminal has focus. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index fe3549d079a..71b2547e532 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -59,6 +59,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "terminal.close", "rightPanel.toggle", "rightPanel.toggleMaximized", + "rightPanel.close", "diff.toggle", "preview.toggle", "preview.refresh", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 939e88b7f0b..1107873c828 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -26,6 +26,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+shift+j", command: "preview.toggle" }, { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, From 5eb4f452ee0fcde7b5bbc93d62de9118b70a33b2 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:16:21 +0000 Subject: [PATCH 30/46] test(web): remove static markup-only component tests (#9364) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .../components/ProjectScriptsControl.test.tsx | 65 ------------------- .../ComposerPendingTerminalContexts.test.tsx | 28 -------- .../ComposerPromptLengthValidation.test.tsx | 23 ------- .../chat/PanelLayoutControls.test.tsx | 28 -------- .../preview/PreviewChromeRow.test.tsx | 24 ------- apps/web/src/components/ui/command.test.tsx | 31 --------- 6 files changed, 199 deletions(-) delete mode 100644 apps/web/src/components/ProjectScriptsControl.test.tsx delete mode 100644 apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx delete mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx delete mode 100644 apps/web/src/components/chat/PanelLayoutControls.test.tsx delete mode 100644 apps/web/src/components/preview/PreviewChromeRow.test.tsx delete mode 100644 apps/web/src/components/ui/command.test.tsx diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx deleted file mode 100644 index d9f3e7e69f0..00000000000 --- a/apps/web/src/components/ProjectScriptsControl.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import ProjectScriptsControl from "./ProjectScriptsControl"; - -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -const PRIMARY_SCRIPT: ProjectScript = { - id: "dev", - name: "Dev", - command: "vp dev", - icon: "play", - runOnWorktreeCreate: false, -}; - -function renderControl(scripts: ReadonlyArray) { - return renderToStaticMarkup( - {}} - onAddScript={async () => undefined as never} - onUpdateScript={async () => undefined as never} - onDeleteScript={async () => undefined as never} - />, - ); -} - -function buttonTag(html: string, ariaLabel: string) { - return html.match(new RegExp(`]*aria-label="${ariaLabel}"[^>]*>`))?.[0]; -} - -function expectResponsiveXsControl(markup: string | undefined) { - expect(markup).toBeDefined(); - expect(markup).toContain("h-7"); - expect(markup).toContain("gap-1"); - expect(markup).toContain("text-sm"); - expect(markup).toContain("sm:h-6"); - expect(markup).toContain("sm:text-xs"); - expect(markup).toContain("w-7"); - expect(markup).toContain("px-0"); - expect(markup).toContain("sm:w-6"); - expect(markup).toContain("@3xl/header-actions:w-auto!"); - expect(markup).toContain("@3xl/header-actions:px-[calc(--spacing(2)-1px)]"); -} - -describe("ProjectScriptsControl compact controls", () => { - it("keeps the primary Run control compact and expands it with its label", () => { - const html = renderControl([PRIMARY_SCRIPT]); - - expectResponsiveXsControl(buttonTag(html, "Run Dev")); - expect(html).toContain( - 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', - ); - }); - - it("keeps the standalone Add control compact and expands it with its label", () => { - const html = renderControl([]); - - expectResponsiveXsControl(buttonTag(html, "Add action")); - expect(html).toContain( - 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', - ); - }); -}); diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx deleted file mode 100644 index 3c610a7b5c4..00000000000 --- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts"; - -describe("ComposerPendingTerminalContextChip", () => { - it("renders expired terminal contexts with error styling", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-terminal-context-expired="true"'); - expect(markup).toContain("border-destructive/35"); - expect(markup).toContain("Terminal 1 lines 2-4"); - }); -}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx deleted file mode 100644 index 3ffb4fa9c20..00000000000 --- a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; -import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; - -describe("ComposerPromptLengthValidation", () => { - it("renders oversized prompt feedback as an actionable composer alert", () => { - const message = getComposerPromptLengthValidationMessage( - "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), - ); - - const markup = renderToStaticMarkup(); - - expect(markup).toContain('role="alert"'); - expect(markup).toContain('data-chat-composer-validation="prompt-length"'); - expect(markup).toContain( - "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", - ); - expect(markup).not.toContain("ProviderValidationError"); - }); -}); diff --git a/apps/web/src/components/chat/PanelLayoutControls.test.tsx b/apps/web/src/components/chat/PanelLayoutControls.test.tsx deleted file mode 100644 index 51ae1a73ad0..00000000000 --- a/apps/web/src/components/chat/PanelLayoutControls.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { PanelLayoutControls } from "./PanelLayoutControls"; - -describe("PanelLayoutControls", () => { - it("keeps unavailable panel tooltip triggers interactive", () => { - const markup = renderToStaticMarkup( - {}} - onToggleRightPanel={() => {}} - />, - ); - - expect(markup.match(/data-slot="tooltip-trigger"/g)).toHaveLength(2); - expect(markup.match(/data-slot="tooltip-trigger"[^>]*>]*disabled=""/g)).toHaveLength( - 2, - ); - }); -}); diff --git a/apps/web/src/components/preview/PreviewChromeRow.test.tsx b/apps/web/src/components/preview/PreviewChromeRow.test.tsx deleted file mode 100644 index 143d38e67a2..00000000000 --- a/apps/web/src/components/preview/PreviewChromeRow.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { PreviewChromeRow } from "./PreviewChromeRow"; - -describe("PreviewChromeRow", () => { - it("shows the complete URL while the address bar is not focused", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('value="https://example.com/dashboard?mode=edit&tab=1#notes"'); - }); -}); diff --git a/apps/web/src/components/ui/command.test.tsx b/apps/web/src/components/ui/command.test.tsx deleted file mode 100644 index bd03b2712d3..00000000000 --- a/apps/web/src/components/ui/command.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Command, CommandFooter, CommandInput } from "./command"; - -describe("command compact geometry", () => { - it("keeps shell selectors on the wrapper and direct-input padding on AutocompleteInput", () => { - const html = renderToStaticMarkup( - - - , - ); - const shellClass = html.match(/class="([^"]*px-\[var\(--command-shell-inset\)[^"]*)"/)?.[1]; - const inputClass = html.match(/class="([^"]*has-focus-visible:ring-0[^"]*)"/)?.[1]; - - expect(shellClass).toContain( - "[&_[data-slot=autocomplete-start-addon]]:ps-[calc(var(--command-shell-inset)+0.0625rem)]", - ); - expect(shellClass).not.toContain("sm:*:data-[slot=autocomplete-input]"); - expect(inputClass).toContain( - "sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!", - ); - }); - - it("uses the semantic footer inset without changing compact vertical padding", () => { - const html = renderToStaticMarkup(Shortcuts); - - expect(html).toContain("px-[var(--command-content-inset)]"); - expect(html).toContain("py-2.5"); - }); -}); From 829c3db94830fc70b5754513b8a370f7301ca213 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 23:23:44 -0700 Subject: [PATCH 31/46] fix(environments): draw the machine icon everywhere an environment is named (#9365) Co-authored-by: Claude Fable 5 --- .../archive/ArchivedThreadsScreen.tsx | 35 +++++++++++++++---- .../features/projects/AddProjectScreen.tsx | 18 +++++++--- .../SettingsClientStorageRouteScreen.tsx | 16 +++++---- .../threads/NewTaskContextPickerScreens.tsx | 24 ++++++++++--- .../features/threads/NewTaskDraftScreen.tsx | 14 ++++++-- .../BranchToolbarEnvironmentSelector.tsx | 26 ++++++-------- apps/web/src/components/CommandPalette.tsx | 11 ++++-- .../src/components/ThreadStatusIndicators.tsx | 21 ++++++++--- .../settings/ProviderSettingsPanel.tsx | 29 +++++---------- docs/user/thread-sidebar.md | 6 ++-- 10 files changed, 130 insertions(+), 70 deletions(-) diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 5b61d302a76..c2d5b6cf65a 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -3,7 +3,11 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type EnvironmentMachineKind, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -24,10 +28,12 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { useServerConfigs } from "../../state/entities"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem, @@ -45,6 +51,7 @@ type ArchivedThreadListItem = readonly kind: "project"; readonly key: string; readonly environmentLabel: string | null; + readonly environmentMachine: EnvironmentMachineKind; readonly project: EnvironmentProject; } | { @@ -360,6 +367,7 @@ function ArchivedThreadsHeader(props: { function ProjectGroupLabel(props: { readonly environmentLabel: string | null; + readonly environmentMachine: EnvironmentMachineKind; readonly project: EnvironmentProject; }) { return ( @@ -378,9 +386,16 @@ function ProjectGroupLabel(props: { {props.project.title} {props.environmentLabel ? ( - - {props.environmentLabel} - + + + + {props.environmentLabel} + + ) : null} ); @@ -517,6 +532,7 @@ export function ArchivedThreadsScreen(props: { ), [props.environments], ); + const serverConfigs = useServerConfigs(); const listItems = useMemo>(() => { const items: ArchivedThreadListItem[] = []; for (const group of props.groups) { @@ -525,6 +541,9 @@ export function ArchivedThreadsScreen(props: { kind: "project", key: `${group.key}:project`, environmentLabel, + environmentMachine: resolveEnvironmentMachineKind( + serverConfigs.get(group.project.environmentId) ?? null, + ), project: group.project, }); @@ -540,7 +559,7 @@ export function ArchivedThreadsScreen(props: { }); } return items; - }, [environmentLabelsById, props.groups]); + }, [environmentLabelsById, props.groups, serverConfigs]); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current && openSwipeableRef.current !== methods) { openSwipeableRef.current.close(); @@ -559,7 +578,11 @@ export function ArchivedThreadsScreen(props: { if (item.kind === "project") { return ( - + ); } diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a82f6937378..cc1e8f4e579 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -31,7 +31,13 @@ import { inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; -import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + type EnvironmentId, + type EnvironmentMachineKind, + ProjectId, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -49,6 +55,7 @@ import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { uuidv4 } from "../../lib/uuid"; @@ -65,6 +72,7 @@ interface EnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; readonly platform: string; + readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; @@ -352,6 +360,7 @@ function useEnvironmentOptions(): ReadonlyArray { environmentId: connection.environmentId, label: connection.environmentLabel, platform: platformFromOs(config?.environment.platform.os ?? null), + machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, @@ -490,11 +499,10 @@ export function AddProjectSourceScreen() { }) } icon={ - } selected={environment.environmentId === selectedEnvironment?.environmentId} diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 3480340f409..bf66574717e 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -1,4 +1,5 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; @@ -6,11 +7,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { clearClientCacheAtom, clientCacheSummaryAtom, type EnvironmentClientCacheSummary, } from "../../state/client-cache-state"; +import { useServerConfigs } from "../../state/entities"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsSection } from "./components/SettingsSection"; @@ -20,6 +23,7 @@ export function SettingsClientStorageRouteScreen() { const clearResult = useAtomValue(clearClientCacheAtom); const clearCache = useAtomSet(clearClientCacheAtom); const { savedConnectionsById } = useSavedRemoteConnections(); + const serverConfigs = useServerConfigs(); const isClearing = clearResult.waiting; const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null; const environmentSummaries = useMemo( @@ -106,6 +110,9 @@ export function SettingsClientStorageRouteScreen() { savedConnectionsById[environment.environmentId]?.environmentLabel ?? environment.environmentId } + machine={resolveEnvironmentMachineKind( + serverConfigs.get(environment.environmentId) ?? null, + )} disabled={isClearing} first={index === 0} onClear={() => confirmClearEnvironment(environment)} @@ -169,6 +176,7 @@ export function SettingsClientStorageRouteScreen() { function CacheEnvironmentRow(props: { readonly environment: EnvironmentClientCacheSummary; readonly environmentLabel: string; + readonly machine: EnvironmentMachineKind; readonly disabled: boolean; readonly first: boolean; readonly onClear: () => void; @@ -181,13 +189,7 @@ function CacheEnvironmentRow(props: { : "border-t border-border flex-row items-center gap-3 p-4" } > - + {props.environmentLabel} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 33aaaeef08b..96bd7438057 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -1,4 +1,5 @@ import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -21,9 +22,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useServerConfigs } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; import { @@ -35,7 +38,7 @@ import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; function SelectionRow(props: { - readonly icon?: "arrow.triangle.branch" | "desktopcomputer"; + readonly icon?: "arrow.triangle.branch" | ReactNode; readonly onPress: () => void; readonly disabled?: boolean; readonly selected: boolean; @@ -56,14 +59,16 @@ function SelectionRow(props: { onPress={props.onPress} style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.icon ? ( + {props.icon === "arrow.triangle.branch" ? ( - ) : null} + ) : ( + (props.icon ?? null) + )} {props.title} @@ -145,6 +150,7 @@ export function NewTaskEnvironmentPickerRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const serverConfigs = useServerConfigs(); return ( @@ -170,7 +176,15 @@ export function NewTaskEnvironmentPickerRouteScreen() { {flow.environments.map((environment, index) => ( + } isLast={index === flow.environments.length - 1} onPress={() => { void Haptics.selectionAsync(); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 9db04041440..e0ca8fee324 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -24,7 +24,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -36,6 +39,7 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom, @@ -1129,7 +1133,13 @@ export function NewTaskDraftScreen(props: { accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} chevronDirection="right" disabled={isComposerInteractionLocked || voiceInput.isBusy} - icon="desktopcomputer" + iconNode={ + + } label={`on ${selectedEnvironmentLabel}`} maxWidth={260} onPress={ diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index b5d5751a280..d970daba329 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,8 +1,8 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Select, SelectGroup, @@ -52,11 +52,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs" data-composer-context-control > - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + ( - {env.isPrimary ? ( - - ) : ( - - )} + {env.label} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2148a6bfa02..c6be4649816 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -36,6 +36,7 @@ import { type SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, + resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -48,7 +49,6 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -145,6 +145,7 @@ import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; @@ -738,6 +739,7 @@ function OpenCommandPaletteDialog(props: { : isLocal ? `${environment.label} (Local)` : environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), }, ] as const; }), @@ -1119,12 +1121,17 @@ function OpenCommandPaletteDialog(props: { const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", + machine: "server" as const, }; return ( {location.kind === "remote" ? ( - + ) : null} {location.label} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 6bc5424cfed..1ad0c3139fd 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,12 +4,18 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; -import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts"; +import { + type EnvironmentId, + resolveEnvironmentMachineKind, + type ThreadLinkedPullRequest, + type 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"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; @@ -602,10 +608,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + // No primary (the hosted app) means every thread is remote, and the machine + // glyph is what tells the environments apart. + const isRemoteThread = thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; + const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); if (!terminalStatus && !isRemoteThread) { @@ -642,7 +650,10 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {threadEnvironmentLabel} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 1399caa9b26..cff35a91099 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -12,6 +12,7 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, + resolveEnvironmentMachineKind, resolveProviderInstanceEnabled, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -23,22 +24,14 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; -import { - ChevronDownIcon, - CloudIcon, - LaptopIcon, - LoaderIcon, - MonitorIcon, - PlusIcon, - RefreshCwIcon, - TerminalIcon, -} from "lucide-react"; +import { ChevronDownIcon, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; import { @@ -152,14 +145,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } -function providerEnvironmentIcon(environment: EnvironmentPresentation) { - if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon; - if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon; - if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon; - if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon; - return CloudIcon; -} - function providerEnvironmentDetail(environment: EnvironmentPresentation): string { if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; if (environment.relayManaged) return "T3 Connect"; @@ -259,7 +244,7 @@ function ProviderSettingsPanelContent() { className="flex h-full w-max min-w-full border-b border-border/70 px-1" > {options.map((environment) => { - const Icon = providerEnvironmentIcon(environment); + const machine = resolveEnvironmentMachineKind(environment.serverConfig); const selected = environment.environmentId === effectiveEnvironmentId; const detail = providerEnvironmentDetail(environment); const statusText = connectionStatusText(environment.connection); @@ -273,7 +258,11 @@ function ProviderSettingsPanelContent() { className={cn(providerSettingsTabClassName(selected), "gap-2 text-left")} onClick={() => setSelectedEnvironmentId(environment.environmentId)} > - + {environment.label} {environment.connection.phase !== "connected" ? ( Date: Wed, 2 Sep 2026 23:35:15 -0700 Subject: [PATCH 32/46] fix(web): settled sidebar rows use the project fallback icon (#9366) Co-authored-by: Claude Code --- apps/web/src/components/Sidebar.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 39253206ed5..38a26c7ac4e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -49,7 +49,6 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, - MessageSquareIcon, PinIcon, PlusIcon, SearchIcon, @@ -1309,7 +1308,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { faviconPath={props.projectFaviconPath} projectIcon={props.projectIcon} className="size-4" - fallbackIcon={MessageSquareIcon} /> {title} @@ -1757,7 +1755,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { faviconPath={props.projectFaviconPath} projectIcon={props.projectIcon} className="size-4 shrink-0" - fallbackIcon={MessageSquareIcon} /> {thread.title} From cf0bb4c3571badbc5aaa8189cd5cbe3563ec73c1 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 3 Sep 2026 01:36:40 -0500 Subject: [PATCH 33/46] fix(web): match project icon chooser button sizes (#9368) --- apps/web/src/components/settings/ProjectSettingsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 3949d2c3488..3ec8b266afe 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -873,7 +873,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { Choose icon