From 8dd02470b1e7603b8d36a21ae27c510480351f18 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 19 Sep 2026 16:01:43 -0300 Subject: [PATCH 001/164] fix(web): restore providers settings heading (#12552) --- apps/web/src/components/settings/ProviderSettingsPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 03cba5140531..7f09e89c53df 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -195,7 +195,7 @@ function ProviderSettingsPlaceholder({ readonly children?: ReactNode; }) { return ( - + {deviceTabs ? (
{deviceTabs}
) : null} @@ -987,7 +987,7 @@ export function EnvironmentProviderSettings({ return ( <> - +
{deviceTabs}
From 7810fb263f5a92b9d438d1eabf408d2ac705e944 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 13:47:51 -0700 Subject: [PATCH 002/164] Add new GitHub user 'yordis' to VOUCHED.td (#12546) --- .github/VOUCHED.td | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 2f9faba635e3..08e5cfb6cc06 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -80,3 +80,5 @@ github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 +github:yordis + From f9e8f578fa0fb1ea25e458334a28bbb4fe22302d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 14:50:12 -0700 Subject: [PATCH 003/164] chore: vouch cestercian (#12638) Co-authored-by: Claude Fable 5 --- .github/VOUCHED.td | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 08e5cfb6cc06..6781356cf58d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -18,6 +18,7 @@ github:bil0000 github:binbandit github:Brechard github:btsouth +github:cestercian github:chrisdeeming github:chuks-qua github:cursoragent @@ -81,4 +82,3 @@ github:Yash-Singh1 github:yashranaway github:Ymit24 github:yordis - From dcf8942304cfef382af55c0068cb2f04c86757fc Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:38:53 +0530 Subject: [PATCH 004/164] fix(web): keep desktop annotation screenshots under CSP (#12636) --- .../components/preview/PreviewView.test.tsx | 12 +-- .../src/components/preview/PreviewView.tsx | 2 +- apps/web/src/lib/previewAnnotation.test.ts | 74 +++++++------------ apps/web/src/lib/previewAnnotation.ts | 45 ++++------- 4 files changed, 44 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index ea28a93235ee..e5b40c907b14 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => ({ openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), pickElement: vi.fn(), - capturePreviewAnnotationScreenshot: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), toggleAnnotation: null as (() => void) | null, @@ -90,10 +89,6 @@ vi.mock("~/composerDraftStore", () => ({ }), })); -vi.mock("~/lib/previewAnnotation", () => ({ - capturePreviewAnnotationScreenshot: mocks.capturePreviewAnnotationScreenshot, -})); - vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(), })); @@ -342,8 +337,6 @@ describe("PreviewView navigation", () => { mocks.openPictureInPicture.mockClear(); mocks.closePictureInPicture.mockClear(); mocks.pickElement.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" }); mocks.addPreviewAnnotation.mockClear(); vi.mocked(toastManager.add).mockClear(); mocks.addImage.mockClear(); @@ -585,7 +578,7 @@ describe("PreviewView navigation", () => { expect(toastManager.add).toHaveBeenCalledTimes(1); }); - it("still sends when the picked element's crop cannot be captured", async () => { + it("still sends annotation text when the picked element's crop is malformed", async () => { const annotation = { id: "annotation-2", pageUrl: "https://example.com/dashboard", @@ -596,7 +589,7 @@ describe("PreviewView navigation", () => { strokes: [], styleChanges: [], screenshot: { - dataUrl: "data:image/png;base64,c2NyZWVuc2hvdA==", + dataUrl: "data:image/png;base64,%%%", width: 10, height: 10, cropRect: { x: 0, y: 0, width: 10, height: 10 }, @@ -605,7 +598,6 @@ describe("PreviewView navigation", () => { }; const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" }); renderToStaticMarkup( { afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("returns the crop when the fetch resolves", async () => { - vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" }))); - const capture = await capturePreviewAnnotationScreenshot(annotation); + it("preserves the PNG bytes, MIME type, and filename without fetching", async () => { + const fetch = vi.fn().mockRejectedValue(new TypeError("Blocked by connect-src")); + vi.stubGlobal("fetch", fetch); + const capture = capturePreviewAnnotationScreenshot(annotation); expect(capture.status).toBe("captured"); + if (capture.status !== "captured") throw new Error("Expected a screenshot file"); + expect(capture.file.name).toBe("preview-annotation-annotation_1.png"); + expect(capture.file.type).toBe("image/png"); + expect(Buffer.from(await capture.file.arrayBuffer()).toString("hex")).toBe( + "89504e470d0a1a0a0000000d4948445200000001000000010804000000b51c0c020000000b4944415478da63fcff1f0003030200efa2a75b0000000049454e44ae426082", + ); + expect(fetch).not.toHaveBeenCalled(); }); - it("reports none when the annotation carries no crop", async () => { - const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); + it("reports none when the annotation carries no crop", () => { + const capture = capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); expect(capture).toEqual({ status: "none" }); }); - it("fails instead of hanging when the crop never arrives", async () => { - vi.useFakeTimers(); - vi.stubGlobal("fetch", () => new Promise(() => {})); - const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000); - await vi.advanceTimersByTimeAsync(1_000); - expect(await capturePromise).toEqual({ status: "failed" }); - }); - - it("fails when the crop fetch throws", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("data url unreadable"); - }); - expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" }); - }); + it.each(["data:image/jpeg;base64,AA==", "data:image/png;base64,", "data:image/png;base64,%%%"])( + "reports a malformed screenshot as failed: %s", + (dataUrl) => { + const picked = { ...annotation, screenshot: { ...annotation.screenshot!, dataUrl } }; + expect(capturePreviewAnnotationScreenshot(picked)).toEqual({ status: "failed" }); + }, + ); }); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 1b4a449e5098..e3dda16f121f 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -1,49 +1,30 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; -async function previewAnnotationScreenshotFile( - annotation: PreviewAnnotationPayload, -): Promise { - if (!annotation.screenshot) return null; - const response = await fetch(annotation.screenshot.dataUrl); - const blob = await response.blob(); - return new File([blob], `preview-annotation-${annotation.id}.png`, { - type: blob.type || "image/png", - }); -} - -/** Upper bound on turning a picked element's crop into a composer attachment. */ -const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +import { dataUrlToFile } from "./imageCompression"; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ | { readonly status: "captured"; readonly file: File } /** The pick carried no crop, which is normal for comment-only annotations. */ | { readonly status: "none" } - /** The crop stalled or threw. Send the annotation without it. */ + /** The crop could not be decoded. Send the annotation without it. */ | { readonly status: "failed" }; -/** - * Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the - * composer while this runs, so it must always settle: a stalled crop resolves as - * `failed` instead of leaving the caller waiting. - */ -export async function capturePreviewAnnotationScreenshot( +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; + +/** Decode Electron's PNG crop locally; fetching a data URL violates desktop connect-src. */ +export function capturePreviewAnnotationScreenshot( annotation: PreviewAnnotationPayload, - timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS, -): Promise { +): PreviewAnnotationCapture { if (!annotation.screenshot) return { status: "none" }; - let timer: ReturnType | undefined; try { - const file = await Promise.race([ - previewAnnotationScreenshotFile(annotation), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs); - }), - ]); - return file ? { status: "captured", file } : { status: "failed" }; + const { dataUrl } = annotation.screenshot; + if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + return { status: "failed" }; + } + const file = dataUrlToFile(dataUrl, `preview-annotation-${annotation.id}.png`, "image/png"); + return file.size > 0 ? { status: "captured", file } : { status: "failed" }; } catch { return { status: "failed" }; - } finally { - clearTimeout(timer); } } From e36725682bd2935f1bcfdf81660cb569b5f3df29 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:39:00 +0530 Subject: [PATCH 005/164] fix(web): keep typed text when a question option is clicked (#12577) --- apps/web/src/components/ChatView.tsx | 14 +++++++++ apps/web/src/components/chat/ChatComposer.tsx | 22 +++++++++----- apps/web/src/composer-logic.test.ts | 30 +++++++++++++++++++ apps/web/src/composer-logic.ts | 12 ++++++++ apps/web/src/pendingUserInput.test.ts | 20 +++++++++++++ apps/web/src/pendingUserInput.ts | 22 ++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79d393fc8d1f..667eb0e11f6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,6 +148,7 @@ import { } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId @@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + composerDraftTarget, composerRef, + pendingUserInputAnswersByRequestId, + setComposerDraftPrompt, ], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8f91f08c7093..255f61a0c752 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -72,6 +72,7 @@ import { type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + // The question is gone and the editor shows the thread draft again. The + // ref still holds the last answer text, and Send reads the ref. Place + // the caret at the end so the next keystroke appends. + if (lastSyncedPendingInputRef.current !== null) { + promptRef.current = prompt; + const { cursor, trigger } = composerStateAtPromptEnd(prompt); + setComposerCursor(cursor); + setComposerTrigger(trigger); + } lastSyncedPendingInputRef.current = null; return; } @@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); + const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); + setComposerCursor(cursor); + setComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index d4509516cba7..83f143a546fd 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -18,6 +19,7 @@ import { parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; +import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; import { formatTerminalContextReference } from "./lib/terminalContext"; const terminalReference = formatTerminalContextReference({ @@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => { }); }); +describe("composerStateAtPromptEnd", () => { + it("puts the caret at the end of a restored parked draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half"); + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: null, + }); + }); + + it("collapses mention chips so the next keystroke lands after the draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please"); + + expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length); + }); + + it("keeps a trailing mention trigger when the restored draft ends with @", () => { + const prompt = "look at @"; + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length }, + }); + }); +}); + describe("collapseExpandedComposerCursor", () => { it("keeps cursor unchanged when no mention segment is present", () => { expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index a2ed12b86b9e..ff638a6d2e46 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -265,6 +265,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +/** Caret and trigger after replacing composer text and continuing at the end. */ +export function composerStateAtPromptEnd(text: string): { + cursor: number; + trigger: ComposerTrigger | null; +} { + const cursor = collapseExpandedComposerCursor(text, text.length); + return { + cursor, + trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + }; +} + export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | null { diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index f2f093a5c826..350087c3aaff 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, resolvePendingUserInputAnswer, @@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => { }), ).toBeNull(); }); + +describe("carryDisplacedCustomAnswerIntoPrompt", () => { + it("keeps the thread draft when nothing was typed into the answer", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft"); + expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft"); + }); + + it("moves the typed answer into an empty thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe( + "also rename the flag", + ); + }); + + it("appends the typed answer after an existing thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe( + "first half\n\nsecond half", + ); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 5ac004649a27..5d22e56105aa 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer( }; } +const DISPLACED_ANSWER_SEPARATOR = "\n\n"; + +/** + * Selecting an option replaces the custom answer, because a non-empty custom + * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the + * user typed into the answer field must not vanish on that click: it moves back + * into the thread draft, after whatever was already waiting there. + */ +export function carryDisplacedCustomAnswerIntoPrompt( + prompt: string, + customAnswer: string | undefined, +): string { + const displaced = customAnswer?.trim() ?? ""; + if (displaced.length === 0) { + return prompt; + } + if (prompt.trim().length === 0) { + return displaced; + } + return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; +} + export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, From 63ff33756c47f127391962458d8388a647e8ed44 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:39:05 +0530 Subject: [PATCH 006/164] fix(server): empty Claude homePath shares continuation with ~/.claude (#12624) --- .../src/provider/Drivers/ClaudeDriver.ts | 11 ++++- .../src/provider/Drivers/ClaudeHome.test.ts | 40 +++++++++++++------ .../server/src/provider/Drivers/ClaudeHome.ts | 28 +++++++++++-- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..b87a89dcf528 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -135,7 +135,10 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), ); - const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); + const continuationGroupKey = yield* makeClaudeContinuationGroupKey( + effectiveConfig, + processEnv, + ); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, @@ -171,7 +174,11 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( + effectiveConfig, + cwd, + processEnv, + ); // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 33b11237547e..fb1caf753f86 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -15,13 +15,20 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { - it.effect("uses the process home when no Claude home override is configured", () => + it.effect("treats empty, ~/.claude, and the expanded default as the same Claude home", () => Effect.gen(function* () { const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); + const resolved = path.resolve(path.join(NodeOS.homedir(), ".claude")); expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* resolveClaudeHomePath({ homePath: "~/.claude" })).toBe(resolved); + expect(yield* resolveClaudeHomePath({ homePath: resolved })).toBe(resolved); expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); + + const key = `claude:home:${resolved}`; + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(key); + expect(yield* makeClaudeContinuationGroupKey({ homePath: "~/.claude" })).toBe(key); + expect(yield* makeClaudeContinuationGroupKey({ homePath: resolved })).toBe(key); }), ); @@ -40,6 +47,24 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); + it.effect("uses inherited CLAUDE_CONFIG_DIR when homePath is empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const inherited = path.resolve("/tmp/claude-inherited"); + const environment = { CLAUDE_CONFIG_DIR: inherited }; + + expect(yield* resolveClaudeHomePath({ homePath: "" }, environment)).toBe(inherited); + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" }, environment)).toBe( + `claude:home:${inherited}`, + ); + + const explicit = path.resolve(NodeOS.homedir(), ".claude-work"); + expect(yield* resolveClaudeHomePath({ homePath: "~/.claude-work" }, environment)).toBe( + explicit, + ); + }), + ); + it("points the signed-out hint at the configured Claude home", () => { expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain( "run `claude auth login`", @@ -59,16 +84,5 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(first).not.toBe(second); }), ); - - it.effect("keeps continuation compatible across instances with the same Claude HOME", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); - - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( - `claude:home:${resolved}`, - ); - }), - ); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bbd005a1e00b..70699ca669e8 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -9,12 +9,28 @@ import { expandHomePath } from "../../pathExpansion.ts"; const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +/** + * Resolve the Claude config directory the CLI would use: the instance's + * `homePath` (exported as `CLAUDE_CONFIG_DIR`), then an inherited + * `CLAUDE_CONFIG_DIR`, then Claude's default `~/.claude`. Empty must not + * fall back to bare `$HOME` — that leftover from the old HOME override + * produced a different continuation group than an explicit `~/.claude`. + */ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( config: Pick, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const path = yield* Path.Path; const homePath = config.homePath.trim(); - return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); + if (homePath.length > 0) { + return path.resolve(expandHomePath(homePath)); + } + // Inherited env vars are not shell-expanded, so a literal `~` stays literal. + const inherited = environment?.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (inherited.length > 0) { + return path.resolve(inherited); + } + return path.resolve(path.join(NodeOS.homedir(), ".claude")); }); export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( @@ -38,8 +54,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }); export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( - function* (config: Pick): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); + function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); return `claude:home:${resolvedHomePath}`; }, ); @@ -48,8 +67,9 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC function* ( config: Pick, cwd?: string, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); + const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; }, ); From 52d08a14b9e475371658fabbb12c056c3d3dad67 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:39:10 +0530 Subject: [PATCH 007/164] fix(desktop): include SnapShot app text for Flatpak and GTK4 (#12635) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../src/snapShot/DesktopSnapShot.test.ts | 138 ++++++++++++++++++ .../src/snapShot/SnapShotAccessibility.ts | 32 +++- apps/desktop/src/snapShot/snapShot.test.ts | 39 +++++ apps/desktop/src/snapShot/snapShot.ts | 26 +++- 4 files changed, 224 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index c724bb396cd8..205a72a55265 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -29,6 +29,7 @@ beforeEach(() => { transitionCapturePageMock.mockReset().mockResolvedValue(undefined); transitionSnapshotMock.mockReset().mockResolvedValue(undefined); prepareCaptureRevealMock.mockReset(); + accessibilityListMock.mockReset().mockResolvedValue([]); }); const { @@ -39,6 +40,7 @@ const { accessibilityProcessCoolMock, accessibilityProcessReadMock, accessibilityByPidMock, + accessibilityListMock, accessibilityForegroundMock, accessibilityTrustedMock, allWindowsMock, @@ -87,6 +89,7 @@ const { } >(), accessibilityByPidMock: vi.fn(), + accessibilityListMock: vi.fn(), accessibilityForegroundMock: vi.fn(), accessibilityTrustedMock: vi.fn((_prompt = false) => true), allWindowsMock: vi.fn( @@ -430,6 +433,7 @@ import * as SnapShotAccessibility from "./SnapShotAccessibility.ts"; // Tests hand it this stand-in so the mocks above drive window lookups. const accessibilityApp = { byPid: accessibilityByPidMock, + list: accessibilityListMock, foreground: accessibilityForegroundMock, } as unknown as Parameters[0]; const readAccessibleWindowContext = ( @@ -2654,6 +2658,140 @@ it.each([ } }); +it("reads Flatpak app text from the AT-SPI proxy when the compositor PID misses", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ + name: "Issue — Zen Browser", + children: [{ name: "New Tab", children: [] }], + })); + const proxyWindow = { + role: "window", + name: "Issue — Zen Browser", + bounds, + tree, + children: async () => [], + }; + accessibilityByPidMock + .mockReset() + .mockRejectedValue( + new Error("XA11Y_SELECTOR_NOT_MATCHED: No element matched selector: application[pid=207651]"), + ); + accessibilityListMock.mockReset().mockResolvedValue([ + { + pid: 1, + children: async () => [ + { + role: "window", + name: "Files", + bounds, + tree: async () => ({ name: "Files", children: [] }), + }, + ], + }, + { pid: 207646, children: async () => [proxyWindow] }, + ]); + try { + assert.strictEqual( + await readAccessibleWindowText( + { + title: "Issue — Zen Browser", + bounds: { x: 0, y: 0, width: 1_920, height: 1_048 }, + owner: { processId: 207651 }, + }, + "linux", + "Issue — Zen Browser", + ), + "Issue — Zen Browser\nNew Tab", + ); + assert.deepEqual(accessibilityByPidMock.mock.calls, [[207651, { timeout: 0 }]]); + assert.lengthOf(accessibilityListMock.mock.calls, 1); + assert.lengthOf(tree.mock.calls, 1); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + +it("reads GTK4 app text from one unnamed PID-scoped window of the captured size", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ + name: "System Monitor", + children: [{ name: "CPU", children: [] }], + })); + accessibilityByPidMock.mockReset().mockResolvedValue({ + children: async () => [ + { + role: "group", + name: null, + bounds, + tree, + children: async () => [ + { role: "page_tab_list", name: "Processes", bounds, children: async () => [] }, + ], + }, + ], + }); + accessibilityListMock.mockReset().mockResolvedValue([]); + try { + assert.strictEqual( + await readAccessibleWindowText( + { + title: "System Monitor", + bounds: { x: 12, y: 48, width: 1_920, height: 1_048 }, + owner: { processId: 210600 }, + }, + "linux", + "System Monitor", + ), + "System Monitor\nCPU", + ); + assert.deepEqual(accessibilityByPidMock.mock.calls, [[210600, { timeout: 0 }]]); + assert.lengthOf(accessibilityListMock.mock.calls, 0); + assert.lengthOf(tree.mock.calls, 1); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + +it("does not guess GTK4 app text when two unnamed PID windows share the captured size", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ value: "Wrong window", children: [] })); + accessibilityByPidMock.mockReset().mockResolvedValue({ + children: async () => [ + { role: "group", name: null, bounds, tree }, + { role: "group", name: null, bounds: { ...bounds, x: 12 }, tree }, + ], + }); + accessibilityListMock.mockReset().mockResolvedValue([ + { + pid: 99, + children: async () => [{ role: "window", name: "System Monitor", bounds, tree }], + }, + ]); + try { + assert.isUndefined( + await readAccessibleWindowText( + { + title: "System Monitor", + bounds: { x: 12, y: 48, width: 1_920, height: 1_048 }, + owner: { processId: 210600 }, + }, + "linux", + "System Monitor", + ), + ); + assert.lengthOf(accessibilityListMock.mock.calls, 0); + assert.lengthOf(tree.mock.calls, 0); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + it.each([20, 1_350, 2_999])( "includes accessibility text as soon as a %d ms read completes", async (duration) => { diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index bb6cb95952b3..b0a0fe7b959c 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -97,6 +97,23 @@ function accessibilityReadSnapshot( ) as CapturedWindowAccessibilityContext; } +type AccessibilityElement = Awaited["children"]>>[number]; + +async function windowsForPid( + App: AccessibilityApp, + processId: number, +): Promise { + return await App.byPid(processId, { timeout: 0 }) + .then((app) => app.children()) + .catch(() => []); +} + +/** Flatpak apps reach AT-SPI through xdg-dbus-proxy, so the compositor PID misses. */ +async function windowsFromAppList(App: AccessibilityApp): Promise { + const apps = await App.list().catch(() => []); + return (await Promise.all(apps.map((app) => app.children().catch(() => [])))).flat(); +} + async function readCapturedWindowAccessibility( App: AccessibilityApp, request: SnapShotAccessibilityRequest, @@ -105,17 +122,24 @@ async function readCapturedWindowAccessibility( ): Promise { const { active, platform, sourceTitle, imageSize } = request; const foreground = platform === "win32" ? await App.foreground({ timeout: 0 }) : undefined; - const windows = + const pidWindows = foreground !== undefined ? foreground.pid === active.owner.processId ? [foreground.asElement()] : [] - : await (await App.byPid(active.owner.processId, { timeout: 0 })).children(); + : await windowsForPid(App, active.owner.processId); const matchMode = isWaylandSession(platform, process.env) ? "wayland" : "screen-bounds"; + const captured = { + title: active.title, + sourceTitle, + bounds: active.bounds, + clientBounds: active.clientBounds, + }; const window = findAccessibleWindow( - windows, - { title: active.title, sourceTitle, bounds: active.bounds, clientBounds: active.clientBounds }, + pidWindows.length > 0 || foreground !== undefined ? pidWindows : await windowsFromAppList(App), + captured, matchMode, + { allowUntitledUniqueBounds: foreground === undefined && pidWindows.length > 0 }, ); if (!window) { onStarted(); diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 6960718759aa..634c4653a5a6 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -464,6 +464,45 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, { ...captured, title: "" }, "wayland")).toBeUndefined(); }); + it("accepts one PID-scoped untitled window whose bounds match", () => { + const windows = [{ name: null, bounds: captured.bounds }]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBe(windows[0]); + expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); + }); + + it("rejects ambiguous PID-scoped untitled windows even when bounds match", () => { + const windows = [ + { name: null, bounds: captured.bounds }, + { name: "", bounds: { ...captured.bounds, x: 0, y: 0 } }, + ]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + + it("does not fall back to a differently titled PID-scoped window with matching bounds", () => { + const windows = [{ name: "Preferences", bounds: captured.bounds }]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + + it("does not use unique bounds when a titled match is already ambiguous", () => { + const windows = [ + { name: "Editor", bounds: captured.bounds }, + { name: "Editor", bounds: { ...captured.bounds, x: 0, y: 0 } }, + ]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + it.each(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])( "ignores a leading Wayland title spinner frame %s", (frame) => { diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 35c240c6e09a..2cfa406d1781 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -480,6 +480,7 @@ export function findAccessibleWindow< readonly clientBounds?: WindowBounds | undefined; }, matchMode: "screen-bounds" | "wayland" = "screen-bounds", + options: { readonly allowUntitledUniqueBounds?: boolean } = {}, ): T | undefined { const normalizeTitle = (value: string) => { const title = value.trim(); @@ -489,7 +490,6 @@ export function findAccessibleWindow< const titles = new Set( [captured.title, captured.sourceTitle ?? ""].map(normalizeTitle).filter(Boolean), ); - if (titles.size === 0) return undefined; // Wayland accessibility providers can expose window size without a screen position. const boundsKeys = matchMode === "wayland" @@ -499,19 +499,31 @@ export function findAccessibleWindow< matchMode === "wayland" && captured.clientBounds ? [captured.bounds, captured.clientBounds] : [captured.bounds]; - const matches = windows.filter((window) => { + const matchesBounds = (window: T) => { const bounds = window.bounds; return ( - titles.has(normalizeTitle(window.name ?? "")) && bounds !== null && candidateBounds.some((candidate) => boundsKeys.every((key) => Math.abs(bounds[key] - candidate[key]) <= 2), ) ); - }); - if (matches.length === 1) return matches[0]; - const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); - return activeMatches.length === 1 ? activeMatches[0] : undefined; + }; + if (titles.size > 0) { + const matches = windows.filter( + (window) => titles.has(normalizeTitle(window.name ?? "")) && matchesBounds(window), + ); + if (matches.length === 1) return matches[0]; + const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); + if (activeMatches.length === 1) return activeMatches[0]; + if (matches.length > 1) return undefined; + } + // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped + // lookup can accept the one window whose bounds match; size-only guesses cannot. + if (!options.allowUntitledUniqueBounds) return undefined; + const boundsMatches = windows.filter( + (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), + ); + return boundsMatches.length === 1 ? boundsMatches[0] : undefined; } const ELECTRON_KEY_NAMES: Readonly> = { From d6f291303ddc0c9a14f570266a4d9eff6d431593 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:42:03 +0530 Subject: [PATCH 008/164] fix(server): surface ACP stderr when cursor-agent exits at session start (#12625) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../Layers/ProviderCommandReactor.ts | 5 ++ .../src/provider/Layers/CursorAdapter.test.ts | 43 ++++++++++++++ .../provider/acp/AcpAdapterSupport.test.ts | 36 ++++++++++++ .../src/provider/acp/AcpAdapterSupport.ts | 5 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 20 +++++++ .../src/provider/acp/AcpSessionRuntime.ts | 42 ++++++++++++-- .../server/src/provider/acp/AcpStderr.test.ts | 56 +++++++++++++++++++ apps/server/src/provider/acp/AcpStderr.ts | 38 +++++++++++++ packages/effect-acp/src/errors.ts | 8 ++- 9 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpStderr.test.ts create mode 100644 apps/server/src/provider/acp/AcpStderr.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 80cf2a1f4761..6a42b7c67ae1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -36,6 +36,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { + ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterValidationError, ProviderWorkspaceMissingError, @@ -64,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); @@ -373,6 +375,9 @@ const make = Effect.gen(function* () { if (isProviderAdapterRequestError(failReason?.error)) { return failReason.error.detail; } + if (isProviderAdapterProcessError(failReason?.error)) { + return failReason.error.detail; + } if (isProviderAdapterValidationError(failReason?.error)) { return failReason.error.issue; } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 4c3ad4b04551..6b6aeff17f35 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -543,6 +543,49 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("surfaces cursor-agent cli.json schema stderr instead of a closed-session error", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-cli-json-")), + ); + const wrapperPath = writeFakeCli({ + directory: workspace, + name: "fake-cursor-agent", + source: [ + "process.stderr.write(`Invalid project config at ${process.cwd()}/.cursor/cli.json: schema validation failed. [`", + " + JSON.stringify({", + ' code: "unrecognized_keys",', + ' keys: ["approvalMode", "sandbox"],', + " path: [],", + " message: \"Unrecognized key(s) in object: 'approvalMode', 'sandbox'\",", + ' }) + "]\\n");', + "process.exit(1);", + ].join("\n"), + }); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const error = yield* adapter + .startSession({ + threadId: ThreadId.make("cursor-cli-json-schema"), + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.include(error.message, "cli.json"); + assert.include(error.message, "Unrecognized key"); + assert.notInclude(error.message, "adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "approvalMode"); + assert.include(error.detail, "sandbox"); + } + }), + ); + it.effect("maps app plan mode onto the ACP plan session mode", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index 0aebe0ca6d82..60f971edb499 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -25,4 +25,40 @@ describe("AcpAdapterSupport", () => { expect(error._tag).toBe("ProviderAdapterRequestError"); expect(error.message).toContain("Invalid params"); }); + + it("maps ACP process exits without stderr to a process error instead of a closed session", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/start", + new EffectAcpErrors.AcpProcessExitedError({ code: 1 }), + ); + + expect(error._tag).toBe("ProviderAdapterProcessError"); + expect(error.message).not.toContain("adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + expect(error.detail).toBe("ACP process exited with code 1"); + } + }); + + it("maps ACP process exits to a process error whose detail includes stderr", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/start", + new EffectAcpErrors.AcpProcessExitedError({ + code: 1, + stderr: + "Invalid project config at ~/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'", + }), + ); + + expect(error._tag).toBe("ProviderAdapterProcessError"); + expect(error.message).toContain("cli.json"); + expect(error.message).toContain("Unrecognized key"); + expect(error.message).not.toContain("adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + expect(error.detail).toContain("Unrecognized key(s): 'approvalMode', 'sandbox'"); + } + }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd99..b90ce88e83dc 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -7,8 +7,8 @@ import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; import { + ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, type ProviderAdapterError, } from "../Errors.ts"; const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); @@ -21,9 +21,10 @@ export function mapAcpToAdapterError( error: EffectAcpErrors.AcpError, ): ProviderAdapterError { if (isAcpProcessExitedError(error)) { - return new ProviderAdapterSessionClosedError({ + return new ProviderAdapterProcessError({ provider, threadId, + detail: error.message, cause: error, }); } diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 57323e2675e0..45a5cadb9a31 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -368,6 +368,26 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("attaches child stderr when the ACP process exits before initialize", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + command: process.execPath, + args: [ + "-e", + "process.stderr.write(\"Invalid project config at /tmp/project/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'\\n\"); process.exit(1);", + ], + }, + }); + const error = yield* runtime.start().pipe(Effect.flip); + expect(error._tag).toBe("AcpProcessExitedError"); + expect(error.message).toContain("cli.json"); + expect(error.message).toContain("Unrecognized key"); + expect(error.message).not.toContain("ACP process exited with code 1\nACP process exited"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("drains large stderr output and keeps auth-sized logging chunks", () => Effect.gen(function* () { const lengths: Array = []; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 70106bd16783..77517c44ea9b 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -22,6 +22,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; import { collectSessionConfigOptionValues, decideToolCallUpdateEmission, @@ -354,6 +355,8 @@ export const make = ( ); const stoppingRef = yield* Ref.make(false); const stderrFailure = yield* Deferred.make(); + const stderrTailRef = yield* Ref.make(""); + const stderrDrained = yield* Deferred.make(); const runtimeClosed = yield* Deferred.make(); const promptSerializationSemaphore = yield* Semaphore.make(1); const promptDispatchSemaphore = yield* Semaphore.make(1); @@ -374,22 +377,45 @@ export const make = ( } }); + const enrichProcessExitWithStderr = ( + error: EffectAcpErrors.AcpError, + ): Effect.Effect => + error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 + ? Effect.succeed(error) + : Deferred.await(stderrDrained).pipe( + Effect.timeout("250 millis"), + Effect.ignore, + Effect.andThen(Ref.get(stderrTailRef)), + Effect.map((tail) => { + const stderr = sanitizeAcpStderrExcerpt(tail); + return stderr.length === 0 + ? error + : new EffectAcpErrors.AcpProcessExitedError({ + ...(error.code !== undefined ? { code: error.code } : {}), + ...(error.pid !== undefined ? { pid: error.pid } : {}), + stderr, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }); + }), + ); + const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( error: EffectAcpErrors.AcpError, ) { if (yield* Ref.get(stoppingRef)) { return; } + const enriched = yield* enrichProcessExitWithStderr(error); const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => Option.isSome(current) ? ([false, current] as const) - : ([true, Option.some(error)] as const), + : ([true, Option.some(enriched)] as const), ); if (!firstTermination) { return; } yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error }); + yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); }); const logRequest = (event: AcpSessionRequestLogEvent) => @@ -406,6 +432,9 @@ export const make = ( ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) : effect ).pipe( + Effect.catch((error) => + enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), + ), Effect.tap((result) => logRequest({ method, @@ -453,10 +482,10 @@ export const make = ( yield* child.stderr.pipe( Stream.decodeText(), Stream.runForEach((chunk) => - (options.onStderr - ? options.onStderr(chunk.slice(-maxStderrChunkLength)) - : Effect.void - ).pipe( + Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( + Effect.andThen( + options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, + ), Effect.catch((error) => Effect.gen(function* () { yield* Deferred.fail(stderrFailure, error); @@ -466,6 +495,7 @@ export const make = ( ), ), ), + Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), Effect.ignore, Effect.forkIn(runtimeScope), ); diff --git a/apps/server/src/provider/acp/AcpStderr.test.ts b/apps/server/src/provider/acp/AcpStderr.test.ts new file mode 100644 index 000000000000..e88b59bdb0e1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpStderr.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ACP_STDERR_TAIL_MAX_CHARS, + appendAcpStderrTail, + sanitizeAcpStderrExcerpt, +} from "./AcpStderr.ts"; + +describe("AcpStderr", () => { + it("keeps a bounded tail of stderr chunks", () => { + const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); + expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); + }); + + it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "Invalid project config at /Users/ada/.cursor/cli.json", + "Authorization: Bearer secret-token-value", + "Visit http://localhost:5733/pair#token=ABCDEF for pairing", + "key=sk-abcdefghijklmnopqrstuv", + ].join("\n"), + { HOME: "/Users/ada" }, + ); + + expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); + expect(excerpt).toContain("Bearer [redacted]"); + expect(excerpt).toContain("[pairing-url]"); + expect(excerpt).toContain("[redacted]"); + expect(excerpt).not.toContain("secret-token-value"); + expect(excerpt).not.toContain("ABCDEF"); + expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); + }); + + it("redacts hyphenated OpenAI project keys and header credentials", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", + "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", + "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", + "Authorization: Basic dXNlcjpwYXNz", + "x-api-key: ant-api-key-value", + ].join("\n"), + ); + + expect(excerpt).toContain("[redacted]"); + expect(excerpt).toContain("Authorization: Basic [redacted]"); + expect(excerpt).toContain("x-api-key: [redacted]"); + expect(excerpt).not.toContain("sk-proj-"); + expect(excerpt).not.toContain("sk-svcacct-"); + expect(excerpt).not.toContain("sk-ant-api03-"); + expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); + expect(excerpt).not.toContain("dXNlcjpwYXNz"); + expect(excerpt).not.toContain("ant-api-key-value"); + }); +}); diff --git a/apps/server/src/provider/acp/AcpStderr.ts b/apps/server/src/provider/acp/AcpStderr.ts new file mode 100644 index 000000000000..52f68bb4c19a --- /dev/null +++ b/apps/server/src/provider/acp/AcpStderr.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. +import * as NodeOS from "node:os"; + +/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ +export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; + +const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; +const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; +const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; +const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; +const SECRET_TOKEN_PATTERN = + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + +export function appendAcpStderrTail(current: string, chunk: string): string { + const next = `${current}${chunk}`; + return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); +} + +/** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ +export function sanitizeAcpStderrExcerpt( + text: string, + environment: NodeJS.ProcessEnv = process.env, +): string { + let result = text.replaceAll("\0", ""); + const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( + (value): value is string => typeof value === "string" && value.length > 1, + ); + for (const home of new Set(homes)) { + result = result.split(home).join("~"); + } + result = result + .replace(PAIRING_URL_PATTERN, "[pairing-url]") + .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") + .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") + .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") + .replace(SECRET_TOKEN_PATTERN, "[redacted]"); + return result.trim(); +} diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index a2e343d67664..01f296bb8cb6 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -95,13 +95,15 @@ export class AcpProcessExitedError extends Schema.TaggedError 0 ? `${base}\n${excerpt}` : base; } } From 599c9776eb887d4fff42da7d2daf8aa07ee9a9e2 Mon Sep 17 00:00:00 2001 From: Akash Moradiya <64416825+akash3444@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:08:43 +0530 Subject: [PATCH 009/164] fix(web): align pull request state glyph to top of row (#11268) --- apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx | 2 +- apps/web/src/components/pullRequest/PullRequestRow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4e58fdbe68fd..fde83ce2ee7b 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1535,7 +1535,7 @@ export function PullRequestDetailPanel({ !detail && !onClose && "hidden", )} > -
+
+ {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the badge's size and anchors the popup to it. */} From 7445aa733ada33e45289e5aa5055f79142556513 Mon Sep 17 00:00:00 2001 From: Akash Moradiya <64416825+akash3444@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:08:59 +0530 Subject: [PATCH 010/164] fix(web): align menu item icons in pull request detail panel (#11263) --- .../src/components/pullRequest/PullRequestDetailPanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index fde83ce2ee7b..31d81315c2e1 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1757,7 +1757,7 @@ export function PullRequestDetailPanel({ startCheckout("worktree")}> - + In a separate worktree @@ -1766,7 +1766,7 @@ export function PullRequestDetailPanel({ startCheckout("local")}> - + In this repository @@ -1982,7 +1982,7 @@ export function PullRequestDetailPanel({ Refresh - + {handoff === "ask" ? "Opening..." : "Ask a question"} @@ -1993,7 +1993,7 @@ export function PullRequestDetailPanel({ - + {handoff === "explain" ? "Opening..." : "Explain this PR"} From 4a560b4e4ebb37efb7f57805ba79e37f5500bdca Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:49:04 +0300 Subject: [PATCH 011/164] fix(web): honor whitespace settings in pull request diffs (#12438) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../pullRequest/PullRequestCodeTab.tsx | 46 ++++-- apps/web/src/lib/diffRendering.test.ts | 131 ++++++++++++++++++ apps/web/src/lib/diffRendering.ts | 68 ++++++++- 3 files changed, 230 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b535c4f45e97..08a46872b697 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -20,6 +20,7 @@ import { InfoIcon, MessageSquareIcon, MessageSquareOffIcon, + PilcrowIcon, Rows3Icon, TextWrapIcon, TriangleAlertIcon, @@ -233,6 +234,7 @@ function PullRequestCodeTab({ const diffLayout = settings.diffLayout; const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [ignoreWhitespace, setIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( PULL_REQUEST_FILE_TREE_STORAGE_KEY, false, @@ -390,16 +392,17 @@ function PullRequestCodeTab({ loadedSlices.map((slice) => { // The patch's own hash is part of the key: a refreshed page reuses its cursor, and a // key of position alone would keep handing back the parse of the patch it replaced. - const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; + const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; const cached = parseCache.current.get(cacheKey); if (cached) return cached; const parsed = getRenderablePatch(slice.patch, cacheKey, { compactPartialHunkOffsets: true, + ignoreWhitespace, }); if (parsed) parseCache.current.set(cacheKey, parsed); return parsed; }), - [loadedSlices, resolvedTheme, scopeKey], + [loadedSlices, resolvedTheme, scopeKey, ignoreWhitespace], ); // Ordered within a slice rather than across them: ordering the accumulated set would let a late // slice push a file the reader is part way through further down the page. @@ -702,7 +705,13 @@ function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); - const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + const sourceFile = parsedSlices + .flatMap((slice) => (slice?.kind === "files" ? slice.sourceFiles : [])) + .find((candidate) => resolveFileDiffPath(candidate) === path); + const side = range.endSide ?? range.side; + const position = + (sourceFile && resolveDiffReviewPosition(sourceFile, range.end, side)) ?? + resolveDiffReviewPosition(file, range.end, side); if (position === null) return; setDraft({ fileKey: item.id, @@ -712,7 +721,7 @@ function PullRequestCodeTab({ range, }); }, - [canCommentOnLines, files], + [canCommentOnLines, files, parsedSlices], ); // Built here because the parsed diff only lives here, and built by the same function the @@ -1123,11 +1132,6 @@ function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; - /** - * The same controls the thread diff panel carries, in the same order, minus the - * ignore-whitespace toggle: that is `git diff -w` on the server, and no host's pull request - * diff API offers it. - */ const toolbar = (
@@ -1276,6 +1280,30 @@ function PullRequestCodeTab({
+ + { + setIgnoreWhitespace(Boolean(pressed)); + setDraft(null); + setSelectedLines(null); + }} + /> + } + > + + + + {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + + {fileKeys.length > 0 ? ( { }); describe("getRenderablePatch", () => { + it("hides indentation changes around inserted JSX without moving review lines", () => { + const patch = [ + "diff --git a/item.tsx b/item.tsx", + "--- a/item.tsx", + "+++ b/item.tsx", + "@@ -40,5 +40,7 @@", + ' ', + "- ", + '-

{name}

', + "-
", + "+ {showName && (", + "+ ", + '+

{name}

', + "+
", + "+ )}", + "
", + "@@ -80 +82 @@", + "-const value = 1;", + "+const value = 2;", + ].join("\n"); + const shown = getRenderablePatch(patch, "pr", { compactPartialHunkOffsets: true }); + const hidden = getRenderablePatch(patch, "pr", { + compactPartialHunkOffsets: true, + ignoreWhitespace: true, + }); + expect(shown?.kind).toBe("files"); + expect(hidden?.kind).toBe("files"); + if (shown?.kind !== "files" || hidden?.kind !== "files") return; + expect(getDiffLineStat(shown.files)).toEqual({ additions: 6, deletions: 4 }); + expect(getDiffLineStat(hidden.files)).toEqual({ additions: 3, deletions: 1 }); + const file = hidden.files[0]!; + expect(file.additionLines).toEqual(shown.files[0]!.additionLines); + expect(file.deletionLines).toEqual(shown.files[0]!.deletionLines); + expect(file.cacheKey).not.toBe(shown.files[0]!.cacheKey); + expect(resolveDiffReviewPosition(hidden.sourceFiles[0]!, 43, "additions")).toEqual({ + kind: "added", + newLine: 43, + }); + expect(resolveDiffReviewPosition(hidden.sourceFiles[0]!, 42, "deletions")).toEqual({ + kind: "deleted", + oldLine: 42, + }); + expect(file.hunks[0]?.hunkContent).toContainEqual({ + type: "context", + lines: 3, + additionLineIndex: 2, + deletionLineIndex: 1, + }); + expect(resolveDiffReviewPosition(file, 41, "additions")).toEqual({ + kind: "added", + newLine: 41, + }); + expect(resolveDiffReviewPosition(file, 43, "additions")).toEqual({ + kind: "context", + oldLine: 42, + newLine: 43, + side: "right", + }); + expect(resolveDiffReviewPosition(file, 42, "deletions")).toEqual({ + kind: "context", + oldLine: 42, + newLine: 43, + side: "left", + }); + expect(file.hunks[1]).toMatchObject({ + additionStart: 82, + deletionStart: 80, + splitLineStart: 7, + unifiedLineStart: 7, + }); + const prefix = "unchanged\n".repeat(39); + const gap = "unchanged\n".repeat(35); + const hydrated = hydratePartialDiff("clone", file, { + oldFile: { + name: file.name, + contents: prefix + file.deletionLines.slice(0, 5).join("") + gap + "const value = 1;\n", + }, + newFile: { + name: file.name, + contents: prefix + file.additionLines.slice(0, 7).join("") + gap + "const value = 2;\n", + }, + }); + expect(getDiffLineStat([hydrated])).toEqual({ additions: 3, deletions: 1 }); + expect(resolveDiffReviewPosition(hydrated, 43, "additions")).toEqual( + resolveDiffReviewPosition(file, 43, "additions"), + ); + }); + + it.each([ + [" const x = 1;\t", "\tconst x=1;", 0, 0], + ["const x = 1;", "const x = 2;", 1, 1], + ["const x = 1;", "const x = 1;\n", 1, 0], + ])("filters whitespace in %j to %j", (before, after, additions, deletions) => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + `@@ -1 +1,${after.split("\n").length} @@`, + `-${before}`, + ...after.split("\n").map((line) => `+${line}`), + "", + ].join("\n"); + const filtered = getRenderablePatch(patch, "pr", { ignoreWhitespace: true }); + expect(filtered?.kind).toBe("files"); + if (filtered?.kind !== "files") return; + expect(getDiffLineStat(filtered.files)).toEqual({ additions, deletions }); + }); + + it.each(["+", "-"])("keeps %s blank lines without a final newline", (sign) => { + const parsed = getRenderablePatch( + [ + "diff --git a/blank.txt b/blank.txt", + "--- a/blank.txt", + "+++ b/blank.txt", + sign === "+" ? "@@ -0,0 +1 @@" : "@@ -1 +0,0 @@", + `${sign} `, + "\\ No newline at end of file", + ].join("\n"), + "pr", + { ignoreWhitespace: true }, + ); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + expect(getDiffLineStat(parsed.files)).toEqual({ + additions: sign === "+" ? 1 : 0, + deletions: sign === "-" ? 1 : 0, + }); + }); + it.each([ ["a/example.ts", "a/example.ts", "change"], ["b/example.ts", "b/example.ts", "change"], diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 3a6c6e20969a..c99349a7dafd 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,4 +1,5 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { parseDiffFromFile } from "@pierre/diffs"; import type { FileDiffMetadata } from "@pierre/diffs/types"; import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; @@ -46,6 +47,7 @@ export type RenderablePatch = | { kind: "files"; files: FileDiffMetadata[]; + sourceFiles: FileDiffMetadata[]; } | { kind: "raw"; @@ -73,6 +75,7 @@ export function getDiffLineStat(files: ReadonlyArray): DiffLin } interface RenderablePatchOptions { + ignoreWhitespace?: boolean; /** * Pierre's partial-patch parser keeps hunk render starts in source-file * coordinates. Its virtualizer iterates partial patches as compact rows, so @@ -82,6 +85,59 @@ interface RenderablePatchOptions { compactPartialHunkOffsets?: boolean; } +function hideWhitespaceChanges(file: FileDiffMetadata): FileDiffMetadata { + let splitDelta = 0; + let unifiedDelta = 0; + const hunks = file.hunks.map((hunk) => { + const oldContents = file.deletionLines + .slice(hunk.deletionLineIndex, hunk.deletionLineIndex + hunk.deletionCount) + .map((line) => `${line.replace(/\s/g, "")}\n`) + .join(""); + const newContents = file.additionLines + .slice(hunk.additionLineIndex, hunk.additionLineIndex + hunk.additionCount) + .map((line) => `${line.replace(/\s/g, "")}\n`) + .join(""); + const filtered = parseDiffFromFile( + { name: file.name, contents: oldContents }, + { name: file.name, contents: newContents }, + { context: Infinity }, + ).hunks[0]; + const next = { + ...hunk, + additionLines: filtered?.additionLines ?? 0, + deletionLines: filtered?.deletionLines ?? 0, + hunkContent: filtered + ? filtered.hunkContent.map((content) => ({ + ...content, + additionLineIndex: content.additionLineIndex + hunk.additionLineIndex, + deletionLineIndex: content.deletionLineIndex + hunk.deletionLineIndex, + })) + : [ + { + type: "context" as const, + lines: hunk.additionCount, + additionLineIndex: hunk.additionLineIndex, + deletionLineIndex: hunk.deletionLineIndex, + }, + ], + splitLineStart: hunk.splitLineStart + splitDelta, + unifiedLineStart: hunk.unifiedLineStart + unifiedDelta, + splitLineCount: filtered?.splitLineCount ?? hunk.additionCount, + unifiedLineCount: filtered?.unifiedLineCount ?? hunk.additionCount, + }; + splitDelta += next.splitLineCount - hunk.splitLineCount; + unifiedDelta += next.unifiedLineCount - hunk.unifiedLineCount; + return next; + }); + return { + ...file, + hunks, + splitLineCount: file.splitLineCount + splitDelta, + unifiedLineCount: file.unifiedLineCount + unifiedDelta, + ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:ignore-whitespace` } : {}), + }; +} + function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { if (!file.isPartial) return file; @@ -121,13 +177,13 @@ export function getRenderablePatch( normalizedPatch, buildPatchCacheKey(normalizedPatch, cacheScope), ); - const files = parsedPatches.flatMap((parsedPatch) => - options.compactPartialHunkOffsets - ? parsedPatch.files.map(compactPartialHunkOffsets) - : parsedPatch.files, - ); + const sourceFiles = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); + const files = sourceFiles.map((file) => { + const filtered = options.ignoreWhitespace ? hideWhitespaceChanges(file) : file; + return options.compactPartialHunkOffsets ? compactPartialHunkOffsets(filtered) : filtered; + }); if (files.length > 0) { - return { kind: "files", files }; + return { kind: "files", files, sourceFiles }; } return { From 0ff87f251dafb32703d5f531e7b248ad0a581ee2 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sun, 20 Sep 2026 05:55:11 -0700 Subject: [PATCH 012/164] fix(web): keep citation comment when popover is dismissed (#10831) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../chat/AssistantCitationChip.test.tsx | 156 ++++++++++++++++++ .../components/chat/AssistantCitationChip.tsx | 61 +++++-- .../chat/AssistantCitationCommentEditor.tsx | 7 +- .../assistantCitationCommentDismissal.test.ts | 80 +++++++++ .../chat/assistantCitationCommentDismissal.ts | 21 +++ 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/chat/AssistantCitationChip.test.tsx create mode 100644 apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts create mode 100644 apps/web/src/components/chat/assistantCitationCommentDismissal.ts diff --git a/apps/web/src/components/chat/AssistantCitationChip.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.test.tsx new file mode 100644 index 000000000000..7d664f237983 --- /dev/null +++ b/apps/web/src/components/chat/AssistantCitationChip.test.tsx @@ -0,0 +1,156 @@ +import { + ASSISTANT_CITATION_MAX_COMMENT_LENGTH, + EnvironmentId, + MessageId, + ThreadId, +} from "@t3tools/contracts"; +import { act, useState, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; + +const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() })); +vi.mock("./AssistantCitationSource", () => ({ + observeAssistantCitationCommentSource: mocks.observeSource, +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + Link: ({ children }: { children: ReactNode }) => {children}, +})); +// Keep the real chip/editor lifecycle while replacing DOM positioning and floating layers. +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("../ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => , + PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("../ui/button", () => ({ + Button: (props: React.ComponentProps<"button">) => + + + ); + })} + {importMenuItems} + + + Add action + + + ); + return ( <> - {primaryScript ? ( + {presentation === "menu" ? ( + <> + {primaryScript && ( + onRunScript(primaryScript)} + > + + Run {primaryScript.name} + + {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))} + + + )} + {primaryScript || importableScripts.length > 0 ? ( + + setActionsMenuOpen({ presentation, scripts: open, imports: false }) + } + > + + + Project actions + + + {scriptItems} + + + ) : ( + + + Add project action… + + )} + + ) : primaryScript ? ( setActionsMenuOpen({ scripts: open, imports: false })} + onOpenChange={(open) => + setActionsMenuOpen({ presentation, scripts: open, imports: false }) + } > } > - - {scripts.map((script) => { - const shortcutLabel = shortcutLabelForCommand( - keybindings, - commandForProjectScript(script.id), - ); - return ( - onRunScript(script)} - > - - - {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} - - - {shortcutLabel && ( - - {shortcutLabel} - - )} - - - - ); - })} - {importMenuItems} - - - Add action - - + {scriptItems} ) : importableScripts.length > 0 ? ( setActionsMenuOpen({ scripts: false, imports: open })} + onOpenChange={(open) => + setActionsMenuOpen({ presentation, scripts: false, imports: open }) + } > }> diff --git a/apps/web/src/components/WorkspaceBreadcrumb.tsx b/apps/web/src/components/WorkspaceBreadcrumb.tsx index f1c3d8b15d04..015e381aa251 100644 --- a/apps/web/src/components/WorkspaceBreadcrumb.tsx +++ b/apps/web/src/components/WorkspaceBreadcrumb.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { cn } from "../lib/utils"; @@ -26,6 +26,23 @@ interface WorkspaceBreadcrumbItemProps { readonly current?: boolean; } +export function WorkspaceBreadcrumbText({ children, className, ...props }: ComponentProps<"span">) { + return ( + + {children} + + ); +} + export function WorkspaceBreadcrumbItem({ children, className, @@ -45,10 +62,16 @@ export function WorkspaceBreadcrumbItem({ ); } -export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) { +export function WorkspaceBreadcrumbSeparator({ + className, + children = "/", +}: { + readonly className?: string; + readonly children?: ReactNode; +}) { return ( ); } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fbebc323a950..1c8bc520485f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -11,7 +11,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, EllipsisIcon } from "lucide-react"; import { memo, useCallback, @@ -22,6 +22,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import { createPortal } from "react-dom"; import GitActionsControl from "../GitActionsControl"; import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; @@ -45,8 +46,12 @@ import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, + WorkspaceBreadcrumbText, } from "../WorkspaceBreadcrumb"; import { cn } from "~/lib/utils"; +import { useIsMobile } from "~/hooks/useMediaQuery"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -155,6 +160,40 @@ export const ChatHeader = memo(function ChatHeader({ breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" }, }); }, [panelAnimationDurationMs, panelAnimationsActive]); + const isMobile = useIsMobile(); + // Side panels can leave a desktop header narrower than a phone. + const [isNarrowHeader, setIsNarrowHeader] = useState(false); + useEffect(() => { + const container = headerActionsRef.current?.parentElement; + if (!container) return; + const update = () => setIsNarrowHeader(container.clientWidth < 512); + update(); + const observer = new ResizeObserver(update); + observer.observe(container); + return () => observer.disconnect(); + }, []); + const actionsCollapsed = isMobile || isNarrowHeader; + const [actionsOpen, setActionsOpen] = useState(false); + const [actionsContainer] = useState(() => { + const container = document.createElement("div"); + container.className = "contents"; + return container; + }); + // Reparent the DOM host, not the React controls: rotating a phone or resizing + // a window must not discard an unsaved script or Git dialog. + const mountInlineActions = useCallback( + (node: HTMLDivElement | null) => { + if (node && !actionsCollapsed) node.appendChild(actionsContainer); + }, + [actionsContainer, actionsCollapsed], + ); + const mountMenuActions = useCallback( + (node: HTMLDivElement | null) => { + if (node && actionsCollapsed) node.appendChild(actionsContainer); + }, + [actionsContainer, actionsCollapsed], + ); + if (!actionsCollapsed && actionsOpen) setActionsOpen(false); const primaryEnvironmentId = usePrimaryEnvironmentId(); const activeProjectName = activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -312,6 +351,50 @@ export const ChatHeader = memo(function ChatHeader({ }, [commitRename], ); + const headerActions = ( + <> + {activeProjectScripts && ( + <> + setActionsOpen(false)} + presentation={actionsCollapsed ? "menu" : "toolbar"} + scripts={activeProjectScripts} + fileScripts={fileScripts} + keybindings={keybindings} + preferredScriptId={preferredScriptId} + onRunScript={onRunProjectScript} + onAddScript={onAddProjectScript} + onUpdateScript={onUpdateProjectScript} + onDeleteScript={onDeleteProjectScript} + /> + + )} + {showOpenInPicker && ( + <> + {actionsCollapsed && activeProjectScripts && } + + + )} + {activeProjectName && gitCwd && ( + <> + {actionsCollapsed && (activeProjectScripts || showOpenInPicker) && } + + + )} + + ); return (
- {activeProjectName} + + {activeProjectName} + New thread in {activeProjectName} - + + / + ) : null} @@ -377,7 +464,9 @@ export const ChatHeader = memo(function ChatHeader({ /> } > -

{activeThreadTitle}

+

+ {activeThreadTitle} +

- {activeThreadTitle} - - } - /> + render={

} + > + {activeThreadTitle} + {activeThreadTitle} )} @@ -405,38 +492,37 @@ export const ChatHeader = memo(function ChatHeader({ data-chat-header-actions className={cn( "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", - rightPanelOpen ? "pr-0" : "pr-16", + // Reserve two panel toggles plus their 4px gaps and 1px edge inset. + // The page header adds 8px more right padding at sm. + rightPanelOpen ? "pr-0" : "pr-[calc(--spacing(18)+1px)] sm:pr-[calc(--spacing(14)+1px)]", "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out", )} > - {activeProjectScripts && ( - - )} - {showOpenInPicker && ( - - )} - {activeProjectName && ( - - )} + + } + > + + +
+ +
+ {createPortal(headerActions, actionsContainer)} + +

); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 2b1f252ef045..0f8f8cbc45c3 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -15,10 +15,20 @@ import { useRemoteOpenState, } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuItem, + MenuItemLabel, + MenuPopup, + MenuShortcut, + MenuSub, + MenuSubTrigger, + MenuSubPopup, + MenuTrigger, +} from "../ui/menu"; import { AntigravityIcon, CursorIcon, @@ -187,6 +197,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, openInCwd, + presentation = "toolbar", compact = false, enableShortcut = true, }: { @@ -194,6 +205,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInCwd: string | null; + presentation?: "toolbar" | "menu"; compact?: boolean; enableShortcut?: boolean; }) { @@ -274,6 +286,69 @@ export const OpenInPicker = memo(function OpenInPicker({ return () => window.removeEventListener("keydown", handler); }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); + const editorItems = ( + <> + {remote.mode === "remote-unavailable" ? ( + + No SSH route to {environmentLabel} + + ) : ( + <> + {options.length === 0 && ( + + No installed editors found + + )} + {options.map(({ label, Icon, value, kind }) => ( + openInEditor(value)} + > + + ))} + {remote.mode === "remote-links" && !remoteHintSeen && ( + + Opens over SSH. Needs your key on {environmentLabel} + + )} + + )} + + ); + if (presentation === "menu") { + return ( + <> + {primaryOption && ( + openInEditor(preferredEditor)} + > + + Open in {primaryOption.label} + {openFavoriteEditorShortcutLabel && ( + {openFavoriteEditorShortcutLabel} + )} + + )} + + + + Open in… + + {editorItems} + + + ); + } + return ( ); From fa1e1715538f6f8ac076761f7821f68479f616ab Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 20 Sep 2026 15:56:26 -0400 Subject: [PATCH 014/164] refactor(observability): hold OTLP export settings per signal (#12657) Signed-off-by: Yordis Prieto --- apps/server/src/bin.test.ts | 7 ++++--- apps/server/src/cli/config.test.ts | 19 +++++++++++------- apps/server/src/cli/config.ts | 20 +++++++++++++++---- apps/server/src/cli/pair.ts | 7 ++++--- apps/server/src/config.ts | 19 +++++++++++------- .../src/environment/ServerEnvironment.test.ts | 8 +++++--- apps/server/src/http.ts | 2 +- .../src/observability/Layers/Observability.ts | 16 +++++++++------ apps/server/src/server.test.ts | 12 +++++------ apps/server/src/serverLogger.test.ts | 17 ++++++++++------ apps/server/src/serverLogger.ts | 7 ++++--- packages/shared/src/observability.ts | 19 ++++++++++++++++++ 12 files changed, 104 insertions(+), 49 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 795bb05db148..9bc20fff84e1 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -101,10 +102,10 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index aea753c3398f..34ea7f685364 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -17,6 +17,7 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -51,10 +52,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", devAllowedOrigins: [], } as const; @@ -764,7 +765,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpHeaders).toEqual({ + expect(resolved.otlpTracesExport.headers).toEqual({ authorization: "Basic abc==", "x-tenant": "t3", }); @@ -808,7 +809,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpHeaders).toEqual({ + expect(resolved.otlpTracesExport.headers).toEqual({ authorization: "Bearer abc==", "x-tenant": "t3", }); @@ -816,7 +817,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("reads the OTLP protocol from env", () => + it.effect("gives every signal the protocol named without one", () => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); @@ -848,7 +849,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpProtocol).toBe("http/protobuf"); + expect([ + resolved.otlpTracesExport.protocol, + resolved.otlpMetricsExport.protocol, + resolved.otlpLogsExport.protocol, + ]).toEqual(["http/protobuf", "http/protobuf", "http/protobuf"]); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 32f465b6fb0a..09a30aeb19e7 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,9 @@ import * as NetService from "@t3tools/shared/Net"; -import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; +import { + OtlpHeadersFromString, + OtlpProtocol, + type SignalExport, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -382,6 +386,14 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); + // T3 Code's own OTLP variables name no signal, so the one answer they give + // is the answer for all three. + const signalExport: SignalExport = { + protocol: env.otlpProtocol, + headers: env.otlpHeaders, + exportIntervalMs: env.otlpExportIntervalMs, + }; + const config: ServerConfig.ServerConfig["Service"] = { logLevel, traceMinLevel: env.traceMinLevel, @@ -399,10 +411,10 @@ export const resolveServerConfig = ( persistedObservabilitySettings.otlpMetricsUrl, otlpLogsUrl: env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, - otlpExportIntervalMs: env.otlpExportIntervalMs, + otlpTracesExport: signalExport, + otlpMetricsExport: signalExport, + otlpLogsExport: signalExport, otlpServiceName: env.otlpServiceName, - otlpHeaders: env.otlpHeaders, - otlpProtocol: env.otlpProtocol, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 9ff9a8e13b39..493e6b719416 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -15,6 +15,7 @@ import { PortSchema, } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, @@ -321,10 +322,10 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 4835ebb40e44..5762ccdb6ff3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -17,7 +17,7 @@ import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; -import { OtlpProtocol } from "@t3tools/shared/observability"; +import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; export const DEFAULT_PORT = 3773; @@ -73,10 +73,15 @@ export class ServerConfig extends Context.Service< readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; readonly otlpLogsUrl: string | undefined; - readonly otlpExportIntervalMs: number; + /** + * How each signal is exported. Read instead of a process-wide setting so + * the wire format, credential, and schedule travel with the endpoint they + * were configured beside. + */ + readonly otlpTracesExport: SignalExport; + readonly otlpMetricsExport: SignalExport; + readonly otlpLogsExport: SignalExport; readonly otlpServiceName: string; - readonly otlpHeaders: Readonly> | undefined; - readonly otlpProtocol: OtlpProtocol; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -212,10 +217,10 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 0ccf8de691eb..b4758e980065 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,6 +10,8 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; + import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { PUBLISH_AGENT_ACTIVITY_SECRET, @@ -54,10 +56,10 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 879ca66ca60a..7533b8c1db19 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -322,7 +322,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; - const otlpHeaders = config.otlpHeaders; + const otlpHeaders = config.otlpTracesExport.headers; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const serialization = yield* OtlpSerialization.OtlpSerialization; diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 0c9acdfb1ee3..8f7b607745f4 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -20,7 +20,11 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const serializationLayer = otlpSerializationLayer(config.otlpProtocol); + const traces = config.otlpTracesExport; + const metrics = config.otlpMetricsExport; + // The trace serializer stays in the returned context because the browser + // trace forwarder exports on the same signal. + const serializationLayer = otlpSerializationLayer(traces.protocol); const resource = ServerConfig.otlpResource(config); const attribution = yield* ResourceAttribution.ResourceAttribution; @@ -51,8 +55,8 @@ export const ObservabilityLive = Layer.unwrap( ? undefined : yield* OtlpTracer.make({ url: config.otlpTracesUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - headers: config.otlpHeaders, + exportInterval: `${traces.exportIntervalMs} millis`, + headers: traces.headers, resource, }); @@ -77,10 +81,10 @@ export const ObservabilityLive = Layer.unwrap( ? Layer.empty : OtlpMetrics.layer({ url: config.otlpMetricsUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - headers: config.otlpHeaders, + exportInterval: `${metrics.exportIntervalMs} millis`, + headers: metrics.headers, resource, - }).pipe(Layer.provideMerge(serializationLayer)); + }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2618020d388d..5d46c866a165 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -218,7 +218,7 @@ import { transferBudgetViolations, } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import { otlpSerializationLayer } from "@t3tools/shared/observability"; +import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -578,10 +578,10 @@ const buildAppUnderTest = (options?: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", mode: "desktop", port: 0, host: "127.0.0.1", @@ -1076,7 +1076,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.browserTraceCollector, }), ), - Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + Layer.provide(otlpSerializationLayer(config.otlpTracesExport.protocol)), Layer.provide( Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), @@ -5299,7 +5299,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ config: { otlpTracesUrl: collector.url, - otlpProtocol: "http/protobuf", + otlpTracesExport: { ...DEFAULT_SIGNAL_EXPORT, protocol: "http/protobuf" }, }, layers: { browserTraceCollector: { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 59ca908cb4ad..cbb5056ed314 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -8,6 +8,8 @@ import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; + import * as ServerConfig from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; @@ -51,10 +53,10 @@ const configLayer = (overrides: Partial) = otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", cwd: baseDir, baseDir, ...derivedPaths, @@ -155,12 +157,15 @@ describe("ServerLoggerLive", () => { }), ); - it.effect("sends the headers and wire format the rest of OTLP export already uses", () => + it.effect("sends the headers and wire format the log signal asked for", () => Effect.gen(function* () { const requests = yield* logThrough({ otlpLogsUrl: "https://collector.example.com/v1/logs", - otlpProtocol: "http/protobuf", - otlpHeaders: { "x-scope": "logs" }, + otlpLogsExport: { + ...DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-scope": "logs" }, + }, }); assert.lengthOf(requests, 1); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 389a9535efab..6d19907c20ca 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -12,13 +12,14 @@ export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); + const logs = config.otlpLogsExport; const otlpLogger = config.otlpLogsUrl === undefined ? undefined : OtlpLogger.make({ url: config.otlpLogsUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - headers: config.otlpHeaders, + exportInterval: `${logs.exportIntervalMs} millis`, + headers: logs.headers, resource: otlpResource(config), }); @@ -41,7 +42,7 @@ export const ServerLoggerLive = Effect.gen(function* () { { mergeWithExisting: false }, ).pipe( Layer.provide(OtlpExporter.layerFlusher), - Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + Layer.provide(otlpSerializationLayer(logs.protocol)), ); return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 62121399853b..6393ed0879db 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -16,6 +16,25 @@ export type OtlpProtocol = typeof OtlpProtocol.Type; export const otlpSerializationLayer = (protocol: OtlpProtocol) => protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson; +/** + * How one signal is exported, once whichever source named that signal's + * endpoint has been resolved. Held per signal rather than per process, so a + * wire format or a credential cannot be paired by hand with an endpoint that + * came from somewhere else. + */ +export interface SignalExport { + readonly protocol: OtlpProtocol; + readonly headers: Readonly> | undefined; + readonly exportIntervalMs: number; +} + +/** What T3 Code exports with when nothing configured a signal. */ +export const DEFAULT_SIGNAL_EXPORT: SignalExport = { + protocol: "http/json", + headers: undefined, + exportIntervalMs: 10_000, +}; + const FLUSH_BUFFER_THRESHOLD = 256; const textEncoder = new TextEncoder(); From ead1dee22916b414906eafdae5f5a1293f16e637 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:04:46 +0200 Subject: [PATCH 015/164] fix(web): explain what enabling network access means in its confirmation (#10098) Co-authored-by: shivamhwp <91240327+shivamhwp@users.noreply.github.com> --- .../settings/ConnectionsSettings.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 324361d7fb68..1410b3b5e0e7 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3417,8 +3417,8 @@ export function ConnectionsSettings() { {pendingDesktopServerExposureMode === "network-accessible" - ? "T3 Code will restart to expose this environment over the network." - : "T3 Code will restart and limit this environment back to this machine."} + ? "Let your other devices connect to T3 Code over the network. Pair devices to give them access. T3 Code will restart." + : "Devices connected over your local network will disconnect. Existing tunnels, such as T3 Connect or Tailscale HTTPS, keep working. T3 Code will restart."} @@ -3426,27 +3426,23 @@ export function ConnectionsSettings() { disabled={isUpdatingDesktopServerExposure} render={ From 9f0c9f725d4553d34e3c91c13d67ebcc58be5b0f Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:20:10 -0700 Subject: [PATCH 016/164] fix(web): reuse current PR status in the sidebar (#12545) --- .../pullRequest/PullRequestService.test.ts | 61 ++++ .../src/pullRequest/PullRequestService.ts | 31 +- .../pullRequest/gitHubPullRequestJson.test.ts | 8 + .../src/pullRequest/gitHubPullRequestJson.ts | 7 +- apps/web/src/components/RightPanelTabs.tsx | 28 +- .../src/components/ThreadStatusIndicators.tsx | 19 +- .../pullRequest/PullRequestChecksPopover.tsx | 10 +- .../PullRequestDetailPanel.test.tsx | 3 +- .../pullRequest/PullRequestDetailPanel.tsx | 300 +++++++++++------- .../pullRequest/PullRequestGhosts.tsx | 44 ++- .../pullRequest/PullRequestSummaryTab.tsx | 13 +- apps/web/src/state/pullRequests.test.ts | 83 +++++ apps/web/src/state/pullRequests.ts | 160 ++++++++-- apps/web/src/state/query.ts | 2 + packages/contracts/src/pullRequest.ts | 6 + 15 files changed, 604 insertions(+), 171 deletions(-) create mode 100644 apps/web/src/state/pullRequests.test.ts diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 9d563d3965c3..a0d111bd710f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3404,6 +3404,67 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => }), ); +it.effect( + "keeps listing freshness tied to read start when filtered reads finish out of order", + () => + Effect.gen(function* () { + const olderStarted = yield* Deferred.make(); + const releaseOlder = yield* Deferred.make(); + let reads = 0; + const updatedAt = "2026-07-02T00:00:00Z"; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ filters }) => + Effect.gen(function* () { + reads += 1; + const older = filters?.checks === "failing"; + if (older) { + yield* Deferred.succeed(olderStarted, undefined); + yield* Deferred.await(releaseOlder); + } + return { + items: [ + { + ...changeRequest(1, updatedAt), + checksState: older ? ("failing" as const) : ("passing" as const), + mergeability: older ? ("mergeable" as const) : ("conflicting" as const), + }, + ], + truncated: false, + continues: false, + }; + }), + }), + ], + }); + const olderInput = { state: "open" as const, filters: { checks: "failing" as const } }; + const newerInput = { state: "open" as const, filters: { checks: "passing" as const } }; + + const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild()); + yield* Deferred.await(olderStarted); + yield* TestClock.adjust("1 second"); + const newer = yield* service.list(newerInput); + yield* Deferred.succeed(releaseOlder, undefined); + const older = yield* Fiber.join(olderRead); + + assert.strictEqual(older.entries[0]?.checksState, "failing"); + assert.strictEqual(older.entries[0]?.mergeability, "mergeable"); + assert.strictEqual(newer.entries[0]?.checksState, "passing"); + assert.strictEqual(newer.entries[0]?.mergeability, "conflicting"); + assert.strictEqual(typeof older.entries[0]?.observedAt, "number"); + assert.strictEqual(typeof newer.entries[0]?.observedAt, "number"); + assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!); + + const cachedOlder = yield* service.list(olderInput); + assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt); + assert.strictEqual(reads, 2); + }), +); + it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => Effect.gen(function* () { const calls: string[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a653ef94c713..6b4b2bd8bfd5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -611,6 +611,12 @@ function withRateLimitBackoff( Record, never>; } +// Capture before the provider read so a slow response keeps its original freshness through caches. +const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { + const observedAt = yield* Clock.currentTimeMillis; + return { value: yield* read, observedAt }; +}); + export const make = Effect.gen(function* () { const mergedPullRequests = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); @@ -1076,6 +1082,7 @@ export const make = Effect.gen(function* () { readonly project: SupportedProject; readonly item: ProviderChangeRequest; readonly viewer: string; + readonly observedAt: number; }): PullRequestListEntry => { const viewer = input.viewer.toLowerCase(); return { @@ -1098,6 +1105,7 @@ export const make = Effect.gen(function* () { deletions: input.item.deletions, createdAt: input.item.createdAt, updatedAt: input.item.updatedAt, + observedAt: input.observedAt, ...(input.item.checksState === undefined || input.item.checksState === null ? {} : { checksState: input.item.checksState }), @@ -1246,7 +1254,8 @@ export const make = Effect.gen(function* () { }), }) .pipe( - Effect.map((page): RepositoryBatch => { + observeRead, + Effect.map(({ value: page, observedAt }): RepositoryBatch => { // The boundary instant was asked for inclusively, so the rows already sent at it // come back with the slice. Dropping them here rather than asking for strictly // older is what keeps their neighbours at the same instant from being skipped. @@ -1262,7 +1271,7 @@ export const make = Effect.gen(function* () { key, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), + .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: @@ -1323,7 +1332,8 @@ export const make = Effect.gen(function* () { ? {} : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( - Effect.flatMap((page) => + observeRead, + Effect.flatMap(({ value: page, observedAt }) => Effect.flatMap(Clock.currentTimeMillis, (now) => { const rows = new Map>(); for (const [key, visibleAt] of searchVisibleAt) { @@ -1384,7 +1394,7 @@ export const make = Effect.gen(function* () { key: project.cursorKey, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), + .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: @@ -1551,7 +1561,8 @@ export const make = Effect.gen(function* () { : project.api.getChangeRequestSummary(providerInput); return read.pipe( Effect.mapError(toPullRequestError("summary")), - Effect.map((changeRequest): PullRequestSummary => ({ + observeRead, + Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({ provider: project.api.kind, projectId: project.project.id, repository: project.repository, @@ -1564,6 +1575,7 @@ export const make = Effect.gen(function* () { closedAt: changeRequest.closedAt ?? null, mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, + observedAt, ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), ...(changeRequest.additions === undefined @@ -1634,12 +1646,12 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, }) - .pipe(Effect.mapError(toPullRequestError("detail"))), + .pipe(Effect.mapError(toPullRequestError("detail")), observeRead), viewerOf(project), ], { concurrency: 2 }, ).pipe( - Effect.map(([changeRequest, viewer]): PullRequestDetail => ({ + Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ provider: project.api.kind, capabilities: project.api.capabilities, projectId: project.project.id, @@ -1664,6 +1676,7 @@ export const make = Effect.gen(function* () { baseBranch: changeRequest.baseBranch, createdAt: changeRequest.createdAt, updatedAt: changeRequest.updatedAt, + observedAt, mergedAt: changeRequest.mergedAt, closedAt: changeRequest.closedAt, reviewers: changeRequest.reviewers, @@ -2894,12 +2907,14 @@ export const make = Effect.gen(function* () { closedAt: detail.closedAt, mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, + observedAt: detail.observedAt, }); 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; + if (next.updatedAt !== current.updatedAt) return next.updatedAt > current.updatedAt; + return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); }; const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 4591ac0437bf..05796283b9b5 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -121,6 +121,13 @@ describe("pull request list decoding", () => { { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, // Neither a pass, a failure nor a wait is no verdict rather than a green tick. { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, + // Cancelled reads as failing here and in the detail header, so the two never flap. + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "test", status: "COMPLETED", conclusion: "CANCELLED" }, + ], + }, { statusCheckRollup: [] }, {}, ]), @@ -132,6 +139,7 @@ describe("pull request list decoding", () => { "passing", "failing", null, + "failing", null, null, ]); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index a011702d685e..8a1434224ad2 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1447,8 +1447,9 @@ function toCheckEntries( * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. * * Null rather than "passing" for a head commit with no checks at all, so a repository that runs - * none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a - * pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither. + * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as + * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count + * towards neither, so the row and the detail header never disagree about one head commit. * * Counted off the deduped checks rather than the raw rollup, so the word and the list under it * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is @@ -1463,7 +1464,7 @@ function rollupChecksState( ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), ]; if (statuses.length === 0) return null; - if (statuses.includes("failure")) return "failing"; + if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; return statuses.includes("success") ? "passing" : null; } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f234efff093e..e1bc3f151fd9 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -34,6 +34,7 @@ import { type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -62,7 +63,11 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; -import { pullRequestEnvironment } from "~/state/pullRequests"; +import { + newestPullRequestSummary, + pullRequestEnvironment, + useSharedPullRequestSummary, +} from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -800,19 +805,26 @@ function PullRequestSurfaceIcon({ }, }), ).data; + const reference = useMemo( + () => ({ + projectId: surface.projectId as ProjectId, + repository: surface.repository, + number: surface.number, + }), + [surface.projectId, surface.repository, surface.number], + ); + const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference, null); // The compact tab intentionally shows lifecycle and draft state only. Conflict warnings have // their own presentation on surfaces that have mergeability, while this tab stays stable as // detail data arrives. - const status = - linkedSnapshot !== null - ? linkedSnapshot - : detail === null - ? (seed ?? null) - : { state: detail.state, isDraft: detail.isDraft }; + const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null; if (status === null) { return ; } - const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft }); + const presentation = resolvePullRequestState({ + state: status.state, + isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false, + }); return ; } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 016a2c2e431d..5b2945042ac2 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -76,15 +76,24 @@ export function useLinkedThreadPullRequest( ); const fallback = current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null; - const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host; - const reference = - fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) }; + // Stable per link: the shared summary effect keys on this object, and a sidebar row must not + // touch the cache on every render. + const reference = useMemo(() => { + if (fallback == null) return null; + const host = parseChangeRequestUrl(fallback.url)?.host; + return { ...fallback, ...(host === undefined ? {} : { host }) }; + }, [fallback]); const queried = useEnvironmentQuery( !enabled || environmentId === null || reference === null ? null : linkedPullRequestDetailAtom({ environmentId, input: reference }), - ).data; - const detail = useSharedPullRequestSummary(environmentId, reference, queried); + ); + const detail = useSharedPullRequestSummary( + environmentId, + reference, + queried.data, + queried.dataUpdatedAt, + ); return useMemo(() => { if (current !== null) return linkedPullRequestSnapshotStatus(current); diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index 20100339ecbb..e9797016aad7 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -109,6 +109,7 @@ function ChecksBody({ export function PullRequestChecksPopover({ checksState, checks, + stale = false, environmentId, reference, threadRef = null, @@ -117,6 +118,7 @@ export function PullRequestChecksPopover({ checksState: PullRequestChecksState; /** The checks already in hand, for the detail header. Absent on a listing row. */ checks?: ReadonlyArray; + stale?: boolean; environmentId?: EnvironmentId; reference?: PullRequestRef; /** Thread the popover sits beside; a listing row has none. */ @@ -125,7 +127,7 @@ export function PullRequestChecksPopover({ }) { const presentation = pullRequestChecksStatePresentation(checksState); // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads. - const summary = checks === undefined ? null : summarizePullRequestChecks(checks); + const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks); return ( {/* A listing row is itself a button, so the trigger renders as a span: a nested button is @@ -148,7 +150,11 @@ export function PullRequestChecksPopover({

{presentation.label}

{summary === null ? null :

{summary}

} - {checks !== undefined ? ( + {stale ? ( +

+ Check details are out of date. Refresh the pull request to update them. +

+ ) : checks !== undefined ? ( ) : environmentId !== undefined && reference !== undefined ? ( ({ usePreparePullRequestThreadAction: () => ({ run: prepareThread }), })); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("~/state/pullRequests", () => ({ +vi.mock("~/state/pullRequests", async (importOriginal) => ({ + ...(await importOriginal()), pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" }, usePullRequestTurnRefresh: () => 0, useSharedPullRequestSummary: () => null, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 31d81315c2e1..c605409b1801 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -78,8 +78,13 @@ import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment } from "~/state/pullRequests"; -import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; +import { + pullRequestEnvironment, + pullRequestListEntryToSummary, + newestPullRequestSummary, + usePullRequestTurnRefresh, + useSharedPullRequestSummary, +} from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { PullRequestStackMenu } from "./PullRequestStackMenu"; import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; @@ -167,6 +172,7 @@ import { PullRequestMetaLine, PullRequestReviewOutcomeIcon, pullRequestChecksState, + pullRequestChecksStatePresentation, pullRequestReviewOutcomeToneClassName, resolvePullRequestState, summarizePullRequestChecks, @@ -521,10 +527,11 @@ export function PullRequestDetailPanel({ }) { const environmentConfigs = useServerConfigs(); const projects = useProjects(); - const repositoryIdentity = projects.find( + const project = projects.find( (project) => project.id === requestedReference.projectId && project.environmentId === environmentId, - )?.repositoryIdentity; + ); + const repositoryIdentity = project?.repositoryIdentity; const supportsThreadPullRequests = environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; const reference = useMemo( @@ -542,6 +549,8 @@ export function PullRequestDetailPanel({ const matchingListEntry = listEntry?.projectId === reference.projectId && listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && + (reference.host === undefined || + listEntry.host.toLowerCase() === reference.host.toLowerCase()) && listEntry.number === reference.number ? listEntry : null; @@ -675,14 +684,47 @@ export function PullRequestDetailPanel({ cached: cachedDetail, reference, }); - const sharedSummary = useSharedPullRequestSummary(environmentId, reference, resolvedCoreDetail); + const listSummary = useMemo( + () => (matchingListEntry === null ? null : pullRequestListEntryToSummary(matchingListEntry)), + [matchingListEntry], + ); + const detailSummary = useMemo( + () => + detailQuery.data === null + ? null + : { + ...detailQuery.data, + checksState: pullRequestChecksState(detailQuery.data.checks), + }, + [detailQuery.data], + ); + const observedSummary = useSharedPullRequestSummary( + environmentId, + reference, + detailSummary, + detailQuery.dataUpdatedAt, + ); + // The list row is also published to the shared cache, but only after this commit's layout + // effects run, so it is compared directly rather than trusted to be there already. + const sharedSummary = useMemo( + () => + newestPullRequestSummary( + resolvedCoreDetail, + newestPullRequestSummary(observedSummary, listSummary), + ), + [resolvedCoreDetail, observedSummary, listSummary], + ); const coreDetail = useMemo( () => resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail ? resolvedCoreDetail : { ...resolvedCoreDetail, - ...sharedSummary, + title: sharedSummary.title, + state: sharedSummary.state, + headBranch: sharedSummary.headBranch, + baseBranch: sharedSummary.baseBranch, + updatedAt: sharedSummary.updatedAt, author: sharedSummary.author ?? resolvedCoreDetail.author, additions: sharedSummary.additions ?? resolvedCoreDetail.additions, deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, @@ -720,6 +762,7 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const handoffSummary = detail ?? sharedSummary; const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { copyToClipboard: copyReference } = useCopyToClipboard({ target: "pull request reference", @@ -945,9 +988,11 @@ export function PullRequestDetailPanel({ const acting = pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null; const actingEnvironmentId = acting?.environmentId ?? environmentId; + const checkoutRoot = + acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null; const prepareThread = usePreparePullRequestThreadAction({ environmentId: actingEnvironmentId, - cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null, + cwd: checkoutRoot, }); const finishAction = async ( @@ -1178,7 +1223,7 @@ export function PullRequestDetailPanel({ // already work — and it moves the branch under everything else that is open there. mode: "worktree" | "local" = "worktree", ) => { - if (!detail || handoff !== null) return; + if (!handoffSummary || handoff !== null) return; if (attachTarget !== null && task !== null) { writeTaskToComposer(attachTarget, task); toastManager.add({ @@ -1188,6 +1233,7 @@ export function PullRequestDetailPanel({ }); return; } + if (checkoutRoot === null) return; setHandoff(kind); // The menu closes on the press and takes its "Preparing..." label with it, so this is the // only thing answering for the checkout. It carries no timeout of its own: a loading toast @@ -1198,7 +1244,10 @@ export function PullRequestDetailPanel({ }); // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer // the task lands in are all one server's, and picking another one moves all three. - const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); + const projectRef = scopeProjectRef( + actingEnvironmentId, + acting?.projectId ?? handoffSummary.projectId, + ); // The thread is opened before the checkout rather than after it, because the project's setup // script only runs for a checkout that knows which thread it is for — and a worktree with no // dependencies installed is not something anyone can test. @@ -1219,7 +1268,7 @@ export function PullRequestDetailPanel({ return; } const prepared = await prepareThread.run({ - reference: detail.url, + reference: handoffSummary.url, mode, threadId: opened.threadId, }); @@ -1348,7 +1397,7 @@ export function PullRequestDetailPanel({ }; const startCheckout = (mode: "worktree" | "local") => { - if (!detail) return; + if (!handoffSummary) return; void startHandoff(`checkout:${mode}`, null, mode); }; @@ -1380,20 +1429,20 @@ export function PullRequestDetailPanel({ baseBranch: detail.baseBranch, reviewThreads: detail.reviewThreads, comments: detail.comments, - checks: detail.checks, + checks: checksStale ? [] : detail.checks, commentsTruncated: detail.commentsTruncated, }), ); }; const startResolveConflicts = () => { - if (!detail) return; + if (!handoffSummary) return; void startHandoff("conflicts", { prompt: buildResolveConflictsPrompt({ - number: detail.number, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, + number: handoffSummary.number, + url: handoffSummary.url, + headBranch: handoffSummary.headBranch, + baseBranch: handoffSummary.baseBranch, }), }); }; @@ -1443,7 +1492,17 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - const checksState = detail ? pullRequestChecksState(detail.checks) : null; + const detailChecksState = detail ? pullRequestChecksState(detail.checks) : null; + const latestChecksState = + sharedSummary?.checksState === undefined ? detailChecksState : sharedSummary.checksState; + // List rollups can omit workflows awaiting approval. Only refreshed detail can clear those. + const checksState = + latestChecksState !== "failing" && + detail?.checks.some((check) => check.status === "action-required") + ? "pending" + : latestChecksState; + // A newer rollup cannot tell us which runs changed or how many passed. + const checksStale = checksState !== detailChecksState; // The merge state remains in one stable slot from waiting through completion. Conflicts take // the slot while they need a person; the armed badge remains beside them so that state is not lost. const primaryAction = detail @@ -1494,7 +1553,13 @@ export function PullRequestDetailPanel({ const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; - const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; + const checksSummary = checksStale + ? checksState === null + ? "No checks reported" + : pullRequestChecksStatePresentation(checksState).label + : detail + ? summarizePullRequestChecks(detail.checks) + : null; // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer // who gave it, so counting it here would have the header assert in a number what the row next // to it has just qualified. @@ -1509,10 +1574,108 @@ export function PullRequestDetailPanel({ ).length : 0; + const checkoutControl = + context === "page" ? ( + + + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + + } + /> + } + /> + Check out this pull request + + + startCheckout("worktree")}> + + + In a separate worktree + + Its own folder and thread. Nothing you have open moves. + + + + startCheckout("local")}> + + + In this repository + + Switches the branch you are working in, like `gh pr checkout`. + + + + {pickableEnvironments.length > 0 ? ( + setActingScope({ pullRequestKey, environmentId: next })} + disabled={handoff !== null} + /> + ) : null} + + + ) : null; + + const resolveConflictsControl = ( + + + + + } + /> + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + + ); + // The list already has the pull request's identity and summary. Keep them on screen // and let the richer detail read replace the remaining placeholders in place. if (detailQuery.isPending && !detail) { - return ; + return ( + + {checkoutControl} + {handoffSummary.state === "open" && handoffSummary.mergeability === "conflicting" + ? resolveConflictsControl + : null} + + ) : undefined + } + /> + ); } return ( @@ -1720,71 +1883,7 @@ export function PullRequestDetailPanel({ threadRef={null} /> ) : null} - {/* Checking a pull request out is the reason to open one here at all, so it is a - button of its own rather than a side effect of asking an agent for something. - It asks where, because the two answers are not interchangeable: one leaves your - work where it is, the other moves the repository you are standing in. Only on - the page: beside a thread the branch is already checked out right there. */} - {context === "page" ? ( - - - - - - {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} - - - - } - /> - } - /> - Check out this pull request - - - startCheckout("worktree")}> - - - In a separate worktree - - Its own folder and thread. Nothing you have open moves. - - - - startCheckout("local")}> - - - In this repository - - Switches the branch you are working in, like `gh pr checkout`. - - - - {pickableEnvironments.length > 0 ? ( - setActingScope({ pullRequestKey, environmentId: next })} - disabled={handoff !== null} - /> - ) : null} - - - ) : null} + {checkoutControl} {/* 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. */} {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( @@ -1809,31 +1908,7 @@ export function PullRequestDetailPanel({ ) : null} {primaryAction === "resolve" ? ( - - - - - } - /> - - {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} - - + resolveConflictsControl ) : primaryAction === "ready" ? ( {tab === "summary" ? ( - {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( + {workflowApprovalsRequired > 0 && !checksStale && can("approve-workflows") ? ( @@ -2649,12 +2725,14 @@ export function PullRequestDetailPanel({ reference={reference} detail={detail} activityPending={activityPending} + checksStale={checksStale} activityError={activityError} pendingFinding={handoff} fixFindingLabel={handoffLabels.fixFinding} fixCheckLabel={handoffLabels.fixCheck} onFixFinding={startFixFinding} onRefresh={refreshDetail} + onRefreshChecks={refreshFromHost} />
) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index f8c922356548..c82129df13b1 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -7,8 +7,9 @@ * both themes) and the single `animate-skeleton` pulse, applied once on the container so any * number of bars costs one opacity animation. */ -import type { PullRequestListEntry } from "@t3tools/contracts"; +import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; import { ArrowLeftIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -72,16 +73,33 @@ export function PullRequestListGhost({ * boundaries in the ghost prevents the loaded pull request from replacing one layout with * another a moment later. */ -export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) { +export function PullRequestDetailGhost({ + seed: entry, + summary, + actions, +}: { + seed?: PullRequestListEntry | null; + summary?: PullRequestSummary | null; + actions?: ReactNode; +}) { + const seed = summary + ? { + ...entry, + ...summary, + isDraft: summary.isDraft ?? entry?.isDraft, + } + : entry; const statePresentation = seed ? resolvePullRequestState({ state: seed.state, - isDraft: seed.isDraft, + isDraft: seed.isDraft ?? false, }) : null; - const checksPresentation = seed?.checksState - ? pullRequestChecksStatePresentation(seed.checksState) - : null; + // Passing list rollups can omit workflows awaiting approval; wait for detail to claim success. + const checksPresentation = + seed?.checksState === "failing" || seed?.checksState === "pending" + ? pullRequestChecksStatePresentation(seed.checksState) + : null; return (
-
+
{seed && statePresentation ? ( @@ -114,8 +132,8 @@ export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | )}
- - + {actions ?? } +
@@ -129,7 +147,7 @@ export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | {seed ? ( <> @@ -165,8 +183,8 @@ export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | {seed ? ( ) : ( @@ -217,7 +235,7 @@ export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry |
- {seed ? ( + {seed?.labels ? ( seed.labels.slice(0, 3).map((label) => { const color = pullRequestLabelColor(label.color); return ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index e89b38c28e1e..b2096c87ded9 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -461,18 +461,21 @@ export function PullRequestSummaryTab({ reference, detail, activityPending, + checksStale = false, activityError, pendingFinding, fixFindingLabel = "Fix in a thread", fixCheckLabel = "Fix", onFixFinding, onRefresh, + onRefreshChecks = onRefresh, }: { environmentId: EnvironmentId; threadRef: ScopedThreadRef | null; reference: PullRequestRef; detail: PullRequestDetailView; activityPending: boolean; + checksStale?: boolean; activityError: string | null; /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */ pendingFinding?: string | null; @@ -480,6 +483,7 @@ export function PullRequestSummaryTab({ fixCheckLabel?: string; onFixFinding?: (finding: PullRequestFinding) => void; onRefresh: () => void; + onRefreshChecks?: () => void; }) { // Keyed by the pull request, so opening another one starts at the end of its conversation // rather than wherever the last one had been read back to. @@ -872,7 +876,14 @@ export function PullRequestSummaryTab({
- {detail.checks.length === 0 ? ( + {checksStale ? ( +
+ Check details are out of date. + +
+ ) : detail.checks.length === 0 ? (

No checks reported.

) : ( detail.checks.map((check, index) => { diff --git a/apps/web/src/state/pullRequests.test.ts b/apps/web/src/state/pullRequests.test.ts new file mode 100644 index 000000000000..48ee5b51b884 --- /dev/null +++ b/apps/web/src/state/pullRequests.test.ts @@ -0,0 +1,83 @@ +import { ProjectId, type PullRequestSummary } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRequests"; + +function summary(overrides: Partial = {}): PullRequestSummary { + return { + provider: "github", + projectId: ProjectId.make("pull-request-cache-test"), + repository: "acme/widget", + number: 7, + title: "Improve widget", + url: "https://github.com/acme/widget/pull/7", + state: "open", + isDraft: false, + headBranch: "improve-widget", + baseBranch: "main", + updatedAt: "2026-09-10T00:00:00Z", + author: { login: "oliver", name: null, avatarUrl: null }, + mergeability: "mergeable", + checksState: "passing", + ...overrides, + }; +} + +const observed = (value: PullRequestSummary, observedAt: number) => ({ + summary: value, + observedAt, +}); + +describe("pull request summary cache", () => { + it("orders same-dated snapshots by the server's read time, not by arrival", () => { + const stale = observed(summary({ observedAt: 200 }), 500); + const held = observed( + summary({ mergeability: "conflicting", checksState: "failing", observedAt: 300 }), + 100, + ); + expect(newestPullRequestObservation(stale, held)?.summary).toMatchObject({ + mergeability: "conflicting", + checksState: "failing", + observedAt: 300, + }); + // An older filtered or server-cached response finishing last must not roll status back. + expect(newestPullRequestObservation(held, stale)).toBe(held); + // The same read seen again is not a new observation. + expect(newestPullRequestObservation(held, { ...held, observedAt: 900 })).toBe(held); + }); + + it("uses arrival order only when neither snapshot carries a server read time", () => { + const first = observed(summary(), 100); + const later = observed(summary({ checksState: "failing" }), 200); + expect(newestPullRequestObservation(first, later)).toMatchObject({ observedAt: 200 }); + expect(newestPullRequestObservation(later, first)).toBe(later); + // A stamped read beats an unstamped one regardless of which arrived last. + const stamped = observed(summary({ observedAt: 50 }), 1); + expect(newestPullRequestObservation(later, stamped)?.summary.observedAt).toBe(50); + expect(newestPullRequestObservation(stamped, later)).toBe(stamped); + }); + + it("keeps known status when a newer snapshot omits it, and clears it when told to", () => { + const list = observed(summary({ reviewDecision: "approved", observedAt: 100 }), 100); + const sparse = observed( + summary({ checksState: undefined, reviewDecision: undefined, observedAt: 200 }), + 200, + ); + expect(newestPullRequestObservation(list, sparse)?.summary).toMatchObject({ + checksState: "passing", + reviewDecision: "approved", + }); + const cleared = observed(summary({ checksState: null, observedAt: 300 }), 300); + expect(newestPullRequestObservation(list, cleared)?.summary.checksState).toBeNull(); + }); + + it("treats merged as final and otherwise prefers the later host update", () => { + const merged = summary({ state: "merged", updatedAt: "2026-09-01T00:00:00Z" }); + const reopened = summary({ updatedAt: "2026-09-12T00:00:00Z", observedAt: 900 }); + expect(newestPullRequestSummary(merged, reopened)).toBe(merged); + expect(newestPullRequestSummary(reopened, merged)).toBe(merged); + const older = summary({ updatedAt: "2026-09-11T00:00:00Z", observedAt: 999 }); + expect(newestPullRequestSummary(older, reopened)).toBe(reopened); + expect(newestPullRequestSummary(reopened, older)).toBe(reopened); + }); +}); diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index e2e840c89c49..365c6812d096 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -8,6 +8,7 @@ import type { EnvironmentId, PullRequestListInput, PullRequestListStatsInput, + PullRequestListEntry, PullRequestRef, PullRequestSummary, } from "@t3tools/contracts"; @@ -30,49 +31,147 @@ export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFam pullRequestEnvironment.refreshes, ); +export interface ObservedPullRequestSummary { + readonly summary: PullRequestSummary; + /** Client arrival time, the only ordering older servers leave us for same-dated snapshots. */ + readonly observedAt: number; +} + const observedPullRequestSummaryAtom = Atom.family((key: string) => - Atom.make(null).pipe( + Atom.make(null).pipe( Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`web-pull-requests:observed-summary:${key}`), ), ); +/** + * Positive when `incoming` is the newer snapshot. Merged is final. Then the host's own update + * time, then the server's read-start time, which survives its caches; a snapshot without one + * never beats a stamped read. Zero when neither side carries a read time. + */ +function compareSummaries(current: PullRequestSummary, incoming: PullRequestSummary): number { + const merged = Number(incoming.state === "merged") - Number(current.state === "merged"); + if (merged !== 0) return merged; + const updated = Date.parse(incoming.updatedAt) - Date.parse(current.updatedAt); + if (updated !== 0) return updated; + if (current.observedAt === undefined && incoming.observedAt === undefined) return 0; + return (incoming.observedAt ?? -Infinity) - (current.observedAt ?? -Infinity); +} + 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; + return compareSummaries(current, observed) >= 0 ? observed : current; +} + +/** Reuse list status without treating its deferred line-count placeholders as real stats. */ +export function pullRequestListEntryToSummary(entry: PullRequestListEntry): PullRequestSummary { + return { + provider: entry.provider, + projectId: entry.projectId, + repository: entry.repository, + number: entry.number, + title: entry.title, + url: entry.url, + state: entry.state, + isDraft: entry.isDraft, + headBranch: entry.headBranch, + baseBranch: entry.baseBranch, + updatedAt: entry.updatedAt, + ...(entry.observedAt === undefined ? {} : { observedAt: entry.observedAt }), + author: entry.author, + ...(entry.reviewDecision === undefined ? {} : { reviewDecision: entry.reviewDecision }), + ...(entry.checksState === undefined ? {} : { checksState: entry.checksState }), + mergeability: entry.mergeability, + }; +} + +// A project has one remote, so its id already pins the host. Leaving the host out lets a list +// row, a hostless legacy reference and a URL-derived thread reference share one entry. +function pullRequestSummaryKey(environmentId: EnvironmentId, reference: PullRequestRef): string { + return JSON.stringify([ + environmentId, + reference.projectId, + reference.repository.toLowerCase(), + reference.number, + ]); +} + +/** The observation to hold after `incoming` arrives. Returns `current` itself on a tie. */ +export function newestPullRequestObservation( + current: ObservedPullRequestSummary | null, + incoming: ObservedPullRequestSummary | null, +): ObservedPullRequestSummary | null { + if (current === null) return incoming; + if (incoming === null) return current; + let order = compareSummaries(current.summary, incoming.summary); + // Client clocks only break ties between snapshots that both lack a server read time. + if ( + order === 0 && + current.summary.observedAt === undefined && + incoming.summary.observedAt === undefined + ) { + order = incoming.observedAt - current.observedAt; + } + if (!(order > 0)) return current; + // A sparse summary must not erase known status, or carry old detail stats into a new list read. + return { + ...incoming, + summary: { + ...incoming.summary, + isDraft: incoming.summary.isDraft ?? current.summary.isDraft, + mergeability: incoming.summary.mergeability ?? current.summary.mergeability, + reviewDecision: + incoming.summary.reviewDecision === undefined + ? current.summary.reviewDecision + : incoming.summary.reviewDecision, + checksState: + incoming.summary.checksState === undefined + ? current.summary.checksState + : incoming.summary.checksState, + }, + }; +} + +function observePullRequestSummary( + environmentId: EnvironmentId, + reference: PullRequestRef, + summary: PullRequestSummary, + observedAt: number, +): void { + const atom = observedPullRequestSummaryAtom(pullRequestSummaryKey(environmentId, reference)); + appAtomRegistry.modify(atom, (previous) => { + const next = newestPullRequestObservation(previous, { summary, observedAt }); + return next === previous ? [false, previous] : [true, next]; + }); } export function useSharedPullRequestSummary( environmentId: EnvironmentId | null, reference: PullRequestRef | null, current: PullRequestSummary | null, + observedAt: number | null = null, ): PullRequestSummary | null { const key = environmentId === null || reference === null ? "none" - : JSON.stringify([ - environmentId, - reference.projectId, - reference.host?.toLowerCase() ?? null, - reference.repository.toLowerCase(), - reference.number, - ]); + : pullRequestSummaryKey(environmentId, reference); 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); + if (environmentId === null || reference === null || current === null || observedAt === null) + return; + observePullRequestSummary(environmentId, reference, current, observedAt); + }, [current, environmentId, reference, observedAt]); + return ( + newestPullRequestObservation( + observed, + current === null || observedAt === null ? null : { summary: current, observedAt }, + )?.summary ?? current + ); } export const pullRequestStackAtom = createPullRequestStackAtomFamily( connectionAtomRuntime, @@ -90,6 +189,7 @@ interface MergedEnvironmentQueryView { /** The first environment that failed. Others may still have answered — this is not fatal. */ readonly error: string | null; readonly isPending: boolean; + readonly observations: ReadonlyArray; } /** @@ -110,6 +210,7 @@ function createMergedEnvironmentQuery( Atom.make((get): MergedEnvironmentQueryView => { const targets = JSON.parse(key) as ReadonlyArray>; const values: Array = []; + const observations: Array = []; let error: string | null = null; let isPending = false; for (const target of targets) { @@ -120,12 +221,16 @@ function createMergedEnvironmentQuery( } const value = Option.getOrNull(AsyncResult.value(result)); if (value !== null) values.push([target.environmentId, value]); + if (result._tag === "Success") { + observations.push([target.environmentId, result.value, result.timestamp]); + } } - return { values, error, isPending }; + return { values, error, isPending, observations }; }).pipe(Atom.withLabel(`${label}:${key}`)), ); const empty = Atom.make>({ values: [], + observations: [], error: null, isPending: false, }).pipe(Atom.withLabel(`${label}:empty`)); @@ -187,6 +292,23 @@ export function usePullRequestList( targets: ReadonlyArray>, ): MergedPullRequestListView { const query = usePullRequestListsQuery(targets); + useLayoutEffect(() => { + for (const [environmentId, answer, observedAt] of query.observations) { + for (const entry of answer.entries) { + observePullRequestSummary( + environmentId, + { + projectId: entry.projectId, + host: entry.host, + repository: entry.repository, + number: entry.number, + }, + pullRequestListEntryToSummary(entry), + observedAt, + ); + } + } + }, [query.observations]); const data = useMemo(() => mergePullRequestLists(query.values), [query.values]); return { data, error: query.error, isPending: query.isPending, refresh: query.refresh }; } diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index b2823a51e332..f5cb765a2479 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -9,6 +9,7 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; + readonly dataUpdatedAt: number | null; readonly error: string | null; readonly isPending: boolean; readonly isSuccess: boolean; @@ -30,6 +31,7 @@ export function useEnvironmentQuery( const refresh = useAtomRefresh(selectedAtom); return { data: Option.getOrNull(AsyncResult.value(result)), + dataUpdatedAt: result._tag === "Success" ? result.timestamp : null, error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, isPending: atom !== null && result.waiting, isSuccess: result._tag === "Success", diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index b146810a8aec..1a3c2d50b209 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -525,6 +525,8 @@ export const PullRequestListEntry = Schema.Struct({ deletions: NonNegativeInt, createdAt: IsoDateTime, updatedAt: IsoDateTime, + /** Server epoch milliseconds when the provider read started; preserved on cache hits. */ + observedAt: Schema.optional(Schema.Finite), viewerReviewRequested: Schema.Boolean, labels: Schema.Array(PullRequestLabel), /** Absent where the host does not summarise its reviews, which is every host but GitHub. */ @@ -736,6 +738,8 @@ export const PullRequestSummary = Schema.Struct({ closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, + /** Server epoch milliseconds when the provider read started; preserved on cache hits. */ + observedAt: Schema.optional(Schema.Finite), author: Schema.optional(Schema.NullOr(PullRequestActor)), additions: Schema.optional(NonNegativeInt), deletions: Schema.optional(NonNegativeInt), @@ -841,6 +845,8 @@ export const PullRequestDetail = Schema.Struct({ baseBranch: TrimmedNonEmptyString, createdAt: IsoDateTime, updatedAt: IsoDateTime, + /** Server epoch milliseconds when the provider read started; preserved on cache hits. */ + observedAt: Schema.optional(Schema.Finite), mergedAt: Schema.NullOr(IsoDateTime), closedAt: Schema.NullOr(IsoDateTime), reviewers: Schema.Array(PullRequestActor), From 6a699f0f2fbd8847d7ec2df8d9245ce8c2eb8707 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:25:46 -0700 Subject: [PATCH 017/164] fix(web): stabilize pull request loading layout (#12721) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../pullRequest/PullRequestCopyableCode.tsx | 66 +++ .../pullRequest/PullRequestDetailPanel.tsx | 113 ++--- .../pullRequest/PullRequestGhosts.tsx | 390 ++++++++++++------ .../pullRequest/PullRequestSummaryTab.tsx | 2 +- .../pullRequestDetail.logic.test.ts | 46 +++ .../pullRequest/pullRequestDetail.logic.ts | 16 + 6 files changed, 419 insertions(+), 214 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx new file mode 100644 index 000000000000..4c9d14b53734 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx @@ -0,0 +1,66 @@ +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function PullRequestCopyableCode({ + value, + target, + copyLabel, + copiedLabel, + className, + tooltipSide = "top", + onError, +}: { + readonly value: string; + readonly target: string; + readonly copyLabel: string; + readonly copiedLabel: string; + readonly className?: string; + readonly tooltipSide?: "top" | "bottom"; + readonly onError?: (error: Error) => void; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target, + timeout: 1600, + ...(onError ? { onError } : {}), + }); + return ( + + copyToClipboard(value)} + /> + } + > + + {value} + + + + + {`${isCopied ? "Copied" : copyLabel}: ${value}`} + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index c605409b1801..98d172f53eb5 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -121,6 +121,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; +import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; @@ -140,6 +141,7 @@ import { handoffPrompt, handoffReviewComments, latestPullRequestReviewOutcomes, + loadingPullRequestCheckoutCommand, isStackedPullRequestBase, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, @@ -326,68 +328,6 @@ const openNumberContextMenu = ( }); }; -function PullRequestCopyableCode({ - value, - target, - copyLabel, - copiedLabel, - className, - tooltipSide = "top", - onError, -}: { - readonly value: string; - readonly target: string; - readonly copyLabel: string; - readonly copiedLabel: string; - readonly className?: string; - readonly tooltipSide?: "top" | "bottom"; - readonly onError?: (error: Error) => void; -}) { - const { copyToClipboard, isCopied } = useCopyToClipboard({ - target, - timeout: 1600, - ...(onError ? { onError } : {}), - }); - return ( - - copyToClipboard(value)} - /> - } - > - - {value} - - - - - {`${isCopied ? "Copied" : copyLabel}: ${value}`} - - - ); -} - /** * The stale-branch warning, said beside the branch it is about rather than as a bar of its own. * The banner this replaces held a row of chrome open across the top of every pull request that @@ -804,15 +744,22 @@ export function PullRequestDetailPanel({ repositoryUrl !== null ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString() : null; - const checkoutCommand = detail + const checkoutCommand = handoffSummary ? pullRequestCheckoutCommand( - detail.provider, - detail.number, - detail.headBranch, - detail.headRepositoryNameWithOwner, - repositoryUrl, + handoffSummary.provider, + handoffSummary.number, + handoffSummary.headBranch, + detail?.headRepositoryNameWithOwner, + changeRequestRepositoryUrl(handoffSummary.url), ) - : null; + : loadingPullRequestCheckoutCommand(reference, repositoryIdentity); + const onCheckoutCommandError = useCallback((error: Error) => { + toastManager.add({ + type: "error", + title: "Could not copy checkout command", + description: error.message, + }); + }, []); const branchRefsQuery = useEnvironmentQuery( detail === null ? null @@ -1475,8 +1422,9 @@ export function PullRequestDetailPanel({ // Out of date with the base, and still cleanly mergeable — the one pairing an update button // exists for. Null everywhere else, including hosts that cannot compare at all. const freshness = detail === null ? null : resolveBaseFreshness(detail); - // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden - // until the detail arrives, so the loading ghost is the panel's only unfinished UI. + // A host that cannot produce a patch has no Code tab to open. While detail is loading the ghost + // uses this optimistic tab set to reserve the same chrome; a host without a patch removes Code + // when its capabilities arrive. const visibleTabs = TABS.filter( (item) => item.value !== "code" || detail === null || detail.capabilities.diff, ); @@ -1664,6 +1612,13 @@ export function PullRequestDetailPanel({ @@ -2371,7 +2326,7 @@ export function PullRequestDetailPanel({ {detail ? (
{titleDraft === null ? ( -
+
)} -
+
- toastManager.add({ - type: "error", - title: "Could not copy checkout command", - description: error.message, - }) - } + onError={onCheckoutCommandError} /> ) : null}
-
+
- + {detail.changedFiles.toLocaleString()}{" "} {detail.changedFiles === 1 ? "file" : "files"} diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index c82129df13b1..90f88da93668 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -8,16 +8,31 @@ * number of bars costs one opacity animation. */ import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; -import { ArrowLeftIcon } from "lucide-react"; +import { + ArrowLeftIcon, + ChevronRightIcon, + EllipsisIcon, + ExternalLinkIcon, + FileDiffIcon, + PanelRightIcon, + TagIcon, + UserPlusIcon, + UsersIcon, +} from "lucide-react"; import type { ReactNode } from "react"; +import { readLocalApi } from "~/localApi"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { Button, InlineButton } from "../ui/button"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { pullRequestLabelColor } from "./pullRequestList.logic"; import { PullRequestActorLabel, PullRequestDiffStat, + PullRequestMetaLine, pullRequestChecksStatePresentation, resolvePullRequestState, } from "./pullRequestPresentation"; @@ -29,6 +44,11 @@ function GhostBar({ className }: { className?: string | undefined }) { /** Widths cycle rather than randomize, so the ghost renders the same on every pass. */ const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"]; const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"]; +const DEFAULT_DETAIL_TABS = [ + { value: "summary", label: "Summary" }, + { value: "timeline", label: "Timeline" }, + { value: "code", label: "Code" }, +] as const; /** Rows in the list's own grid — glyph, title over meta, time over diffstat. */ export function PullRequestListGhost({ @@ -77,10 +97,25 @@ export function PullRequestDetailGhost({ seed: entry, summary, actions, + checkoutCommand, + tabs = DEFAULT_DETAIL_TABS, + activeTab, + number, + onBack, + onClose, + onCheckoutError, }: { seed?: PullRequestListEntry | null; summary?: PullRequestSummary | null; actions?: ReactNode; + checkoutCommand?: string | null; + tabs?: ReadonlyArray<{ value: string; label: string }>; + /** The panel's current tab, so the highlight does not jump when the detail arrives. */ + activeTab?: string; + number?: number; + onBack?: (() => void) | undefined; + onClose?: (() => void) | undefined; + onCheckoutError?: ((error: Error) => void) | undefined; }) { const seed = summary ? { @@ -100,6 +135,10 @@ export function PullRequestDetailGhost({ seed?.checksState === "failing" || seed?.checksState === "pending" ? pullRequestChecksStatePresentation(seed.checksState) : null; + const checkout = checkoutCommand ?? null; + const changedFiles = summary?.changedFiles ?? null; + const selectedTab = + tabs.find((item) => item.value === activeTab)?.value ?? tabs[0]?.value ?? "summary"; return (
-
-
-
- {seed && statePresentation ? ( +
+
+
+ {onBack ? ( + + ) : null} + {seed ? ( <> - - {seed.repository} - - {seed.repository} + void readLocalApi()?.shell.openExternal(seed.url)} + className={cn( + "font-medium underline-offset-2 hover:underline", + statePresentation?.toneClassName, + )} + aria-label={`Open pull request #${seed.number} on host`} > #{seed.number} - + + ) : ( <> - + #{number ?? "…"} )}
-
- {actions ?? } - -
+
+
+ {actions ?? } + + {onClose ? ( + + ) : null}
-
- {seed ? ( -

{seed.title}

- ) : ( - - )} -
- {seed ? ( - <> - - - updated {formatRelativeTimeLabel(seed.updatedAt)} - - - ) : ( - <> - - - - )} -
-
- {seed ? ( - - {seed.baseBranch} - - {seed.headBranch} - - ) : ( - <> - - - - - )} -
- +
+
+
{seed ? ( - +
+

{seed.title}

+
) : ( - +
+ +
)} +
+ {seed ? ( + + + updated {formatRelativeTimeLabel(seed.updatedAt)} + + ) : ( + + + + + + + + )} + {checkout ? ( + + ) : null} +
+ +
+ + {seed ? ( + + {seed.baseBranch} + + ) : ( + + + + )} + + {seed ? ( + + ) : ( + + )} + + + + + {changedFiles === null ? ( + + ) : ( + `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` + )} + + {seed ? ( + + ) : ( + + )} + +
-
-
- - - -
+
+
-
-
-
- - -
-
- - - -
-
-
-
- - -
-
- {seed?.labels ? ( - seed.labels.slice(0, 3).map((label) => { - const color = pullRequestLabelColor(label.color); - return ( - - - {label.name} - - ); - }) - ) : ( - <> - - - - )} +
+
+
+ + + Reviewers + + + + +
-
-
-
- - +
+ + + Labels + + + {entry?.labels ? ( + entry.labels.length > 0 ? ( + entry.labels.map((label) => { + const color = pullRequestLabelColor(label.color); + return ( + + + {label.name} + + ); + }) + ) : ( + None + ) + ) : ( + <> + + + + )} + +
-
-
-
- - +
+
+
+ Description + +
-
- - - - +
+ +
+ + + + +
diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b2096c87ded9..4470e6c307c0 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -275,7 +275,7 @@ function MetaRow({ children: ReactNode; }) { return ( -
+
{icon} {label} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 5e75851f08ad..ac0c9d01d17e 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -7,7 +7,9 @@ import { type PullRequestComment, type PullRequestDetail, type PullRequestDetailView, + type PullRequestRef, type PullRequestReviewThread, + type RepositoryIdentity, type ThreadPullRequestLink, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -27,6 +29,7 @@ import { stripPullRequestHandoffReferences, isPullRequestVerdictStale, isStackedPullRequestBase, + loadingPullRequestCheckoutCommand, pullRequestPanelContext, latestPullRequestReviewOutcomes, newestPullRequestCommitAt, @@ -94,6 +97,49 @@ describe("pull request checkout commands", () => { "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD", ); }); + + const reference = (host?: string): PullRequestRef => ({ + projectId: ProjectId.make("project-1"), + ...(host === undefined ? {} : { host }), + repository: "acme/web", + number: 42, + }); + const identity = (provider: string, canonicalKey: string): RepositoryIdentity => ({ + canonicalKey, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:acme/web.git", + }, + provider, + }); + + it("uses a public host when no repository identity is available", () => { + expect(loadingPullRequestCheckoutCommand(reference("github.com"), undefined)).toBe( + "gh pr checkout 42", + ); + expect(loadingPullRequestCheckoutCommand(reference("gitlab.com"), null)).toBe( + "glab mr checkout 42", + ); + }); + + it("uses a matching enterprise identity and rejects an explicit host mismatch", () => { + const enterprise = identity("github", "github.example.test/acme/web"); + expect(loadingPullRequestCheckoutCommand(reference("github.example.test"), enterprise)).toBe( + "gh pr checkout 42", + ); + expect(loadingPullRequestCheckoutCommand(reference("github.com"), enterprise)).toBeNull(); + }); + + it("does not infer a number-only command without a trusted provider", () => { + expect(loadingPullRequestCheckoutCommand(reference(), undefined)).toBeNull(); + expect( + loadingPullRequestCheckoutCommand( + reference("github.com"), + identity("gitlab", "gitlab.com/acme/web"), + ), + ).toBeNull(); + }); }); const TIMELINE_SOURCE: Pick< diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 733e314b770f..2ec4863072dc 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -140,6 +140,22 @@ export function pullRequestCheckoutCommand( } } +/** Build a checkout command from identity metadata while the detail request is still pending. */ +export function loadingPullRequestCheckoutCommand( + reference: PullRequestRef, + identity: RepositoryIdentity | null | undefined, +): string | null { + const host = reference.host?.trim().toLowerCase(); + const provider = + identity?.provider ?? + (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null); + if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null; + if (identity?.provider !== undefined && host && pullRequestHostOf(identity, provider) !== host) { + return null; + } + return pullRequestCheckoutCommand(provider, reference.number, ""); +} + /** Activity changes only when the same host resource reports a newer revision. */ export function shouldRefreshPullRequestActivity( previous: { readonly key: string; readonly updatedAt: string } | null, From 55c24273a025874cdb8a96a25ee0c59eeb4e3051 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 13:48:27 -0700 Subject: [PATCH 018/164] fix(desktop): align preview recording cursors and show input feedback (#12779) --- apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/preview.ts | 25 +- apps/desktop/src/preload.ts | 10 + apps/desktop/src/preview/GuestProtocol.ts | 5 + apps/desktop/src/preview/Manager.test.ts | 352 ++++++++++++++---- apps/desktop/src/preview/Manager.ts | 154 +++++++- apps/desktop/src/preview/PickPreload.ts | 82 ++++ apps/desktop/src/preview/RecordingCursor.ts | 197 ++++++++++ .../src/preview/RecordingInput.test.ts | 51 +++ apps/desktop/src/preview/RecordingInput.ts | 58 +++ apps/web/src/browser/browserRecording.ts | 25 +- .../src/browser/recordingCompositor.test.ts | 179 +++++++++ apps/web/src/browser/recordingCompositor.ts | 190 ++++++++++ .../components/preview/AgentBrowserCursor.tsx | 8 +- .../components/preview/PreviewView.test.tsx | 43 ++- .../src/components/preview/PreviewView.tsx | 11 +- .../settings/IntegrationsSettings.tsx | 39 ++ .../settings/SettingsPanels.logic.test.ts | 4 + .../settings/SettingsPanels.logic.ts | 4 + .../components/settings/SettingsPanels.tsx | 4 + .../src/components/settings/settingsSearch.ts | 12 + packages/contracts/src/ipc.ts | 25 ++ packages/contracts/src/settings.test.ts | 17 + packages/contracts/src/settings.ts | 8 + 24 files changed, 1398 insertions(+), 107 deletions(-) create mode 100644 apps/desktop/src/preview/RecordingCursor.ts create mode 100644 apps/desktop/src/preview/RecordingInput.test.ts create mode 100644 apps/desktop/src/preview/RecordingInput.ts create mode 100644 apps/web/src/browser/recordingCompositor.test.ts create mode 100644 apps/web/src/browser/recordingCompositor.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 226793657848..f49b6dbd968e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -113,3 +113,5 @@ export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; + +export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5fb7eff99fc6..36cf7c9abe15 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -30,11 +30,13 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -50,6 +52,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeRecordingFrames((frame) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), ); + yield* manager.subscribeRecordingInputs((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), + ); yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); @@ -180,11 +185,21 @@ export const cancelPickElement = tabMethod( "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), ); -export const startRecording = tabMethod( - IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - "desktop.ipc.preview.startRecording", - (manager, tabId) => manager.startRecording(tabId), -); +export const startRecording = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + const store = yield* DesktopClientSettings.DesktopClientSettings; + const settings = yield* store.get; + const options = Option.map(settings, (value) => ({ + showKeyPresses: value.browserRecordingShowKeyPresses, + showMousePresses: value.browserRecordingShowMousePresses, + })); + yield* manager.startRecording(tabId, Option.getOrUndefined(options)); + }), +}); export const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 885b31a64d6b..7256f610fbeb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,7 @@ import type { DesktopBridge, DesktopPreviewPointerEvent, + DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, @@ -327,6 +328,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), }, recording: { + onInput: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => { + if (typeof event !== "object" || event === null) return; + listener(event as DesktopPreviewRecordingInputEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); + }, startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), stopScreencast: (tabId) => diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index e63597b71efc..1a73bb30f29e 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,3 +5,8 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; +export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; +export const RECORDING_KEY_CHANNEL = "preview:recording-key"; +export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; +export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7b76a1b8003a..304efeafefe9 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,7 +1,10 @@ import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; -import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; +import type { + DesktopPreviewRecordingFrame, + DesktopPreviewRecordingInputEvent, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -2885,6 +2888,165 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "restores the native cursor when recording startup fails, then allows a retry", + () => + withManager((manager) => + Effect.gen(function* () { + const host = makeTestHostWebContents(); + host.executeJavaScript.mockResolvedValueOnce(false); + let cursorActive = false; + const cursorAtCapture: boolean[] = []; + const contents = Object.assign( + makeTestPreviewWebContents( + async () => { + cursorAtCapture.push(cursorActive); + return { + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + }; + }, + 42, + host, + ), + { + send: (channel: string, active: unknown) => { + if (channel === "preview:recording-cursor") cursorActive = active === true; + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_cursor"); + yield* manager.registerWebview("tab_cursor", 42); + const failed = yield* Effect.exit(manager.startRecording("tab_cursor")); + expect(Exit.isFailure(failed)).toBe(true); + expect(cursorAtCapture).toEqual([true]); + expect(cursorActive).toBe(false); + + yield* manager.startRecording("tab_cursor"); + expect(cursorAtCapture).toEqual([true, true]); + expect(cursorActive).toBe(true); + yield* manager.stopRecording("tab_cursor"); + expect(cursorActive).toBe(false); + }), + ), + ); + + effectIt.effect("restores the recording cursor after navigation only while recording", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + let cursorActive = false; + let cursorUpdated: (() => void) | undefined; + let inputOptions: unknown; + const options = { showKeyPresses: true, showMousePresses: false }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + on: (event: string, listener: () => void) => listeners.set(event, listener), + send: (channel: string, active: unknown, recordingOptions: unknown) => { + if (channel !== "preview:recording-cursor") return; + cursorActive = active === true; + inputOptions = recordingOptions; + cursorUpdated?.(); + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_cursor_reload"); + yield* manager.registerWebview("tab_cursor_reload", 42); + yield* manager.startRecording("tab_cursor_reload", options); + for (const recording of [true, false]) { + if (!recording) yield* manager.stopRecording("tab_cursor_reload"); + // A new document has lost the previous preload's cursor overlay. + cursorActive = false; + const restored = new Promise((resolve) => { + cursorUpdated = resolve; + }); + listeners.get("dom-ready")?.(); + yield* Effect.promise(() => restored); + cursorUpdated = undefined; + expect(cursorActive).toBe(recording); + expect(inputOptions).toEqual(recording ? options : undefined); + } + }), + ), + ); + + effectIt.effect("gates recording decorations and isolates failed subscribers", () => + withManager((manager) => + Effect.gen(function* () { + const callbacks = new Map< + string, + (event: unknown, input: unknown) => Fiber.Fiber | undefined + >(); + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + ipc: { + on: ( + channel: string, + callback: (event: unknown, input: unknown) => Fiber.Fiber | undefined, + ) => callbacks.set(channel, callback), + off: vi.fn(), + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_recording_input"); + yield* manager.registerWebview("tab_recording_input", 42); + const received: DesktopPreviewRecordingInputEvent[] = []; + yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable")); + yield* manager.subscribeRecordingInputs((event) => + Effect.sync(() => { + received.push(event); + }), + ); + const send = (input: unknown) => + Effect.gen(function* () { + const fiber = callbacks.get("preview:recording-input")?.(null, input); + if (fiber) yield* Fiber.join(fiber); + }); + const key = { type: "key", label: "⌘C", held: true, width: 800 }; + const pointer = { + type: "pointer", + phase: "down", + x: 120, + y: 80, + width: 800, + height: 600, + }; + yield* send(key); + expect(received).toEqual([]); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: true, + showMousePresses: false, + }); + yield* send(key); + yield* send(pointer); + yield* send({ ...key, width: 0 }); + expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]); + yield* manager.stopRecording("tab_recording_input"); + yield* send(key); + expect(received).toHaveLength(1); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: false, + showMousePresses: true, + }); + yield* send(key); + yield* send(pointer); + expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer }); + expect(received).toHaveLength(2); + }), + ), + ); + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { @@ -3853,88 +4015,108 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("emits the resolved pointer target before dispatching an automation click", () => - withManager((manager) => - Effect.gen(function* () { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const activity: string[] = []; - const sendCommand = vi.fn(async (method: string, params?: Record) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, - }, - }; - } - if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { - activity.push("mousePressed"); - humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, + effectIt.effect( + "records the resolved pointer target before dispatching an automation click", + () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + hostWebContents: makeTestHostWebContents(), + capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), + setBackgroundThrottling: vi.fn(), + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), - }, - } as never); - - yield* manager.subscribePointerEvents((event) => - Effect.sync(() => { - activity.push(event.phase); - }), - ); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const click = yield* manager - .automationClick("tab_1", { x: 120, y: 80 }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(200); - yield* Fiber.join(click); + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); - expect(activity).toEqual(["move", "click", "mousePressed"]); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - }), - ), + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + yield* manager.startRecording("tab_1"); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect( + webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-controller") + .map(([, controller]) => controller), + ).toEqual(["agent", "none"]); + + const recordedPointer = webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-pointer") + .map(([, event]) => event); + expect(recordedPointer).toEqual([ + expect.objectContaining({ phase: "move", x: 120, y: 80 }), + expect.objectContaining({ phase: "click", x: 120, y: 80 }), + ]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + yield* manager.stopRecording("tab_1"); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }), + ), ); effectIt.effect("types in background webviews and enables native key input", () => @@ -4230,6 +4412,9 @@ describe("PreviewManager", () => { }); fromId.mockReturnValue({ id: 42, + hostWebContents: makeTestHostWebContents(), + capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), + setBackgroundThrottling: vi.fn(), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -4263,6 +4448,7 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); + yield* manager.startRecording("tab_1"); const click = yield* manager .automationClick("tab_1", { x: 120, y: 80 }) @@ -4270,6 +4456,12 @@ describe("PreviewManager", () => { yield* TestClock.adjust(200); const exit = yield* Fiber.await(click); expect(Exit.isFailure(exit)).toBe(true); + expect( + webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-controller") + .map(([, controller]) => controller), + ).toEqual(["agent", "human", "none"]); + yield* manager.stopRecording("tab_1"); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a2e34fe54736..468c47065315 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -6,7 +6,10 @@ * here). Single layer-scoped browser session partition. */ import * as NodeCrypto from "node:crypto"; -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import { + DesktopPreviewRecordingInputSchema, + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, +} from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, @@ -18,6 +21,7 @@ import type { PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, + DesktopPreviewRecordingInputEvent, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, @@ -71,6 +75,11 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + RECORDING_CURSOR_CHANNEL, + RECORDING_POINTER_CHANNEL, + RECORDING_KEY_CHANNEL, + RECORDING_INPUT_CHANNEL, + RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -81,6 +90,7 @@ import { previewAutomationEditingCommandExpression, } from "./PreviewKeyboard.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; +import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -437,6 +447,7 @@ interface ManagedListeners { type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { + readonly recordingInputOptions?: RecordingInputOptions; readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; readonly unthrottledWebContentsIds: ReadonlySet; @@ -485,6 +496,10 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } +const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema); + +type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect; + type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { @@ -636,6 +651,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); + const recordingInputListenersRef = yield* Ref.make>( + new Set(), + ); const recordingFrameListenersRef = yield* Ref.make>( new Set(), ); @@ -821,6 +839,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return Effect.succeed([undefined, sessions] as const); } return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.tap(() => + Effect.gen(function* () { + if (!current.consumers.has("recording")) return; + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => + wc.send( + RECORDING_CURSOR_CHANNEL, + true, + current.recordingInputOptions, + tab?.controller, + ), + ); + }), + ), Effect.map( () => [ @@ -846,6 +878,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || !current.consumers.has(consumer)) { return [undefined, sessions] as const; } + if (consumer === "recording") { + yield* Effect.forEach(current.unthrottledWebContentsIds, (id) => + attempt({ operation: "recording.cursor", tabId, webContentsId: id }, () => { + const contents = webContents.fromId(id); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CURSOR_CHANNEL, false); + }).pipe(Effect.ignore), + ); + } const consumers = new Set(current.consumers); consumers.delete(consumer); if (consumers.size > 0) { @@ -896,7 +937,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-frame" | "pointer-event", + eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event", tabId: string, delivery: () => Effect.Effect, ) => @@ -933,6 +974,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, + humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { @@ -950,7 +992,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // can commit between the modify above and here, and republishing this // snapshot would roll the UI back to a value that writer will not send // again because it suppresses unchanged audibility. - if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + if (Option.isSome(next)) { + if (patch.controller !== undefined && next.value.webContentsId != null) { + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + const webContentsId = next.value.webContentsId; + if (capture?.consumers.has("recording")) { + yield* attempt({ operation: "recording.controller", tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); + }).pipe(Effect.ignore); + } + } + yield* emitIfCurrent(tabId, next.value); + } }); /** @@ -1728,6 +1783,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const sync = () => runFork(syncState(true)); const syncNavigation = () => runFork(syncState(false, true)); const syncInPageNavigation = () => runFork(syncState(false)); + const restoreRecordingCursor = () => + runFork( + Effect.gen(function* () { + const session = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (!wc.isDestroyed()) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + wc.send( + RECORDING_CURSOR_CHANNEL, + session?.consumers.has("recording") ?? false, + session?.recordingInputOptions, + tab?.controller, + ); + } + }), + ); const navigationStarted = ( event: Electron.Event, ) => { @@ -1860,13 +1930,37 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); }), ); - yield* update(tabId, { controller: "human" }); + yield* update( + tabId, + { controller: "human" }, + isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer" + ? { x: rawSignal.x, y: rawSignal.y } + : undefined, + ); yield* Effect.sleep(750); const tabs = yield* SynchronizedRef.get(tabsRef); if (tabs.get(tabId)?.controller === "human") { yield* update(tabId, { controller: "none" }); } }); + const recordingInput = (_event: unknown, input: unknown) => { + if (!isRecordingInput(input)) return; + return runFork( + Effect.gen(function* () { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (tab?.webContentsId !== wc.id || !capture?.consumers.has("recording")) return; + if (input.type === "key" && !capture.recordingInputOptions?.showKeyPresses) return; + if (input.type === "pointer" && !capture.recordingInputOptions?.showMousePresses) return; + const listeners = yield* Ref.get(recordingInputListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("recording-input", tabId, () => listener({ tabId, input })), + { discard: true }, + ); + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1928,11 +2022,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); + wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); @@ -1948,9 +2044,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); + wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler((details) => { if (previewWindowOpenAction(details) === "popup") { @@ -3405,7 +3503,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }; - const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( + tabId: string, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + ) { if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } @@ -3413,11 +3514,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, Effect.gen(function* () { yield* startFrameCapture(tabId, "recording"); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + const current = copy.get(tabId); + if (current) copy.set(tabId, { ...current, recordingInputOptions: options }); + }), + ); const wc = yield* requireWebContents(tabId); const requestWebContents = wc.hostWebContents; if (requestWebContents === null) { return yield* new PreviewMainWindowClosedError({ tabId }); } + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => + wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), + ); yield* attemptPromise( { operation: "recording.warmSource", @@ -3720,6 +3831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( event: DesktopPreviewPointerEvent, ) { + const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(event.tabId); + const tab = (yield* SynchronizedRef.get(tabsRef)).get(event.tabId); + const webContentsId = tab?.webContentsId; + if (recording?.consumers.has("recording") && webContentsId != null) { + yield* attempt({ operation: "recording.pointer", tabId: event.tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event); + }); + } const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, @@ -4110,6 +4230,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const keySequence = makePreviewAutomationNativeKeySequence(input, { isMac: hostPlatform === "darwin", }); + const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (recording?.consumers.has("recording") && recording.recordingInputOptions?.showKeyPresses) { + yield* attempt({ operation: "recording.key", tabId, webContentsId: wc.id }, () => + wc.send(RECORDING_KEY_CHANNEL, { + key: keySequence.signal.key || input.key, + metaKey: input.modifiers?.includes("Meta") ?? false, + ctrlKey: input.modifiers?.includes("Control") ?? false, + altKey: input.modifiers?.includes("Alt") ?? false, + shiftKey: input.modifiers?.includes("Shift") ?? false, + }), + ); + } // CDP keyboard dispatch follows the embedder's focused renderer, and // WebContents.focus() is a no-op for webview guests. Native input targets // this guest's widget directly, so Enter cannot submit the host composer. @@ -4470,6 +4602,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(expectedAgentInputsRef, new Map()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(recordingFrameListenersRef, new Set()), + Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, ); @@ -4513,6 +4646,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), + subscribeRecordingInputs: (listener: RecordingInputListener) => + subscribe(recordingInputListenersRef, listener), subscribeRecordingFrames: (listener: RecordingFrameListener) => subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -4877,7 +5012,10 @@ export class PreviewManager extends Context.Service< readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; - readonly startRecording: (tabId: string) => Effect.Effect; + readonly startRecording: ( + tabId: string, + options?: RecordingInputOptions, + ) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, @@ -4918,6 +5056,9 @@ export class PreviewManager extends Context.Service< readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; + readonly subscribeRecordingInputs: ( + listener: RecordingInputListener, + ) => Effect.Effect; readonly subscribeRecordingFrames: ( listener: RecordingFrameListener, ) => Effect.Effect; @@ -5011,6 +5152,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeRecordingFrames: operations.subscribeRecordingFrames, + subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 6155c4119ec8..78351e9d79c3 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -16,6 +16,8 @@ import type { import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { installRecordingCursor } from "./RecordingCursor.ts"; +import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, @@ -23,6 +25,11 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + RECORDING_CURSOR_CHANNEL, + RECORDING_POINTER_CHANNEL, + RECORDING_KEY_CHANNEL, + RECORDING_INPUT_CHANNEL, + RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -35,6 +42,80 @@ const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; +let recordingCursor: ReturnType | null = null; +ipcRenderer.on( + RECORDING_CURSOR_CHANNEL, + (_event, active: unknown, inputOptions: unknown, controller: unknown) => { + if (active === true) { + const options = + typeof inputOptions === "object" && inputOptions !== null + ? { + showKeyPresses: + "showKeyPresses" in inputOptions && inputOptions.showKeyPresses === true, + showMousePresses: + "showMousePresses" in inputOptions && inputOptions.showMousePresses === true, + } + : DEFAULT_RECORDING_INPUT_OPTIONS; + recordingCursor ??= installRecordingCursor(document, window, options, (input) => + ipcRenderer.send(RECORDING_INPUT_CHANNEL, input), + ); + recordingCursor.setTheme(annotationTheme); + if (controller === "agent" || controller === "human" || controller === "none") + recordingCursor.setController(controller); + } else { + recordingCursor?.dispose(); + recordingCursor = null; + } + }, +); +ipcRenderer.on(RECORDING_CONTROLLER_CHANNEL, (_event, controller: unknown, point: unknown) => { + const humanPoint = + typeof point === "object" && + point !== null && + "x" in point && + typeof point.x === "number" && + Number.isFinite(point.x) && + "y" in point && + typeof point.y === "number" && + Number.isFinite(point.y) + ? { x: point.x, y: point.y } + : undefined; + if (controller === "agent" || controller === "human" || controller === "none") + recordingCursor?.setController(controller, humanPoint); +}); +ipcRenderer.on(RECORDING_KEY_CHANNEL, (_event, input: unknown) => { + if ( + typeof input !== "object" || + input === null || + !("key" in input) || + typeof input.key !== "string" + ) + return; + recordingCursor?.keyPress({ + key: input.key, + metaKey: "metaKey" in input && input.metaKey === true, + ctrlKey: "ctrlKey" in input && input.ctrlKey === true, + altKey: "altKey" in input && input.altKey === true, + shiftKey: "shiftKey" in input && input.shiftKey === true, + }); +}); +ipcRenderer.on(RECORDING_POINTER_CHANNEL, (_event, point: unknown) => { + if ( + typeof point === "object" && + point !== null && + "x" in point && + typeof point.x === "number" && + Number.isFinite(point.x) && + "y" in point && + typeof point.y === "number" && + Number.isFinite(point.y) + ) + recordingCursor?.move( + { x: point.x, y: point.y }, + "phase" in point && point.phase === "click" ? "click" : "move", + ); +}); + type AnnotationTool = "select" | "marquee" | "draw" | "erase"; interface SelectedElement { @@ -1361,6 +1442,7 @@ ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme }); ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { annotationTheme = theme; + recordingCursor?.setTheme(theme); activeSession?.applyTheme(theme); }); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts new file mode 100644 index 000000000000..70b0a43be43a --- /dev/null +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -0,0 +1,197 @@ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewRecordingInput, +} from "@t3tools/contracts"; + +import { + DEFAULT_RECORDING_INPUT_OPTIONS, + recordingKeyLabel, + recordingKeysAreSensitive, + type RecordingInputOptions, + type RecordingKeyPress, +} from "./RecordingInput.ts"; + +/** + * Chromium's capture cursor uses native window bounds, which do not follow a + * webview's CSS placement or scale. Draw it in the guest's coordinate space + * while recording, and make the native cursor transparent to avoid two cursors. + */ +export function installRecordingCursor( + document: Document, + window: Window, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + emit: (input: DesktopPreviewRecordingInput) => void = () => {}, +) { + const style = document.createElement("style"); + style.textContent = + "html, html * { cursor: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }"; + const cursor = document.createElement("div"); + cursor.setAttribute("aria-hidden", "true"); + cursor.setAttribute("data-t3code-recording-cursor", ""); + cursor.style.cssText = + "position:fixed;left:0;top:0;width:16px;height:24px;pointer-events:none;z-index:2147483647;display:none;"; + cursor.innerHTML = + ''; + const agentCursor = document.createElement("div"); + agentCursor.setAttribute("aria-hidden", "true"); + agentCursor.setAttribute("data-t3code-recording-agent-cursor", ""); + agentCursor.style.cssText = + "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;"; + // Match the MousePointer2 icon used by the live AgentBrowserCursor. + agentCursor.innerHTML = + ''; + document.documentElement.append(style, cursor, agentCursor); + let controller: "human" | "agent" | "none" = "none"; + let humanPoint: { readonly x: number; readonly y: number } | null = null; + const drawHuman = () => { + if (!humanPoint) return; + cursor.style.transform = `translate(${humanPoint.x}px, ${humanPoint.y}px)`; + cursor.style.display = "block"; + }; + let agentActive = false; + let agentTimer: number | undefined; + const setController = ( + next: typeof controller, + point?: { readonly x: number; readonly y: number }, + ) => { + if (point) humanPoint = point; + controller = next; + if (next === "agent") cursor.style.display = "none"; + if (next === "human") drawHuman(); + if (!agentActive) agentCursor.style.opacity = next === "human" ? "0.18" : "0.35"; + }; + const setTheme = ( + theme: Pick | null, + ) => { + agentCursor.style.setProperty("--recording-cursor-primary", theme?.primary ?? "#2563eb"); + agentCursor.style.setProperty("--recording-cursor-background", theme?.background ?? "white"); + }; + let lastKeyLabel: string | null = null; + let pointerHeld = false; + let pointerFrame: number | undefined; + let pendingPointer: DesktopPreviewRecordingInput | undefined; + const cancelPendingPointer = () => { + if (pointerFrame !== undefined) window.cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pendingPointer = undefined; + }; + const keyPress = (input: RecordingKeyPress, held = false) => { + if (!options.showKeyPresses) return; + const label = recordingKeysAreSensitive(document) + ? null + : recordingKeyLabel(input, /Mac/.test(window.navigator.platform)); + lastKeyLabel = label; + emit({ type: "key", label, held, width: window.innerWidth }); + }; + const pointer = ( + point: { readonly x: number; readonly y: number }, + phase: "move" | "down" | "up" | "click", + ) => { + if (!options.showMousePresses || (phase === "move" && !pointerHeld)) return; + if (phase === "down") pointerHeld = true; + if (phase === "up") pointerHeld = false; + const input: DesktopPreviewRecordingInput = { + type: "pointer", + phase, + ...point, + width: window.innerWidth, + height: window.innerHeight, + }; + if (phase === "move") { + pendingPointer = input; + pointerFrame ??= window.requestAnimationFrame(() => { + pointerFrame = undefined; + if (pendingPointer) emit(pendingPointer); + pendingPointer = undefined; + }); + } else { + cancelPendingPointer(); + emit(input); + } + }; + const move = ( + point: { readonly x: number; readonly y: number }, + phase: "move" | "click" = "move", + ) => { + agentCursor.style.transform = `translate(${point.x}px, ${point.y}px)`; + agentCursor.style.display = "block"; + agentCursor.style.opacity = "1"; + agentActive = true; + window.clearTimeout(agentTimer); + agentTimer = window.setTimeout(() => { + agentActive = false; + agentCursor.style.opacity = controller === "human" ? "0.18" : "0.35"; + }, 700); + pointer(point, phase); + }; + const moveHuman = (point: { readonly x: number; readonly y: number }) => { + if (controller === "agent") return; + humanPoint = point; + drawHuman(); + }; + const pointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + const point = { x: event.clientX, y: event.clientY }; + moveHuman(point); + pointer(point, "move"); + }; + const pointerDown = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + moveHuman({ x: event.clientX, y: event.clientY }); + pointer({ x: event.clientX, y: event.clientY }, "down"); + }; + const pointerUp = (event: PointerEvent) => { + if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + }; + const keyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.repeat) return; + keyPress(event, true); + }; + const keyUp = () => { + if (!options.showKeyPresses) return; + emit({ + type: "key", + label: recordingKeysAreSensitive(document) ? null : lastKeyLabel, + held: false, + width: window.innerWidth, + }); + }; + const hide = () => { + cursor.style.display = "none"; + pointerHeld = false; + cancelPendingPointer(); + if (options.showKeyPresses || options.showMousePresses) emit({ type: "clear" }); + }; + const leave = (event: PointerEvent) => { + if (event.relatedTarget === null) hide(); + }; + window.addEventListener("pointermove", pointerMove, true); + window.addEventListener("pointerdown", pointerDown, true); + window.addEventListener("pointerup", pointerUp, true); + window.addEventListener("pointercancel", pointerUp, true); + window.addEventListener("keydown", keyDown, true); + window.addEventListener("keyup", keyUp, true); + window.addEventListener("pointerout", leave, true); + window.addEventListener("blur", hide); + return { + move, + keyPress, + setController, + setTheme, + dispose: () => { + window.removeEventListener("pointermove", pointerMove, true); + window.removeEventListener("pointerdown", pointerDown, true); + window.removeEventListener("pointerup", pointerUp, true); + window.removeEventListener("pointercancel", pointerUp, true); + window.removeEventListener("keydown", keyDown, true); + window.removeEventListener("keyup", keyUp, true); + window.removeEventListener("pointerout", leave, true); + window.removeEventListener("blur", hide); + cancelPendingPointer(); + window.clearTimeout(agentTimer); + cursor.remove(); + agentCursor.remove(); + style.remove(); + }, + }; +} diff --git a/apps/desktop/src/preview/RecordingInput.test.ts b/apps/desktop/src/preview/RecordingInput.test.ts new file mode 100644 index 000000000000..062685ec2934 --- /dev/null +++ b/apps/desktop/src/preview/RecordingInput.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vite-plus/test"; +import { recordingKeyLabel, recordingKeysAreSensitive } from "./RecordingInput.ts"; + +const key = ( + value: string, + modifiers: Partial<{ + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }> = {}, +) => ({ + key: value, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...modifiers, +}); + +describe("recording key labels", () => { + it("formats macOS and other-platform shortcuts", () => { + expect(recordingKeyLabel(key("c", { metaKey: true }), true)).toBe("⌘C"); + expect(recordingKeyLabel(key("c", { ctrlKey: true }), false)).toBe("Ctrl + C"); + expect(recordingKeyLabel(key("Tab", { altKey: true, shiftKey: true }), true)).toBe("⌥⇧⇥"); + }); + it("shows held modifiers once and labels navigation keys", () => { + expect(recordingKeyLabel(key("Meta", { metaKey: true }), true)).toBe("⌘"); + expect(recordingKeyLabel(key("Shift", { shiftKey: true }), false)).toBe("Shift"); + expect(recordingKeyLabel(key("ArrowLeft"), true)).toBe("←"); + expect(recordingKeyLabel(key(" "), false)).toBe("Space"); + }); + it.each(["Dead", "Unidentified", "Process", ""])("excludes composition key %s", (value) => { + expect(recordingKeyLabel(key(value), true)).toBeNull(); + }); +}); + +describe("recording key privacy", () => { + const field = (type: string) => ({ tagName: "INPUT", getAttribute: () => type }); + const sensitive = (activeElement: unknown) => + recordingKeysAreSensitive({ activeElement } as Document); + it("excludes password fields and their shadow-root focus", () => { + expect(sensitive(field("password"))).toBe(true); + expect(sensitive({ shadowRoot: { activeElement: field("password") } })).toBe(true); + expect(sensitive(field("text"))).toBe(false); + }); + it("excludes iframe focus whose field cannot be inspected", () => { + expect(sensitive({ tagName: "IFRAME" })).toBe(true); + expect(sensitive({ tagName: "SECRET-FIELD" })).toBe(true); + }); +}); diff --git a/apps/desktop/src/preview/RecordingInput.ts b/apps/desktop/src/preview/RecordingInput.ts new file mode 100644 index 000000000000..a69102121d4e --- /dev/null +++ b/apps/desktop/src/preview/RecordingInput.ts @@ -0,0 +1,58 @@ +export interface RecordingInputOptions { + readonly showKeyPresses: boolean; + readonly showMousePresses: boolean; +} + +export const DEFAULT_RECORDING_INPUT_OPTIONS: RecordingInputOptions = { + showKeyPresses: false, + showMousePresses: false, +}; + +export interface RecordingKeyPress { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +/** Formats a single chord without duplicating a modifier pressed on its own. */ +export function recordingKeyLabel(input: RecordingKeyPress, isMac: boolean): string | null { + if (["Dead", "Process", "Unidentified", ""].includes(input.key)) return null; + const modifiers = [ + input.ctrlKey || input.key === "Control" ? (isMac ? "⌃" : "Ctrl") : null, + input.altKey || input.key === "Alt" ? (isMac ? "⌥" : "Alt") : null, + input.shiftKey || input.key === "Shift" ? (isMac ? "⇧" : "Shift") : null, + input.metaKey || input.key === "Meta" ? (isMac ? "⌘" : "Win") : null, + ].filter((value) => value !== null); + const labels: Record = { + Enter: "↵", + Tab: "⇥", + Backspace: "⌫", + Delete: "⌦", + Escape: "Esc", + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + " ": "Space", + Space: "Space", + }; + if (!["Control", "Alt", "Shift", "Meta"].includes(input.key)) { + modifiers.push( + labels[input.key] ?? (input.key.length === 1 ? input.key.toUpperCase() : input.key), + ); + } + return modifiers.join(isMac ? "" : " + "); +} + +/** Unknown iframe or closed-shadow focus is excluded because its field type cannot be checked. */ +export function recordingKeysAreSensitive(document: Document): boolean { + let element = document.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return ( + element?.tagName === "IFRAME" || + element?.tagName.includes("-") === true || + element?.getAttribute("type")?.toLowerCase() === "password" + ); +} diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 5ea10c2b05d5..0bbe4bd7bf18 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -8,6 +8,8 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { createRecordingCompositor } from "./recordingCompositor"; + import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; export class BrowserRecordingUnavailableError extends Schema.TaggedError()( @@ -123,6 +125,7 @@ interface ActiveRecording { releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; + compositor: Awaited>; savedBlob?: Blob; uploadPromise?: Promise; lifecycle: BrowserRecordingLifecycle; @@ -381,6 +384,8 @@ const captureTabMediaStreamWithTimeout = async ( }; const clearActiveRecording = (recording: ActiveRecording): void => { + recording.compositor?.dispose(); + recording.compositor = null; recording.releaseSurfaceActivity?.(); recording.releaseSurfaceActivity = null; if (activeRecordings.get(recording.tabId) !== recording) return; @@ -525,6 +530,7 @@ export async function startBrowserRecording( releaseSurfaceActivity, stream: null, recorder: null, + compositor: null, lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); @@ -534,7 +540,8 @@ export async function startBrowserRecording( clearActiveRecording(recording); throw cause; }); - const frameRate = getClientSettings().browserRecordingFrameRate; + const settings = getClientSettings(); + const frameRate = settings.browserRecordingFrameRate; await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. @@ -614,7 +621,19 @@ export async function startBrowserRecording( let recorder: MediaRecorder; try { - recorder = createMediaRecorder(stream); + recording.compositor = await createRecordingCompositor( + stream, + { + showKeyPresses: settings.browserRecordingShowKeyPresses, + showMousePresses: settings.browserRecordingShowMousePresses, + frameRate, + }, + (listener) => + bridge.recording.onInput((event) => { + if (event.tabId === tabId) listener(event.input); + }), + ); + recorder = createMediaRecorder(recording.compositor?.stream ?? stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -694,6 +713,8 @@ const finalizeBrowserRecording = async ( cause, }); } + recording.compositor?.dispose(); + recording.compositor = null; // Encoding has flushed; release native capture before materializing and saving the file. stopMediaStream(recording.stream); recording.stream = null; diff --git a/apps/web/src/browser/recordingCompositor.test.ts b/apps/web/src/browser/recordingCompositor.test.ts new file mode 100644 index 000000000000..d10e69d24663 --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.test.ts @@ -0,0 +1,179 @@ +import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createRecordingCompositor, RecordingDecorations } from "./recordingCompositor"; + +const primaryColor = "oklch(0.65 0.2 310)"; +vi.mock("./annotationTheme", () => ({ + readPreviewAnnotationTheme: () => ({ primary: "oklch(0.65 0.2 310)" }), +})); + +const options = { showKeyPresses: true, showMousePresses: true, frameRate: 30 }; +const pointer = ( + phase: "move" | "down" | "up" | "click", + x = 100, +): DesktopPreviewRecordingInput => ({ + type: "pointer", + phase, + x, + y: 80, + width: 800, + height: 600, +}); +const context = () => ({ + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + ellipse: vi.fn(), + roundRect: vi.fn(), + fillText: vi.fn(), + drawImage: vi.fn(), + measureText: () => ({ width: 40 }), + globalAlpha: 1, + strokeStyle: "", + fillStyle: "", +}); + +describe("recording decorations", () => { + it("keeps rings aligned through dragging and stops following the cursor after release", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply(pointer("down"), 0); + decorations.apply(pointer("move", 120), 10); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 10); + expect(ctx.ellipse.mock.calls[0]?.slice(0, 4)).toEqual([240, 160, 40, 40]); + expect(ctx.strokeStyle).toBe(primaryColor); + expect(ctx.fillStyle).toBe(primaryColor); + expect(decorations.nextRedraw(10)).toBeNull(); + decorations.apply(pointer("up", 130), 20); + decorations.apply(pointer("move", 300), 30); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 320); + expect(ctx.ellipse.mock.calls[1]?.slice(0, 4)).toEqual([260, 160, 50, 50]); + ctx.ellipse.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 620); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(decorations.nextRedraw(620)).toBeNull(); + }); + + it("pulses agent clicks and clears decorations on blur or navigation", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply(pointer("click"), 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 300); + expect(ctx.ellipse).toHaveBeenCalledOnce(); + decorations.apply({ type: "clear" }, 301); + ctx.ellipse.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 302); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(decorations.nextRedraw(302)).toBeNull(); + }); + + it("holds shortcut badges until release, then expires them even on a static page", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + const key = { type: "key" as const, label: "⌘C", held: true, width: 800 }; + decorations.apply(key, 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5000); + expect(ctx.fillText.mock.calls[0]?.slice(0, 3)).toEqual(["⌘C", 800, 1098]); + decorations.apply({ ...key, held: false }, 5000); + expect(decorations.nextRedraw(5500)).toBe(400); + ctx.fillText.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5900); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it("removes the previous key badge on password focus", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply({ type: "key", label: "A", held: true, width: 800 }, 0); + decorations.apply({ type: "key", label: null, held: true, width: 800 }, 1); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 2); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it("honors independent opt-in flags", () => { + const decorations = new RecordingDecorations( + { ...options, showMousePresses: false }, + primaryColor, + ); + const ctx = context(); + decorations.apply(pointer("down"), 0); + decorations.apply({ type: "key", label: "⌘C", held: true, width: 800 }, 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 1); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(ctx.fillText).toHaveBeenCalledOnce(); + }); +}); + +describe("detached recording compositor", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("keeps native capture when both decorations are off", async () => { + vi.stubGlobal("document", { + createElement: () => { + throw new Error("must not allocate"); + }, + }); + expect( + await createRecordingCompositor( + {} as MediaStream, + { + ...options, + showKeyPresses: false, + showMousePresses: false, + }, + () => { + throw new Error("must not subscribe"); + }, + ), + ).toBeNull(); + }); + + it.each([false, true])( + "releases the detached output on disposal or playback failure (%s)", + async (failPlayback) => { + const ctx = context(); + const stop = vi.fn(); + const unsubscribe = vi.fn(); + const cancelFrame = vi.fn(); + const source = { + getVideoTracks: () => [{ getSettings: () => ({ width: 800, height: 600 }) }], + } as unknown as MediaStream; + const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; + const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream }; + const video = { + muted: false, + playsInline: false, + srcObject: null as MediaStream | null, + readyState: 2, + videoWidth: 800, + videoHeight: 600, + pause: vi.fn(), + play: async () => { + if (failPlayback) throw new Error("play failed"); + }, + requestVideoFrameCallback: () => 1, + cancelVideoFrameCallback: cancelFrame, + }; + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? canvas : video), + }); + vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() }); + const compositor = createRecordingCompositor(source, options, () => unsubscribe); + if (failPlayback) await expect(compositor).rejects.toThrow("play failed"); + else { + const result = await compositor; + expect(result?.stream).toBe(stream); + expect(ctx.drawImage).toHaveBeenCalledOnce(); + result?.dispose(); + result?.dispose(); + } + expect(stop).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(cancelFrame).toHaveBeenCalledWith(1); + expect(video.srcObject).toBeNull(); + }, + ); +}); diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts new file mode 100644 index 000000000000..352154bd9aef --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.ts @@ -0,0 +1,190 @@ +import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; + +import { readPreviewAnnotationTheme } from "./annotationTheme"; + +interface RecordingDecorationOptions { + readonly showKeyPresses: boolean; + readonly showMousePresses: boolean; + readonly frameRate: number; +} + +/** Decorates a detached canvas; no recording UI is inserted into the preview page. */ +export async function createRecordingCompositor( + source: MediaStream, + options: RecordingDecorationOptions, + subscribe: (listener: (input: DesktopPreviewRecordingInput) => void) => () => void, +) { + if (!options.showKeyPresses && !options.showMousePresses) return null; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Recording canvas is unavailable."); + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.srcObject = source; + const settings = source.getVideoTracks()[0]?.getSettings(); + canvas.width = settings?.width ?? 1920; + canvas.height = settings?.height ?? 1080; + const decorations = new RecordingDecorations(options, readPreviewAnnotationTheme().primary); + let disposed = false; + let frameId: number | undefined; + let timer: number | undefined; + const draw = () => { + if (disposed || video.readyState < 2) return; + const width = video.videoWidth || canvas.width; + const height = video.videoHeight || canvas.height; + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + context.drawImage(video, 0, 0, width, height); + const now = performance.now(); + decorations.draw(context, width, height, now); + window.clearTimeout(timer); + const next = decorations.nextRedraw(now); + if (next !== null) timer = window.setTimeout(draw, next); + }; + const frame = () => { + if (disposed) return; + draw(); + frameId = video.requestVideoFrameCallback(frame); + }; + const output = canvas.captureStream(options.frameRate); + let unsubscribe: (() => void) | undefined; + const dispose = () => { + if (disposed) return; + disposed = true; + unsubscribe?.(); + window.clearTimeout(timer); + if (frameId !== undefined) video.cancelVideoFrameCallback(frameId); + video.pause(); + video.srcObject = null; + for (const track of output.getTracks()) track.stop(); + }; + try { + unsubscribe = subscribe((input) => { + decorations.apply(input, performance.now()); + draw(); + }); + frameId = video.requestVideoFrameCallback(frame); + await video.play(); + draw(); + return { stream: output, dispose }; + } catch (error) { + dispose(); + throw error; + } +} + +/** Keeps input timing and coordinates independent of native video frame delivery. */ +export class RecordingDecorations { + private ring: { + x: number; + y: number; + width: number; + height: number; + held: boolean; + releasedAt: number | null; + } | null = null; + private key: { label: string; width: number; expiresAt: number | null } | null = null; + + constructor( + private readonly options: RecordingDecorationOptions, + private readonly primaryColor: string, + ) {} + + apply(input: DesktopPreviewRecordingInput, now: number) { + if (input.type === "clear") { + this.ring = null; + this.key = null; + } else if (input.type === "key" && this.options.showKeyPresses) { + this.key = input.label + ? { label: input.label, width: input.width, expiresAt: input.held ? null : now + 900 } + : null; + } else if (input.type === "pointer" && this.options.showMousePresses) { + if (input.phase === "down" || input.phase === "click") { + this.ring = { + x: input.x, + y: input.y, + width: input.width, + height: input.height, + held: input.phase === "down", + releasedAt: input.phase === "click" ? now : null, + }; + } else if (this.ring?.held) { + this.ring = { + ...this.ring, + x: input.x, + y: input.y, + width: input.width, + height: input.height, + held: input.phase !== "up", + releasedAt: input.phase === "up" ? now : null, + }; + } + } + } + + nextRedraw(now: number): number | null { + if ( + this.ring?.releasedAt !== null && + this.ring?.releasedAt !== undefined && + now < this.ring.releasedAt + 600 + ) { + return 1000 / this.options.frameRate; + } + return this.key?.expiresAt !== null && + this.key?.expiresAt !== undefined && + now < this.key.expiresAt + ? this.key.expiresAt - now + : null; + } + + draw(context: CanvasRenderingContext2D, width: number, height: number, now: number) { + // Guest coordinates are CSS pixels; native frames include zoom and display scale. + const scale = width / (this.key?.width ?? this.ring?.width ?? 1280); + const ring = this.ring; + if (ring && (ring.held || (ring.releasedAt !== null && now < ring.releasedAt + 600))) { + const progress = ring.releasedAt === null ? 0 : Math.min(1, (now - ring.releasedAt) / 600); + context.save(); + const opacity = 0.9 * (1 - progress); + context.strokeStyle = this.primaryColor; + context.fillStyle = this.primaryColor; + context.lineWidth = 2 * scale; + context.beginPath(); + context.ellipse( + (ring.x * width) / ring.width, + (ring.y * height) / ring.height, + ((20 * width) / ring.width) * (1 + progress * 0.5), + ((20 * height) / ring.height) * (1 + progress * 0.5), + 0, + 0, + Math.PI * 2, + ); + context.globalAlpha = opacity * 0.15; + context.fill(); + context.globalAlpha = opacity; + context.stroke(); + context.restore(); + } + const key = this.key; + if (key && (key.expiresAt === null || now < key.expiresAt)) { + context.save(); + context.font = `500 ${26 * scale}px system-ui, sans-serif`; + const badgeWidth = Math.min( + width - 32 * scale, + context.measureText(key.label).width + 36 * scale, + ); + const badgeHeight = 54 * scale; + const left = (width - badgeWidth) / 2; + const top = height - 24 * scale - badgeHeight; + context.fillStyle = "rgba(32,32,34,.86)"; + context.beginPath(); + context.roundRect(left, top, badgeWidth, badgeHeight, 14 * scale); + context.fill(); + context.fillStyle = "white"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(key.label, width / 2, top + badgeHeight / 2, badgeWidth - 24 * scale); + context.restore(); + } + } +} diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index bc89daee4595..408f268fa0c4 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -24,7 +24,6 @@ export function AgentBrowserCursor(props: { return ( (null); + const active = inactiveSequence !== event.sequence; useEffect(() => { - const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); + const timeout = window.setTimeout(() => setInactiveSequence(event.sequence), CURSOR_ACTIVE_MS); return () => window.clearTimeout(timeout); - }, []); + }, [event.sequence]); return (
({ pictureInPicture: false, showEmptyState: false, loading: false, + serverEpoch: null as string | null, + recordingTabIds: new Set(), + recordingRuntimeTabId: null as string | null, recordVisitForThread: vi.fn(), })); @@ -98,6 +101,7 @@ vi.mock("~/previewStateStore", () => ({ updatePreviewServerSnapshot: vi.fn(), useThreadPreviewState: () => ({ activeTabId: "tab-1", + serverEpoch: mocks.serverEpoch, desktopByTabId: { "tab-1": { hasWebContents: true, @@ -146,11 +150,11 @@ vi.mock("~/state/use-atom-command", () => ({ })); vi.mock("~/browser/browserRecording", () => ({ - findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), + findActiveBrowserRecordingRuntimeTabId: () => mocks.recordingRuntimeTabId, isBrowserRecordingStartCancelledError: vi.fn(() => false), startBrowserRecording: vi.fn(), stopBrowserRecording: vi.fn(), - useActiveBrowserRecordingTabIds: () => new Set(), + useActiveBrowserRecordingTabIds: () => mocks.recordingTabIds, })); vi.mock("~/browser/browserSurfaceStore", () => ({ @@ -244,7 +248,9 @@ vi.mock("./PreviewMoreMenu", () => ({ })); vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); -vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("./AgentBrowserCursor", () => ({ + AgentBrowserCursor: () => createElement("agent-cursor"), +})); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); @@ -344,9 +350,38 @@ describe("PreviewView navigation", () => { mocks.pictureInPicture = false; mocks.showEmptyState = false; mocks.loading = false; + mocks.serverEpoch = null; + mocks.recordingTabIds = new Set(); + mocks.recordingRuntimeTabId = null; mocks.recordVisitForThread.mockClear(); }); + it("shows the cursor in a replacement browser while the old instance still records", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container as unknown as Element); + const hasCursor = (node: TestNode): boolean => + node.nodeName === "AGENT-CURSOR" || node.childNodes.some(hasCursor); + mocks.recordingTabIds.add(TEST_RUNTIME_TAB_ID); + mocks.recordingRuntimeTabId = TEST_RUNTIME_TAB_ID; + try { + await act(() => { + root.render(); + }); + expect(hasCursor(container)).toBe(false); + mocks.serverEpoch = "replacement-server"; + await act(() => { + root.render(); + }); + expect(hasCursor(container)).toBe(true); + expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index d190a78d4825..810e6d502804 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -798,18 +798,17 @@ export function PreviewView({ {snapshot && desktopOverlay ? ( ) : null} - {runtimeTabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( + {runtimeTabId && + desktopOverlay && + !showEmptyState && + !isUnreachable && + !activeRecordingTabIds.has(runtimeTabId) ? ( ) : null} - {controller !== "none" ? ( -
- {controller === "agent" ? "Agent controlling browser" : "Human control"} -
- ) : null} {navStatus._tag === "LoadFailed" ? (
settings.browserRecordingShowKeyPresses); + const showMouse = useClientSettings((settings) => settings.browserRecordingShowMousePresses); + const updateSettings = useUpdatePrimarySettings(); + return ( + <> + + updateSettings({ browserRecordingShowKeyPresses: Boolean(checked) }) + } + /> + } + /> + + updateSettings({ browserRecordingShowMousePresses: Boolean(checked) }) + } + /> + } + /> + + ); +} + function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) { const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate); const updateSettings = useUpdatePrimarySettings(); @@ -1326,6 +1364,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 d93db8d970b1..3f9d233ed278 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -268,6 +268,8 @@ describe("getChangedBrowserSettingLabels", () => { browserDefaultZoomFactor: 1.5, browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, + browserRecordingShowKeyPresses: true, + browserRecordingShowMousePresses: true, browserLinkTarget: "app", browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, }), @@ -276,6 +278,8 @@ describe("getChangedBrowserSettingLabels", () => { "Browser zoom", "Browser appearance", "Recording frame rate", + "Recording key presses", + "Recording mouse presses", "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 5cbcb190a97b..d5c6359c1c9f 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -115,6 +115,8 @@ export type BrowserDefaultSettings = Pick< | "browserDefaultZoomFactor" | "browserDefaultAppearance" | "browserRecordingFrameRate" + | "browserRecordingShowKeyPresses" + | "browserRecordingShowMousePresses" | "browserLinkTarget" | "browserAutoShowFloatingPreview" >; @@ -156,6 +158,8 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings) ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate ? ["Recording frame rate"] : []), + ...(settings.browserRecordingShowKeyPresses ? ["Recording key presses"] : []), + ...(settings.browserRecordingShowMousePresses ? ["Recording mouse presses"] : []), ...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget ? ["Open links in"] : []), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 85e1de588f94..180ab45361df 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -635,6 +635,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserDefaultZoomFactor, settings.browserDefaultAppearance, settings.browserRecordingFrameRate, + settings.browserRecordingShowKeyPresses, + settings.browserRecordingShowMousePresses, settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, @@ -798,6 +800,8 @@ export function useSettingsRestore(onRestored?: () => void) { browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor, browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance, browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate, + browserRecordingShowKeyPresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowKeyPresses, + browserRecordingShowMousePresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowMousePresses, browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget, browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, // Re-granted like any other default. The confirmation dialog lists it by diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4532bd5f72fb..88f0326ea9e0 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -615,6 +615,18 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Browser recording frame rate", to: "/settings/integrations", }, + { + id: "browser-recording-key-presses", + title: "Show key presses in recordings", + to: "/settings/integrations", + searchTerms: ["browser preview keyboard shortcuts keystrokes overlay capture"], + }, + { + id: "browser-recording-mouse-presses", + title: "Show mouse presses in recordings", + to: "/settings/integrations", + searchTerms: ["browser preview clicks buttons drag overlay capture"], + }, { id: "browser-link-target", title: "Open links in", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7e738b97786b..c67ecd27c0e1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -658,6 +658,30 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } +/** Recording decorations are forwarded separately from the captured page pixels. */ +export const DesktopPreviewRecordingInputSchema = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("pointer"), + phase: Schema.Literals(["move", "down", "up", "click"]), + x: Schema.Finite, + y: Schema.Finite, + width: Schema.Finite.check(Schema.isGreaterThan(0)), + height: Schema.Finite.check(Schema.isGreaterThan(0)), + }), + Schema.Struct({ + type: Schema.Literal("key"), + label: Schema.NullOr(Schema.String.check(Schema.isMaxLength(100))), + held: Schema.Boolean, + width: Schema.Finite.check(Schema.isGreaterThan(0)), + }), + Schema.Struct({ type: Schema.Literal("clear") }), +]); +export type DesktopPreviewRecordingInput = typeof DesktopPreviewRecordingInputSchema.Type; +export interface DesktopPreviewRecordingInputEvent { + readonly tabId: string; + readonly input: DesktopPreviewRecordingInput; +} + /** * Static config a renderer needs to mount a preview ``. Returned * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer @@ -1295,6 +1319,7 @@ export interface DesktopPreviewBridge { close: (tabId: string) => Promise; }; recording: { + onInput: (listener: (event: DesktopPreviewRecordingInputEvent) => void) => () => void; startScreencast: (tabId: string) => Promise; stopScreencast: (tabId: string) => Promise; save: ( diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 3a9fc35c4c82..ff54b6c0aa53 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -471,6 +471,23 @@ describe("ClientSettings browser recording frame rate", () => { }); }); +describe("ClientSettings recording input overlays", () => { + it("defaults both overlays off and accepts independent opt-ins", () => { + const settings = decodeClientSettings({}); + expect(settings.browserRecordingShowKeyPresses).toBe(false); + expect(settings.browserRecordingShowMousePresses).toBe(false); + expect( + decodeClientSettingsPatch({ + browserRecordingShowKeyPresses: true, + browserRecordingShowMousePresses: false, + }), + ).toMatchObject({ + browserRecordingShowKeyPresses: true, + browserRecordingShowMousePresses: false, + }); + }); +}); + describe("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 42e91a76ecaf..73a2a2517d05 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -317,6 +317,12 @@ export const ClientSettingsSchema = Schema.Struct({ browserRecordingFrameRate: BrowserRecordingFrameRate.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)), ), + browserRecordingShowKeyPresses: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), + browserRecordingShowMousePresses: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), /** * 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". @@ -1525,6 +1531,8 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), + browserRecordingShowKeyPresses: Schema.optionalKey(Schema.Boolean), + browserRecordingShowMousePresses: Schema.optionalKey(Schema.Boolean), browserLinkTarget: Schema.optionalKey(BrowserLinkTarget), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), From 7ade2d2c6b37df6f1bc879bfcbf64658b7af2554 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:24:33 +0200 Subject: [PATCH 019/164] fix(web): the Run on / Workspace menu closes after a pick (#12685) --- apps/web/src/components/BranchToolbar.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3330f40a090f..cdf3aeffbea1 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -233,6 +233,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ { if (autoEnvironmentLabel) onAutoEnvironment?.(); }} @@ -250,6 +251,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ key={env.environmentId} disabled={envLocked} value={env.environmentId} + closeOnClick > @@ -274,7 +276,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onEnvModeChange(value as EnvMode); }} > - + {activeWorktreePath ? ( @@ -286,14 +288,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ - + {resolveEnvModeLabel("worktree")} {previousWorktreeLabel ? ( - + {previousWorktreeLabel} From 3cfebf4aa3ce7410806c0ba4cc4d991cc1ce8dbb Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 20 Sep 2026 18:24:52 -0300 Subject: [PATCH 020/164] fix(web): keep portaled menus clickable over Electron drag regions (#12527) --- apps/web/src/components/ui/menu.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 3563b0ed01ac..8beed75880ae 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -56,6 +56,10 @@ function MenuPopup({ Date: Sun, 20 Sep 2026 18:25:03 -0300 Subject: [PATCH 021/164] fix(web): render citations in queued messages (#12403) --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 42a45b991028..17e8fb1f56ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1778,7 +1778,7 @@ function QueuedMessageTimelineRow({
{text.length > 0 ? ( -
{text}
+ ) : null} {attachmentCount > 0 || contextCount > 0 ? (
0 && "mt-1.5")}> @@ -4067,7 +4067,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop const UserMessageBody = memo(function UserMessageBody(props: { text: string; - renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; + renderContextReference?: (reference: ChatMarkdownContextReference) => ReactNode; skills: ReadonlyArray>; markdownCwd: string | undefined; }) { From 2d8f9a8f55d2195ac820c91f0f1e8f66a055f841 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:31:38 -0700 Subject: [PATCH 022/164] fix(web): keep the timeline still when the resting composer expands (#12771) --- .../components/ComposerPromptEditorTiptap.tsx | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5126246dd05c..c108207304c4 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -725,6 +725,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ); }, []); + const editorAttributes = useMemo( + () => ({ + class: cn( + "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", + className, + ), + "data-testid": "composer-editor", + "data-composer-rich-text": richText ? "true" : "false", + "aria-placeholder": placeholder, + }), + [className, placeholder, richText], + ); + const editor = useEditor( { extensions: [ @@ -787,15 +800,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ), editable: !disabled, editorProps: { - attributes: { - class: cn( - "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", - className, - ), - "data-testid": "composer-editor", - "data-composer-rich-text": richText ? "true" : "false", - "aria-placeholder": placeholder, - }, + attributes: editorAttributes, handleKeyDown: (view, event) => { if ( isMacPlatform(navigator.platform) && @@ -995,6 +1000,17 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { editorHolder.current = editor; }, [editor]); + // Tiptap forwards option changes to the view from a passive effect, so a + // class change here would reach the ProseMirror element one tick after + // React commits. The chat composer measures its resting and expanded + // geometry in layout effects that run first, and it clamps the prompt + // through `className`, so the attributes are pushed to the view here for + // those measurements to see the layout they are about to reserve for. + useLayoutEffect(() => { + if (!editor?.isInitialized) return; + editor.view.setProps({ attributes: editorAttributes }); + }, [editor, editorAttributes]); + const readSnapshot = useCallback(() => { const snapshot = snapshotRef.current; if (!editor) return snapshot; From 45e06f48a03354b600782259010ae6222241aff0 Mon Sep 17 00:00:00 2001 From: "Khai Shern, Toh" <55418374+Leos-Khai@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:46:08 +0800 Subject: [PATCH 023/164] fix: composer hero reads project name to screen readers (#12397) --- .../features/threads/NewTaskDraftScreen.tsx | 2 +- .../src/components/chat/DraftHeroHeadline.tsx | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 9732ed6865f5..681f6c5ee52d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1455,7 +1455,7 @@ export function NewTaskDraftScreen(props: { in + // The trigger's accessible name comes from its visible text (the + // project title) so the hero sentence reads naturally: an + // aria-label here would replace the title with an action phrase + // mid-sentence and baffle screen-reader users. + } > {activeProjectDisplayName ?? "Choose a project"} @@ -233,8 +234,21 @@ export function DraftHeroHeadline({ ); + // The composer hero is a sentence, so the heading's accessible name must be + // a complete sentence too. The project picker is a control rendered inline + // in the h1; without an explicit label its widget state bleeds into the + // announced phrase. + const headingLabel = hasResolvedProject + ? `What should we build in ${activeProjectDisplayName}?` + : canChooseProject + ? `${activeProjectDisplayName ?? "Choose a project"} to start` + : "Add a project to start"; + return ( -

+

{hasResolvedProject ? ( <>What should we build in {projectSelector}? ) : canChooseProject ? ( From f6cc6bc7e9cbe1feeb7811ea92d519ad77c76596 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 20 Sep 2026 18:04:01 -0400 Subject: [PATCH 024/164] fix(mobile): respect word wrap in diffs (#12590) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Julius Marminge --- .../t3-review-diff/android/build.gradle | 12 + .../t3reviewdiff/ReviewDiffCanvasDrawing.kt | 108 ++++++- .../t3reviewdiff/ReviewDiffCodeLayout.kt | 169 +++++++++++ .../modules/t3reviewdiff/T3ReviewDiffView.kt | 53 ++-- .../t3reviewdiff/ReviewDiffCodeLayoutTest.kt | 150 +++++++++ .../ios/ReviewDiffCodeLayout.swift | 123 ++++++++ .../t3-review-diff/ios/T3ReviewDiffView.swift | 284 ++++++++++++++---- .../t3-review-diff/tests/ios/main.swift | 60 ++++ .../modules/t3-review-diff/tests/run-ios.sh | 12 + .../src/features/review/ReviewCommentCard.tsx | 3 +- .../review/nativeReviewDiffAdapter.ts | 7 +- .../reviewDiffHighlightScheduler.test.ts | 74 +++++ .../review/reviewDiffHighlightScheduler.ts | 51 ++++ .../review/useNativeReviewDiffHighlighting.ts | 41 +-- .../appearance/useAppearanceCodeSurface.ts | 4 +- 15 files changed, 1032 insertions(+), 119 deletions(-) create mode 100644 apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt create mode 100644 apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt create mode 100644 apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift create mode 100644 apps/mobile/modules/t3-review-diff/tests/ios/main.swift create mode 100644 apps/mobile/modules/t3-review-diff/tests/run-ios.sh create mode 100644 apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts create mode 100644 apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle index 22bb070b3b81..d360d1580f1d 100644 --- a/apps/mobile/modules/t3-review-diff/android/build.gradle +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3reviewdiff' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -16,4 +20,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 6782e6894d99..80d0410643ff 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -9,6 +9,7 @@ import android.graphics.Path import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface +import android.text.TextPaint import kotlin.math.max import kotlin.math.min @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { textPaint.isUnderlineText = fontStyle and 4 != 0 } + var codeLayouts = CodeLayoutCache() + + /** Capture paint on the UI thread; the decode worker owns the new cache until publication. */ + fun prepareRows( + tokens: Map>, + style: DiffStyle, + width: Int + ): (List) -> CodeLayoutCache { + configureCodePaint(theme.text, 0, style) + val paint = TextPaint(textPaint) + val colors = theme + val cache = codeLayouts.copyForPreparation() + val availableWidth = ( + width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + ).toInt() + return { rows -> + cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) } + } + } + + fun codeWrapLayout( + rows: List, + tokens: Map>, + style: DiffStyle, + width: Int + ): CodeWrapLayout { + configureCodePaint(theme.text, 0, style) + val availableWidth = width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) + } + fun lineNumberColor(change: String): Int = when (change) { "add" -> theme.addText "delete" -> theme.deleteText @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } } + /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */ + @Suppress("LongParameterList") fun drawWordDiffRanges( canvas: Canvas, row: DiffRow, codeX: Float, top: Int, - bottom: Int + bottom: Int, + lines: CodeLines ) { + if (lines.nativeLayout != null) return if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return val color = if (row.change == "add") theme.addBar else theme.deleteBar backgroundPaint.color = withAlpha(color, 71) @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) val highlightTop = (top + bottom - highlightHeight) / 2f row.wordDiffRanges.forEach { range -> - val left = codeX + range.start * characterWidth - val right = max(left + 2f * density, codeX + range.end * characterWidth) - canvas.drawRoundRect( - RectF(left, highlightTop, right, highlightTop + highlightHeight), - 3f * density, - 3f * density, - backgroundPaint, - ) + // A wrapped row splits the highlight at each visual line boundary. + lines.starts.forEachIndexed { line, lineStart -> + val start = max(range.start, lineStart) + val end = min(range.end, lines.end(line, Int.MAX_VALUE)) + if (end <= start) return@forEachIndexed + val left = codeX + (start - lineStart) * characterWidth + val right = max(left + 2f * density, left + (end - start) * characterWidth) + val lineTop = highlightTop + line * lines.height + canvas.drawRoundRect( + RectF(left, lineTop, right, lineTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + } + + /** Draws a code row's text, or its syntax [tokens] when present, one visual line per start. */ + @Suppress("LongParameterList") + fun drawCode( + canvas: Canvas, + content: String, + tokens: List?, + codeX: Float, + baseline: Float, + style: DiffStyle, + lines: CodeLines + ) { + val nativeLayout = lines.nativeLayout + if (nativeLayout != null) { + canvas.save() + canvas.translate(codeX, baseline - nativeLayout.getLineBaseline(0)) + nativeLayout.draw(canvas) + canvas.restore() + return + } + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens + var line = 0 + var x = codeX + var column = 0 + runs.forEach { run -> + configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + var start = 0 + while (start < run.content.length) { + while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) { + line += 1 + x = codeX + } + val end = min(run.content.length, lines.end(line, Int.MAX_VALUE) - column) + val lineBaseline = baseline + line * lines.height + if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top && + lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom + ) { + canvas.drawText(run.content, start, end, x, lineBaseline, textPaint) + x += textPaint.measureText(run.content, start, end) + } + start = end + } + column += run.content.length } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt new file mode 100644 index 000000000000..dc77a8467033 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt @@ -0,0 +1,169 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import kotlin.math.ceil +import kotlin.math.max + +/** Text layout is independent of comment heights and vertical row offsets. */ +internal class CodeLines( + val starts: IntArray, + val height: Int, + val nativeLayout: StaticLayout? = null +) { + fun end(line: Int, length: Int): Int = if (line + 1 < starts.size) starts[line + 1] else length + + fun firstHeight(base: Int): Int = max(base, nativeLayout?.getLineBottom(0) ?: 0) + + fun baseline(top: Int, bottom: Int, paint: Paint): Float = nativeLayout?.let { + top + (bottom - top - it.getLineBottom(0)) / 2f + it.getLineBaseline(0) + } ?: ((top + bottom - paint.fontMetrics.ascent - paint.fontMetrics.descent) / 2f) + + val extraHeight: Int + get() = nativeLayout?.let { it.height - it.getLineBottom(0) } ?: ((starts.size - 1) * height) +} + +internal class CodeWrapLayout( + val enabled: Boolean, + private val linesByRowId: Map +) { + fun lines(rowId: String): CodeLines = linesByRowId[rowId] ?: SINGLE_LINE + fun extraHeight(rowId: String): Int = lines(rowId).extraHeight + fun rowHeight(rowId: String, base: Int): Int = lines(rowId).let { + it.firstHeight(base) + + it.extraHeight + } + + companion object { + private val SINGLE_LINE = CodeLines(intArrayOf(0), 0) + val NONE = CodeWrapLayout(false, emptyMap()) + } +} + +/** ASCII is fixed-pitch; other text needs the same shaping for measurement and drawing. */ +internal fun createCodeLines(text: CharSequence, paint: TextPaint, width: Int): CodeLines { + val characterWidth = paint.measureText("M") + val lineHeight = ceil(paint.fontMetrics.run { descent - ascent }).toInt() + if (text.all { it in ' '..'~' }) { + val columns = max(1, (width / characterWidth).toInt()) + return CodeLines( + IntArray(max(1, (text.length + columns - 1) / columns)) { + it * columns + }, + lineHeight + ) + } + val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, max(1, width)) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE) + .build() + return CodeLines(IntArray(layout.lineCount) { layout.getLineStart(it) }, lineHeight, layout) +} + +internal class CodeLayoutCache { + private data class Entry(val row: DiffRow, val tokens: List?, val lines: CodeLines) + private var entries = emptyMap() + private var previousStyle: DiffStyle? = null + private var previousTheme: DiffTheme? = null + private var previousWidth = 0 + + /** Entries are immutable; a worker can reuse them without changing the displayed cache. */ + fun copyForPreparation(): CodeLayoutCache = CodeLayoutCache().also { + it.entries = entries + it.previousStyle = previousStyle + it.previousTheme = previousTheme + it.previousWidth = previousWidth + } + + @Suppress("LongParameterList") + fun layout( + rows: List, + tokens: Map>, + paint: Paint, + style: DiffStyle, + theme: DiffTheme, + width: Int + ): CodeWrapLayout { + if (!style.wordWrap || width < paint.measureText("M")) { + entries = emptyMap() + return CodeWrapLayout.NONE + } + if (previousStyle != style || previousTheme != theme || previousWidth != width) { + entries = emptyMap() + previousStyle = style + previousTheme = theme + previousWidth = width + } + val next = HashMap() + val layouts = HashMap() + for (row in rows) { + if (row.kind != "line") continue + val rowTokens = tokens[row.id] + val cached = entries[row.id] + val entry = if (cached?.row == row && cached.tokens == rowTokens) { + cached + } else { + val text = styledCode(row, rowTokens, theme) + Entry(row, rowTokens, createCodeLines(text, TextPaint(paint), width)) + } + next[row.id] = entry + layouts[row.id] = entry.lines + } + entries = next + return CodeWrapLayout(true, layouts) + } + + private fun styledCode(row: DiffRow, tokens: List?, theme: DiffTheme): CharSequence { + // The ASCII path uses the existing token drawing and rounded highlight rectangles. + if (row.content.all { it in ' '..'~' }) return row.content + val text = SpannableString(row.content) + var offset = 0 + for (token in tokens.orEmpty()) { + val end = (offset + token.content.length).coerceAtMost(text.length) + if (end > offset) { + token.color?.let { + text.setSpan(ForegroundColorSpan(it), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val fontStyle = (if (token.fontStyle and 2 != 0) Typeface.BOLD else 0) or + (if (token.fontStyle and 1 != 0) Typeface.ITALIC else 0) + if (fontStyle != + 0 + ) { + text.setSpan(StyleSpan(fontStyle), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (token.fontStyle and 4 != + 0 + ) { + text.setSpan(UnderlineSpan(), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + offset = end + } + if (row.change == "add" || row.change == "delete") { + val bar = if (row.change == "add") theme.addBar else theme.deleteBar + val color = Color.argb(71, Color.red(bar), Color.green(bar), Color.blue(bar)) + for (range in row.wordDiffRanges) { + val start = range.start.coerceIn(0, text.length) + val end = range.end.coerceIn(start, text.length) + if (end > + start + ) { + text.setSpan(BackgroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + return text + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 97e9f696db90..37fee1cb3613 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setRowsJson(value: String) { rowsDecodeGeneration += 1 val generation = rowsDecodeGeneration + val prepareLayout = canvasView.prepareRows() payloadDecodeExecutor.execute { val decodedRows = parseRows(value) + val codeLayouts = prepareLayout(decodedRows) post { if (generation != rowsDecodeGeneration) return@post + canvasView.useCodeLayouts(codeLayouts) rows = decodedRows lastVisibleFileId = null rebuildVisibleRows() @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( val end: Int ) -private data class DiffToken( +internal data class DiffToken( val content: String, val color: Int?, val fontStyle: Int @@ -543,6 +546,7 @@ internal data class DiffTheme( } internal data class DiffStyle( + val wordWrap: Boolean, val rowHeightPx: Float, val gutterWidthPx: Float, val codePaddingPx: Float, @@ -564,6 +568,7 @@ internal data class DiffStyle( ) { companion object { fun defaults(density: Float): DiffStyle = DiffStyle( + wordWrap = false, rowHeightPx = 20f * density, gutterWidthPx = 72f * density, codePaddingPx = 10f * density, @@ -587,6 +592,7 @@ internal data class DiffStyle( fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { val json = JSONObject(value) DiffStyle( + wordWrap = json.optBoolean("wordWrap", fallback.wordWrap), rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { }, ) private var rowOffsets = intArrayOf(0) + + private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 private var horizontalOffset = 0 private val headerPathOffsetsByFileId = mutableMapOf() @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { var tokensByRowId: Map> = emptyMap() set(value) { field = value + if (style.wordWrap) rebuildOffsets() invalidate() } var viewedFileIds: Set = emptySet() @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { set(value) { field = value drawing.theme = value + if (style.wordWrap) rebuildOffsets() invalidate() } var style: DiffStyle = DiffStyle.defaults(density) @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) + fun useCodeLayouts(layouts: CodeLayoutCache) { + drawing.codeLayouts = layouts + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( MeasureSpec.getSize(widthMeasureSpec), @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { super.onSizeChanged(width, height, oldWidth, oldHeight) + // Wrapped rows take their height from the width, so a new width is a new layout. + if (style.wordWrap && width != oldWidth) layoutRows() setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { fun horizontalOffset(): Int = horizontalOffset - fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + fun maxHorizontalOffset(): Int = + if (codeWrap.enabled) 0 else max(0, contentWidthPx - width) fun maxHorizontalOffset(target: HorizontalPanTarget): Int = if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun rebuildOffsets() { + layoutRows() + requestLayout() + invalidate() + } + + private fun layoutRows() { + codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } setVerticalOffset(verticalOffset) + setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() - requestLayout() - invalidate() } private fun rowHeight(row: DiffRow): Int = when (row.kind) { @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { } else { (124 * density).toInt() } + "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { ) } - val tokens = tokensByRowId[row.id] + // Wrapped rows keep the line number and first code line in the first row-height band. + val lines = codeWrap.lines(row.id) + val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt()) drawScrollableCode(canvas, top, bottom) { codeX -> drawing.configureCodePaint(theme.text, 0, style) - drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) - if (tokens.isNullOrEmpty()) { - canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) - } else { - var x = codeX - tokens.forEach { token -> - drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) - canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) - x += textPaint.measureText(token.content) - } - } + drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines) + val baseline = lines.baseline(top, firstLineBottom, textPaint) + drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines) } - drawLineNumber(canvas, row, top, bottom) + drawLineNumber(canvas, row, top, firstLineBottom) } private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt new file mode 100644 index 000000000000..1f4234b7175f --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -0,0 +1,150 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.text.Spanned +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ReviewDiffCodeLayoutTest { + private val paint = TextPaint().apply { + color = Color.WHITE + textSize = 24f + typeface = Typeface.MONOSPACE + } + + @Test + fun unicodeAndTabsFitWithoutSplittingClusters() { + val fixtures = listOf("漢字表示", "e\u0301", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue ") + for (fixture in fixtures) { + val text = fixture.repeat(40) + for (width in listOf(180, 280, 420)) { + val layout = requireNotNull(createCodeLines(text, paint, width).nativeLayout) + assertInkFits(layout, width, fixture) + assertLinesFit(layout, fixture, width) + } + } + } + + private fun assertLinesFit(layout: android.text.StaticLayout, fixture: String, width: Int) { + val text = layout.text + for (line in 0 until layout.lineCount) { + if (!fixture.contains('\t')) { + assertTrue("$fixture line $line at $width", layout.getLineMax(line) <= width + 1) + } + val start = layout.getLineStart(line) + assertTrue(start == 0 || !Character.isLowSurrogate(text[start])) + if (fixture == "👨‍👩‍👧‍👦" || fixture == "e\u0301") { + assertEquals(0, start % fixture.length) + } + } + } + + private fun assertInkFits(layout: android.text.StaticLayout, width: Int, fixture: String) { + val bitmap = Bitmap.createBitmap(width + 40, layout.height, Bitmap.Config.ARGB_8888) + layout.draw(Canvas(bitmap)) + for (x in width + 1 until bitmap.width) { + for (y in 0 until bitmap.height) { + assertEquals("$fixture ink outside width $width", 0, Color.alpha(bitmap.getPixel(x, y))) + } + } + bitmap.recycle() + } + + @Test + fun asciiSegmentsCoverTheWholeLineAndFit() { + val text = "const value = 123; ".repeat(100) + val lines = createCodeLines(text, paint, 280) + val pieces = lines.starts.indices.map { + text.substring(lines.starts[it], lines.end(it, text.length)) + } + assertEquals(text, pieces.joinToString("")) + assertTrue(pieces.all { paint.measureText(it) <= 280 }) + } + + @Test + fun changingCommentHeightReusesCodeButWidthAndContentInvalidateIt() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(100)) + val comment = row.copy(kind = "comment", id = "comment", content = "", commentText = "Before") + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val first = cache.layout( + listOf(row, comment), + emptyMap(), + paint, + style, + theme, + 280 + ).lines(row.id) + val second = cache.layout( + listOf(row, comment.copy(commentText = "After")), + emptyMap(), + paint, + style, + theme, + 280, + ).lines(row.id) + assertSame(first, second) + val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + assertNotSame(first, narrow) + assertTrue(narrow.extraHeight > first.extraHeight) + val edited = cache.layout( + listOf(row.copy(content = "短い")), + emptyMap(), + paint, + style, + theme, + 180 + ).lines(row.id) + assertTrue(edited.extraHeight < narrow.extraHeight) + assertEquals( + 0, + cache.layout( + listOf(row), + emptyMap(), + paint, + style.copy(wordWrap = false), + theme, + 180 + ).extraHeight(row.id) + ) + } + + @Test + fun highlightsUseNativeTextRangesAndSurviveSyntaxArrival() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(30)).copy(wordDiffRanges = listOf(DiffWordDiffRange(3, 21))) + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + val tokens = mapOf(row.id to listOf(DiffToken(row.content, 0xff008800.toInt(), 2))) + val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row.id) + assertNotSame(initial, highlighted) + val text = requireNotNull(highlighted.nativeLayout).text as Spanned + val span = text.getSpans(0, text.length, BackgroundColorSpan::class.java).single() + assertEquals(3, text.getSpanStart(span)) + assertEquals(21, text.getSpanEnd(span)) + } + + private fun row(content: String) = DiffRow( + kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, + changeType = "modified", additions = 1, deletions = 0, text = "", content = content, + change = "add", oldLineNumber = null, newLineNumber = 1, wordDiffRanges = emptyList(), + commentText = "", commentRangeLabel = "", commentSectionTitle = "", + ) +} diff --git a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift new file mode 100644 index 000000000000..1d8b3e8ea8d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift @@ -0,0 +1,123 @@ +import UIKit + +/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. +final class ReviewDiffCodeLayout: NSObject { + // Measurement reuses one engine; only recently drawn rows retain a full TextKit layout. + private static var measurer: ReviewDiffTextLayout { + let key = "T3ReviewDiff.textMeasurer" + if let layout = Thread.current.threadDictionary[key] as? ReviewDiffTextLayout { return layout } + let layout = ReviewDiffTextLayout() + Thread.current.threadDictionary[key] = layout + return layout + } + private static let drawnLayouts: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + let text: String + let starts: [Int] + let lineHeight: CGFloat + let firstLineHeight: CGFloat + let extraHeight: CGFloat + private let font: UIFont + private let width: CGFloat + private let characterWidth: CGFloat + let usesNativeLayout: Bool + + init(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + self.text = text + self.font = font + self.width = width + self.characterWidth = characterWidth + lineHeight = ceil(font.lineHeight) + if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) { + let columns = max(1, Int(width / characterWidth)) + starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns)) + firstLineHeight = font.lineHeight + extraHeight = CGFloat(starts.count - 1) * lineHeight + usesNativeLayout = false + } else { + let layout = Self.measurer + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + let manager = layout.manager + let container = layout.container + usesNativeLayout = true + starts = [0] + firstLineHeight = manager.numberOfGlyphs > 0 + ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight + extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight) + } + } + + private func nativeLayout() -> ReviewDiffTextLayout { + if let cached = Self.drawnLayouts.object(forKey: self) { return cached } + let layout = ReviewDiffTextLayout() + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + Self.drawnLayouts.setObject(layout, forKey: self) + return layout + } + + /// Only colors change when syntax tokens arrive; the measured text and font stay intact. + func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + guard layout.decorationVersion != version else { return } + let storage = layout.storage + let fullRange = NSRange(location: 0, length: storage.length) + storage.beginEditing() + storage.removeAttribute(.foregroundColor, range: fullRange) + storage.removeAttribute(.backgroundColor, range: fullRange) + text.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: text.length)) { value, range, _ in + let intersection = NSIntersectionRange(range, fullRange) + if let value, intersection.length > 0 { + storage.addAttribute(.foregroundColor, value: value, range: intersection) + } + } + for range in highlights { + let intersection = NSIntersectionRange(range, fullRange) + if intersection.length > 0 { + storage.addAttribute(.backgroundColor, value: color, range: intersection) + } + } + storage.endEditing() + layout.decorationVersion = version + } + + func draw(at origin: CGPoint, clip: CGRect) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + let manager = layout.manager + let container = layout.container + let visible = clip.offsetBy(dx: -origin.x, dy: -origin.y) + let range = manager.glyphRange(forBoundingRect: visible, in: container) + manager.drawBackground(forGlyphRange: range, at: origin) + manager.drawGlyphs(forGlyphRange: range, at: origin) + } +} + +private final class ReviewDiffTextLayout { + let storage = NSTextStorage() + let manager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + var decorationVersion = -1 + + init() { + container.lineFragmentPadding = 0 + container.lineBreakMode = .byCharWrapping + manager.addTextContainer(container) + storage.addLayoutManager(manager) + } + + func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byCharWrapping + paragraph.tabStops = [] + paragraph.defaultTabInterval = characterWidth * 4 + container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude) + storage.setAttributedString(NSAttributedString(string: text, attributes: [ + .font: font, .ligature: 0, .paragraphStyle: paragraph, + ])) + manager.ensureLayout(for: container) + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 74111988f150..e2400e0a9393 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme { } private struct ReviewDiffNativeStylePayload: Decodable { + let wordWrap: Bool? let rowHeight: Double? let contentWidth: Double? let changeBarWidth: Double? @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { } private struct ReviewDiffNativeStyle { + let wordWrap: Bool let rowHeight: CGFloat let contentWidth: CGFloat let changeBarWidth: CGFloat @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: payload?.wordWrap ?? false, rowHeight: metric(payload?.rowHeight, fallback: 24), contentWidth: metric(payload?.contentWidth, fallback: 2800), changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: wordWrap, rowHeight: rowHeight ?? self.rowHeight, contentWidth: contentWidth ?? self.contentWidth, changeBarWidth: changeBarWidth, @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { guard let self, generation == self.rowsDecodeGeneration else { return } - self.rows = decodedRows - self.contentView.rows = decodedRows - self.hasAppliedInitialRowIndex = false - self.lastVisibleFileId = nil - self.emitDebug("rows-decoded", [ - "rows": decodedRows.count, - "firstKind": decodedRows.first?.kind ?? "none", - ]) - self.updateContentMetrics() - self.applyPendingScrollIfNeeded() + self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in + generation == self?.rowsDecodeGeneration + }, completion: { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { return } + self.rows = decodedRows + self.contentView.rows = decodedRows + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.emitDebug("rows-decoded", [ + "rows": decodedRows.count, + "firstKind": decodedRows.first?.kind ?? "none", + ]) + self.updateContentMetrics() + self.applyPendingScrollIfNeeded() + }) } } catch { let message = error.localizedDescription @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private func updateContentMetrics() { let style = contentView.style + contentView.viewportWidth = bounds.width let height = max(bounds.height, contentView.contentHeight) let width = bounds.width scrollView.contentSize = CGSize(width: bounds.width, height: height) @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { width: max(width, 1), height: max(bounds.height, 1) ) - contentView.viewportWidth = bounds.width contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { headerPathOffsetsByFileId.removeAll() activePanFileId = nil activePanKind = nil + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() setNeedsDisplayForVisibleBounds() @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var style = ReviewDiffNativeStyle.resolve(nil) { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() clampHorizontalOffsets() @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var viewportWidth: CGFloat = 0 { didSet { + // Wrapped rows take their height from the width, so a new width is a new layout. + if style.wordWrap, viewportWidth != oldValue { + rebuildRowLayout() + } clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() } @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var theme = ReviewDiffNativeTheme.resolve("light") { didSet { tokenColorsByHex.removeAll() + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() setNeedsDisplayForVisibleBounds() } @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var tokenColorsByHex: [String: UIColor] = [:] private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] private var codeCharacterWidth: CGFloat = 8 + /// Columns per visual line while word wrap is on; nil while code rows pan horizontally. + private var codeWrapColumns: Int? + /// Text geometry survives comment height changes; width, font, and content invalidate it. + private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] + private var codeLayoutWidth: CGFloat = 0 + private var codeLayoutFont: UIFont? + private var codeDecorationVersion = 0 private var panStartHorizontalOffset: CGFloat = 0 private var activePanFileId: String? private var activePanKind: ReviewDiffHorizontalPanKind? @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { stickyWidth + style.codePadding } + /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. private func height(for row: ReviewDiffNativeRow) -> CGFloat { if row.kind == "file" { return style.fileHeaderHeight @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return style.rowHeight } + /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. + private func height(at index: Int) -> CGFloat { + let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight + return nextOffset - rowOffsets[index] + } + + private var codeWrapLineHeight: CGFloat { + ceil(codeFont.lineHeight) + } + func frameForRow(at index: Int) -> CGRect? { guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { return nil @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { x: 0, y: rowOffsets[index], width: max(viewportWidth, 1), - height: height(for: rows[index]) + height: height(at: index) ) } + /// Shape new content on the existing decode worker before publishing rows to the UI. + func prepareRows( + _ rows: [ReviewDiffNativeRow], + on queue: DispatchQueue, + isCurrent: @escaping () -> Bool, + completion: @escaping () -> Void + ) { + let font = codeFont + let width = viewportWidth - codeStartX - style.codePadding + let characterWidth = monospaceCharacterWidth(font: font) + guard style.wordWrap, width >= characterWidth, characterWidth > 0 else { + completion() + return + } + let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] + queue.async { [weak self] in + var layouts: [String: ReviewDiffCodeLayout] = [:] + for row in rows where row.kind == "line" { + guard let text = row.content else { continue } + if let previous = cached[row.id], previous.text == text { + layouts[row.id] = previous + } else { + layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + } + } + DispatchQueue.main.async { [weak self] in + guard let self, isCurrent() else { return } + if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width { + self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) + return + } + self.codeLayoutWidth = width + self.codeLayoutFont = font + self.codeLayoutsByRowId = layouts + completion() + } + } + } + private func rebuildRowLayout() { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] nextOffsets.reserveCapacity(rows.count) var maxColumnCountsByFileId: [String: Int] = [:] + var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] var offset: CGFloat = 0 + let font = codeFont + let characterWidth = monospaceCharacterWidth(font: font) + let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding + let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth + ? Int(wrapAvailableWidth / characterWidth) + : nil + if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font { + codeLayoutsByRowId.removeAll() + codeLayoutWidth = wrapAvailableWidth + codeLayoutFont = font + } for (index, row) in rows.enumerated() { nextOffsets.append(offset) if row.kind == "file" { nextFileHeaderRowIndices.append(index) } - offset += height(for: row) + var rowHeight = height(for: row) let fileId = resolvedFileId(for: row) switch row.kind { case "line": - maxColumnCountsByFileId[fileId] = max( - maxColumnCountsByFileId[fileId] ?? 0, - row.content?.count ?? 0 - ) + // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. + let columnCount = row.content?.utf16.count ?? 0 + maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount) + if wrapColumns != nil, let content = row.content { + let cached = codeLayoutsByRowId[row.id] + let layout: ReviewDiffCodeLayout + if let cached, cached.text == content { + layout = cached + } else { + layout = ReviewDiffCodeLayout( + text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth + ) + } + nextCodeLayouts[row.id] = layout + if rowHeight > 0 { + rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight + } + } case "hunk": maxColumnCountsByFileId[fileId] = max( maxColumnCountsByFileId[fileId] ?? 0, - row.text?.count ?? 0 + row.text?.utf16.count ?? 0 ) default: - continue + break } + offset += rowHeight } - let characterWidth = monospaceCharacterWidth(font: codeFont) codeCharacterWidth = characterWidth + codeWrapColumns = wrapColumns + codeLayoutsByRowId = nextCodeLayouts contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 return max(0, min(style.contentWidth, measuredWidth)) @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { while lowerBound <= upperBound { let midpoint = (lowerBound + upperBound) / 2 let rowStart = rowOffsets[midpoint] - let rowEnd = rowStart + height(for: rows[midpoint]) + let rowEnd = rowStart + height(at: midpoint) if absoluteY < rowStart { upperBound = midpoint - 1 @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var upperBound = rows.count while lowerBound < upperBound { let midpoint = (lowerBound + upperBound) / 2 - let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint]) + let rowEnd = rowOffsets[midpoint] + height(at: midpoint) if rowEnd < absoluteY { lowerBound = midpoint + 1 @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { return maxHeaderPathOffset(for: row) } + if codeWrapColumns != nil { + return 0 + } return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) } @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var drawnRowCount = 0 for rowIndex in firstRowIndex...lastRowIndex { let rowStart = rowOffsets[rowIndex] - let rowHeight = height(for: rows[rowIndex]) + let rowHeight = height(at: rowIndex) if rowHeight <= 0 { continue } @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { let rowY = rowOffsets[rowIndex] - verticalOffset - let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) + let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex)) switch row.kind { case "file": @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let horizontalOffset = horizontalOffset(for: fileId) let contentWidth = contentWidth(for: fileId) let change = row.change ?? "context" + // Wrapped rows keep the line number and first code line in the first row-height band. + let layout = codeLayoutsByRowId[row.id] + let firstLineRect = CGRect( + x: rect.minX, y: rect.minY, width: rect.width, + height: max(style.rowHeight, layout?.firstLineHeight ?? 0) + ) rowBackground(for: change).setFill() context.fill(rect) if change == "add" { theme.addBar.setFill() - context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) + context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) } else if change == "delete" { drawDeleteStripes( - rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), + rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), context: context ) } @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { "\(lineNumber)", rect: CGRect( x: style.changeBarWidth, - y: centeredTextY(in: rect, font: lineNumberFont), + y: centeredTextY(in: firstLineRect, font: lineNumberFont), width: style.gutterWidth - style.codePadding, height: lineNumberFont.lineHeight ), @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } context.saveGState() - context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) + context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) let codeTextRect = CGRect( x: codeStartX - horizontalOffset, - y: centeredTextY(in: rect, font: codeFont), + y: centeredTextY(in: firstLineRect, font: codeFont), width: contentWidth, height: codeFont.lineHeight ) - drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset) + if let layout, layout.usesNativeLayout { + let text = tokensByRowId[row.id].map { + tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont) + } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text]) + let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : [] + layout.decorate( + text: text, + highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map { + NSRange(location: $0.start, length: $0.end - $0.start) + }, + color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), + version: codeDecorationVersion + ) + layout.draw( + at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)), + clip: context.boundingBoxOfClipPath + ) + context.restoreGState() + return + } + let lineStarts = layout?.starts ?? [0] + drawWordDiffRanges( + row, + lineStarts: lineStarts, + firstLineRect: firstLineRect, + context: context, + horizontalOffset: horizontalOffset + ) if let tokens = tokensByRowId[row.id], !tokens.isEmpty { - drawTokenText( + let attributedText = tokenAttributedString( rowId: row.id, - tokens, - rect: codeTextRect, + tokens: tokens, fallbackColor: theme.text, font: codeFont ) + drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == attributedText.length + ? attributedText + : attributedText.attributedSubstring(from: range) + segment.draw(in: lineRect) + } } else { - drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) + let content = (row.content ?? "") as NSString + drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == content.length ? content as String : content.substring(with: range) + drawText(segment, rect: lineRect, color: theme.text, font: codeFont) + } } context.restoreGState() } + /// Draws the segment starting at each of the row's line starts on its own visual line. + private func drawCodeLines( + length: Int, + lineStarts: [Int], + firstLineRect: CGRect, + draw: (NSRange, CGRect) -> Void + ) { + var lineRect = firstLineRect + let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds + let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight))) + let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight))) + guard first < last else { return } + lineRect.origin.y += CGFloat(first) * codeWrapLineHeight + for line in first.. start else { + continue + } + let highlightRect = CGRect( + x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth, + y: highlightY + CGFloat(line) * codeWrapLineHeight, + width: max(2, CGFloat(end - start) * codeCharacterWidth), + height: highlightHeight + ) + UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() + } } } @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) } - private func drawTokenText( - rowId: String, - _ tokens: [ReviewDiffNativeToken], - rect: CGRect, - fallbackColor: UIColor, - font: UIFont - ) { - let attributedText = tokenAttributedString( - rowId: rowId, - tokens: tokens, - fallbackColor: fallbackColor, - font: font - ) - attributedText.draw(in: rect) - } - private func tokenAttributedString( rowId: String, tokens: [ReviewDiffNativeToken], diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/main.swift b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift new file mode 100644 index 000000000000..baeeaae77d9a --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift @@ -0,0 +1,60 @@ +import UIKit + +func check(_ passed: Bool, _ message: String = "Failed layout check") { + if !passed { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) + } +} + +// Runs the production layout against UIKit through Mac Catalyst, without launching an app. +let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) +let characterWidth = ("M" as NSString).size(withAttributes: [.font: font]).width +let fixtures = ["漢字表示", "e\u{301}", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue "] +var cases = 0 +for fixture in fixtures { + let text = String(repeating: fixture, count: 40) + var previousHeight = CGFloat.greatestFiniteMagnitude + for width: CGFloat in [180, 280, 420] { + let layout = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + let height = layout.firstLineHeight + layout.extraHeight + check(height <= previousHeight, "Wider text must not require more height") + previousHeight = height + let fullRange = NSRange(location: 0, length: text.utf16.count) + let attributed = NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.black]) + layout.decorate(text: attributed, highlights: [], color: .clear, version: 0) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + format.preferredRange = .standard + let size = CGSize(width: width + 40, height: ceil(height)) + let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in + layout.draw(at: .zero, clip: CGRect(origin: .zero, size: size)) + } + let bitmap = image.cgImage! + let data = bitmap.dataProvider!.data! + let bytes = CFDataGetBytePtr(data)! + // Render without a viewport clip so an overflowing glyph cannot hide behind clipping. + for y in 0.. JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); + // The card's height is sized from its row count, so its snippet stays unwrapped. const nativeStyleJson = useMemo( - () => JSON.stringify(nativeReviewDiffStyle), + () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), [nativeReviewDiffStyle], ); const nativeDiffHeight = useMemo( diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 8bea04c524dd..3056cc2136ca 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string { return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } -export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { +/** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */ +export function createNativeReviewDiffStyle( + codeSurface: ResolvedMobileCodeSurface, + wordWrap: boolean, +) { return { + wordWrap, rowHeight: codeSurface.rowHeight, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts new file mode 100644 index 000000000000..3792e49f54c7 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createReviewDiffHighlightScheduler } from "./reviewDiffHighlightScheduler"; + +describe("review diff highlighting while scrolling", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("keeps requesting new rows during gradual scrolling through a large diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let firstRowIndex = 1; firstRowIndex <= 1_674; firstRowIndex++) { + scheduler.update({ firstRowIndex, lastRowIndex: firstRowIndex + 80 }); + vi.advanceTimersByTime(16); + } + expect(request.mock.calls.length).toBeGreaterThan(100); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenLastCalledWith({ firstRowIndex: 1_674, lastRowIndex: 1_754 }); + }); + + it("highlights the settled viewport even below the movement threshold", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 2, lastRowIndex: 82 }); + vi.advanceTimersByTime(100); + scheduler.update({ firstRowIndex: 3, lastRowIndex: 83 }); + vi.advanceTimersByTime(100); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(50); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 3, lastRowIndex: 83 }); + }); + + it("does not let repeated draw events starve the settled refresh", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let i = 0; i < 10; i++) { + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + vi.advanceTimersByTime(30); + } + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); + + it("requests large jumps and reverse scrolling immediately without stale timers", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + scheduler.update({ firstRowIndex: 0, lastRowIndex: 80 }); + vi.runAllTimers(); + expect(request.mock.calls).toEqual([ + [{ firstRowIndex: 1_000, lastRowIndex: 1_080 }], + [{ firstRowIndex: 0, lastRowIndex: 80 }], + ]); + }); + + it("cancels pending work on disposal and resets the range for a new diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.cancel(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + request.mockClear(); + scheduler.update({ firstRowIndex: 1_001, lastRowIndex: 1_081 }); + scheduler.reset(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts new file mode 100644 index 000000000000..4cededa52954 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts @@ -0,0 +1,51 @@ +export interface NativeReviewVisibleRange { + readonly firstRowIndex: number; + readonly lastRowIndex: number; +} + +export function createReviewDiffHighlightScheduler( + request: (range: NativeReviewVisibleRange) => void, +) { + let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 }; + let visibleRange = requestedRange; + let timer: ReturnType | undefined; + + const cancel = () => { + clearTimeout(timer); + timer = undefined; + }; + const flush = () => { + cancel(); + requestedRange = visibleRange; + request(visibleRange); + }; + + return { + update(nextRange: NativeReviewVisibleRange) { + if ( + nextRange.firstRowIndex === visibleRange.firstRowIndex && + nextRange.lastRowIndex === visibleRange.lastRowIndex + ) { + return; + } + visibleRange = nextRange; + cancel(); + // Accumulate small scroll events relative to the last request, not each other. + const movedRows = + Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) + + Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex); + if (movedRows >= 20) { + flush(); + } else if (movedRows > 0) { + // Cover the final viewport even when scrolling stops below the threshold. + timer = setTimeout(flush, 150); + } + }, + reset() { + cancel(); + requestedRange = { firstRowIndex: 0, lastRowIndex: 80 }; + visibleRange = requestedRange; + }, + cancel, + }; +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 35f06c263666..61a205f9d917 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { highlightNativeReviewDiffVisibleRows, @@ -8,10 +8,10 @@ import { import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; -interface NativeReviewVisibleRange { - readonly firstRowIndex: number; - readonly lastRowIndex: number; -} +import { + createReviewDiffHighlightScheduler, + type NativeReviewVisibleRange, +} from "./reviewDiffHighlightScheduler"; function createEmptyTokenPatch(resetKey: string): string { return JSON.stringify({ resetKey, tokensByRowId: {} }); @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { }) { const { enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); - const visibleRangeRef = useRef({ + const [visibleRange, setVisibleRange] = useState({ firstRowIndex: 0, lastRowIndex: 80, }); const visibleChunkIndexRef = useRef(0); const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); - const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); + const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); useEffect(() => { + scheduler.reset(); highlightedRowIdsRef.current = new Set(); visibleChunkIndexRef.current = 0; - visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; + setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); - if (enabled && rows.length > 0) { - setVisibleHighlightRequest((request) => request + 1); - } - }, [enabled, resetKey, rows.length]); + return () => scheduler.cancel(); + }, [enabled, resetKey, rows.length, scheduler]); useEffect(() => { if (!enabled || rows.length === 0) { @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { } const abortController = new AbortController(); - const requestRange = visibleRangeRef.current; + const requestRange = visibleRange; const engine: NativeReviewDiffHighlightEngine = "native"; void (async () => { @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { })(); return () => abortController.abort(); - }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); - - const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { - const previousRange = visibleRangeRef.current; - const movedRows = - Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + - Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); - - visibleRangeRef.current = nextRange; - if (movedRows >= 20) { - setVisibleHighlightRequest((request) => request + 1); - } - }, []); + }, [enabled, files, resetKey, rows, scheme, visibleRange]); return { tokensPatchJson, - updateVisibleRange, + updateVisibleRange: scheduler.update, }; } diff --git a/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts b/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts index 62760f1e43fa..5bd7bb469827 100644 --- a/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts +++ b/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts @@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): { ); const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); const nativeReviewDiffStyle = useMemo( - () => createNativeReviewDiffStyle(codeSurface), - [codeSurface], + () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak), + [appearance.codeWordBreak, codeSurface], ); return { From adcd90858c3bfece59095465d2f49860ba3dcea9 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 20 Sep 2026 19:10:16 -0300 Subject: [PATCH 025/164] fix(web): allow full contrast in assistant replies (#12405) --- apps/web/src/components/ChatMarkdown.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index abebe4841cbd..8da35ee35cee 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -863,7 +863,10 @@ function MarkdownDetails({ {summary} -
+
{content}
@@ -3332,7 +3335,7 @@ function ChatMarkdown({
Date: Sun, 20 Sep 2026 15:51:32 -0700 Subject: [PATCH 026/164] fix(web): pull request chips share the link hover preview (#12719) --- apps/web/src/components/ChatMarkdown.test.tsx | 1 + apps/web/src/components/ChatMarkdown.tsx | 35 ++------- .../ChatMarkdown.workspace-images.test.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 3 +- .../composerContextPresentation.tsx | 4 + apps/web/src/components/contextChipParts.tsx | 58 +++++++++------ .../pullRequest/PullRequestLinkPreview.tsx | 74 +++++++++++-------- apps/web/src/lib/openPullRequestLink.ts | 51 ++++++++++++- 9 files changed, 146 insertions(+), 82 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 4e22e68ca902..3e1222e0ebb9 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8da35ee35cee..3b398dd5100e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, @@ -178,9 +177,9 @@ import { WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { - findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, + resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; @@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = { const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; - const pullRequestCandidate = pullRequestCandidateUrl - ? parseChangeRequestUrl(pullRequestCandidateUrl) + const pullRequestPreviewTarget = pullRequestCandidateUrl + ? resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + url: pullRequestCandidateUrl, + }) : null; - const pullRequestProject = - environmentId !== null && - serverConfig?.environment.capabilities.pullRequests === true && - pullRequestCandidate !== null - ? findProjectForChangeRequest( - projects.filter((project) => project.environmentId === environmentId), - pullRequestCandidate, - ) - : undefined; - const pullRequestPreviewTarget = - environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null - ? null - : { - environmentId, - input: { - projectId: pullRequestProject.id, - host: pullRequestCandidate.authority ?? pullRequestCandidate.host, - repository: - sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? - pullRequestCandidate.repository, - number: pullRequestCandidate.number, - }, - }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 344eca250ce9..bab751c74816 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 255f61a0c752..6ae4d6fe390e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const previewFile = composerFiles.find((file) => file.id === previewFileId); const composerContextActions = useMemo( () => ({ + environmentId, expandImage: (imageId: string) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 17e8fb1f56ca..8517ff6c5114 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: { copyMarkdown: string; toneClassName: string; }) { - const { openPullRequest } = use(TimelineRowCtx); + const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx); const metadata = props.record.pullRequest; if (metadata === undefined) return null; return ( void; expandVideo: (fileId: string) => void; openFile: (fileId: string) => void; @@ -74,6 +76,7 @@ export interface ComposerContextActions { } export const ComposerContextActionsContext = createContext({ + environmentId: null, expandImage: () => {}, expandVideo: () => {}, openFile: () => {}, @@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass return ( , url: string) => void; }) { + const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); + const button = ( + + ); + if (previewTarget !== null) { + return ( + } + /> + ); + } return ( - props.onOpen(event, props.metadata.url)} - > - - {props.label} - - } - /> + diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index bbdbd9e16f99..1ed759eaa80e 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -1,6 +1,13 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; -import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react"; +import { + cloneElement, + useState, + type ComponentPropsWithoutRef, + type MouseEvent, + type ReactElement, + type ReactNode, +} from "react"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget { readonly input: PullRequestRef; } -type PullRequestLinkElement = ReactElement>; +type PullRequestLinkElement = ReactElement< + ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button"> +>; export function PullRequestLinkPreview({ link, @@ -24,13 +33,15 @@ export function PullRequestLinkPreview({ confirmBeforeOpen, onOpenPullRequest, onOpenFallback, + fallback, }: { link: PullRequestLinkElement; originalUrl: string; target: PullRequestLinkPreviewTarget; - confirmBeforeOpen: boolean; - onOpenPullRequest: (url: string) => boolean; - onOpenFallback: (url: string) => Promise; + confirmBeforeOpen?: boolean; + onOpenPullRequest?: (url: string) => boolean; + onOpenFallback?: (url: string) => Promise; + fallback?: ReactNode; }) { const [open, setOpen] = useState(false); const [resolvingClick, setResolvingClick] = useState(false); @@ -46,28 +57,29 @@ export function PullRequestLinkPreview({ reportFailure: false, reportDefect: false, }); - const trigger = confirmBeforeOpen - ? cloneElement(link, { - onClick: (event) => { - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - event.preventDefault(); - event.stopPropagation(); - if (resolvingClick) return; - setOpen(false); - setResolvingClick(true); - void readPreview(target) - .then(async (result) => { - if (isAtomCommandInterrupted(result)) return; - if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; - await onOpenFallback(originalUrl); - }) - .catch((error: unknown) => { - console.error("[pull-request-link-preview] failed to open link", error); - }) - .finally(() => setResolvingClick(false)); - }, - }) - : link; + const trigger = + confirmBeforeOpen === true + ? cloneElement(link, { + onClick: (event: MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + event.stopPropagation(); + if (resolvingClick) return; + setOpen(false); + setResolvingClick(true); + void readPreview(target) + .then(async (result) => { + if (isAtomCommandInterrupted(result)) return; + if (result._tag === "Success" && onOpenPullRequest?.(result.value.url)) return; + await onOpenFallback?.(originalUrl); + }) + .catch((error: unknown) => { + console.error("[pull-request-link-preview] failed to open link", error); + }) + .finally(() => setResolvingClick(false)); + }, + }) + : link; const detail = detailQuery.data; const state = detail === null @@ -86,9 +98,11 @@ export function PullRequestLinkPreview({ {detail !== null || detailQuery.error !== null ? ( {detail === null ? ( -

- {originalUrl} -

+ (fallback ?? ( +

+ {originalUrl} +

+ )) ) : (
diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index b691aa58cce3..da67d0b043a4 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,6 +1,7 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; -import { type MouseEvent, useCallback } from "react"; +import { type MouseEvent, useCallback, useMemo } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; @@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; +import { serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; export { @@ -93,6 +95,51 @@ export function findProjectForChangeRequest( }); } +export function resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled, + url, +}: { + environmentId: EnvironmentId | null; + projects: ReadonlyArray; + pullRequestsEnabled: boolean; + url: string; +}): { environmentId: EnvironmentId; input: PullRequestRef } | null { + if (!pullRequestsEnabled || environmentId === null) return null; + const parsed = parseChangeRequestUrl(url); + if (parsed === null) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === environmentId), + parsed, + ); + if (project === undefined) return null; + return { + environmentId, + input: { + projectId: project.id, + host: parsed.authority ?? parsed.host, + repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + number: parsed.number, + }, + }; +} + +export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { + const projects = useProjects(); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + return useMemo( + () => + resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + url, + }), + [environmentId, projects, serverConfig, url], + ); +} + /** * Any project checked out from the link's host. Thread links are host-level, so a pull request * from a repository nobody has checked out is still linkable as long as one project on that From 584450a1f198c8592d28947a0a7a060d28013298 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 20 Sep 2026 20:12:43 -0300 Subject: [PATCH 027/164] fix(web): compact the worktree setup glass popover (#12802) --- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8517ff6c5114..161adc8be00d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2570,7 +2570,12 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap {scriptName} - + Date: Sun, 20 Sep 2026 20:32:35 -0300 Subject: [PATCH 028/164] fix(web): route keyboard submit through the primary worktree action (#12526) --- .../BranchToolbarBranchSelector.tsx | 53 ++++++++++++++----- .../components/PullRequestThreadDialog.tsx | 5 +- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index d958f2f76625..a7fa1ce99724 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -545,6 +545,7 @@ export function BranchToolbarBranchSelector({ setIsBranchMenuOpen(open); if (!open) { setBranchQuery(""); + highlightedBranchValueRef.current = null; } }, []); @@ -594,6 +595,9 @@ export function BranchToolbarBranchSelector({ }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListRef = useRef(null); + // Tracks the highlighted picker value so Enter can activate it even when the + // virtualized row is not mounted (Base UI Enter clicks the mounted element). + const highlightedBranchValueRef = useRef(null); const updateBranchListScrollFades = useCallback(() => { const scrollElement = branchListRef.current?.getScrollableNode?.(); if (!(scrollElement instanceof HTMLElement)) { @@ -676,6 +680,20 @@ export function BranchToolbarBranchSelector({ const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); + function selectPickerItem(itemValue: string) { + highlightedBranchValueRef.current = null; + if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { + handleOpenChange(false); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(prReference); + } else if (itemValue === createBranchItemValue) { + createRef(trimmedBranchQuery); + } else { + const refName = branchByName.get(itemValue); + if (refName) selectBranch(refName); + } + } + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( @@ -685,15 +703,7 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-2" - onClick={() => { - if (!prReference || !onCheckoutPullRequestRequest) { - return; - } - setIsBranchMenuOpen(false); - setBranchQuery(""); - onComposerFocusRequest?.(); - onCheckoutPullRequestRequest(prReference); - }} + onClick={() => selectPickerItem(itemValue)} >
@@ -715,7 +725,7 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" - onClick={() => createRef(trimmedBranchQuery)} + onClick={() => selectPickerItem(itemValue)} > Create new ref "{newRefName}" @@ -743,7 +753,7 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" - onClick={() => selectBranch(refName)} + onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
@@ -760,7 +770,8 @@ export function BranchToolbarBranchSelector({ filteredItems={filteredBranchPickerItems} autoHighlight virtualized - onItemHighlighted={(_value, eventDetails) => { + onItemHighlighted={(value, eventDetails) => { + highlightedBranchValueRef.current = typeof value === "string" ? value : null; if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } @@ -828,6 +839,24 @@ export function BranchToolbarBranchSelector({ placeholder="Search refs..." value={branchQuery} onChange={(event) => setBranchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229) { + return; + } + const highlightedValue = highlightedBranchValueRef.current; + if ( + highlightedValue === null || + !filteredBranchPickerItems.includes(highlightedValue) + ) { + return; + } + ( + event as typeof event & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + selectPickerItem(highlightedValue); + }} />
No refs found. diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c27..ddbd2876d4d2 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -222,9 +222,12 @@ export function PullRequestThreadDialog({ if (event.key !== "Enter") { return; } + if (event.nativeEvent.isComposing || event.keyCode === 229) { + return; + } event.preventDefault(); if (!isResolving && !preparePullRequestThreadAction.isPending) { - void handleConfirm("local"); + void handleConfirm("worktree"); } }} /> From c789cd174ae50f50ecb0a34b96a81e00b0f5c823 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 20 Sep 2026 20:34:06 -0300 Subject: [PATCH 029/164] fix(web): skip image inline chip when composer is empty (#12528) --- apps/web/src/components/chat/ChatComposer.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6ae4d6fe390e..3fe6e740fb9f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -206,6 +206,7 @@ import { import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { collectInlineContextIds, + stripInlineContextReferences, type ComposerContextReference, ensureInlineContextReferences, formatInlineContextReference, @@ -5241,6 +5242,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) options?: { readonly source?: ChatFileAttachment["source"]; readonly selection?: { start: number; end: number }; + readonly skipImageInlineChip?: boolean; }, ): Promise => { if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; @@ -5260,6 +5262,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // large image is being compressed, and the attachments and errors belong // to the thread the paste happened in. const threadId = activeThreadId; + // Images landing with no prose live on the shelf with no chip. Read before + // the awaits below: compression is async and the prompt may change while it + // runs. An explicit selection replace and states where the editor refuses + // input (connecting, approval, pending questions, project selection) still + // get chips so the image is never invisible, unless paste-as-text explicitly + // requests no inline image chip. + const imageAttachmentsGetChips = + !options?.skipImageInlineChip && + (options?.selection !== undefined || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + stripInlineContextReferences(promptRef.current).trim().length > 0); // Validation happens synchronously so concurrent pastes see each other: // accepted files reserve their attachment slots (via the pending counter) @@ -5416,7 +5432,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : [], ); const storedImages = nextImages.filter((image) => storedImageIds.has(image.id)); - if (storedImages.length > 0) { + if (storedImages.length > 0 && imageAttachmentsGetChips) { insertedAny = insertAttachmentReferences(storedImages.map(imageContextReference)) || insertedAny; } @@ -5444,6 +5460,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /** * Chips for freshly attached files land at the caret; when the editor cannot take * input (approval, pending questions) they are appended so the file is never invisible. + * Images skip this when they land with no prose and the editor takes input: + * the shelf thumbnail is enough. */ const insertAttachmentReferences = ( references: ReadonlyArray, @@ -5582,7 +5600,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { event.preventDefault(); event.stopPropagation(); - void addComposerAttachments(files); + void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment }); return; } From f4d979a6c6139ab10aa1013335bd94a313a5b56e Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:35:02 -0300 Subject: [PATCH 030/164] fix(web): only show notice details when text is clipped (#12760) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> --- .../chat/ComposerBannerStack.test.tsx | 103 +++++++++++++++ .../components/chat/ComposerBannerStack.tsx | 117 ++++++++++++------ 2 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerBannerStack.test.tsx diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx new file mode 100644 index 000000000000..d2e9a649df10 --- /dev/null +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -0,0 +1,103 @@ +import { cloneElement, type ReactElement, type ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { ComposerBannerStack } from "./ComposerBannerStack"; + +vi.mock("../ui/popover", () => ({ + Popover: "popover", + PopoverTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => + cloneElement(render, {}, children), + PopoverPopup: "popup", +})); +vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); + +let renderer: ReactTestRenderer; +afterEach(async () => { + if (renderer) await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +it("only offers notice details when the description cannot fit", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let resize = () => {}; + let mutate = () => {}; + vi.stubGlobal( + "MutationObserver", + class { + constructor(callback: () => void) { + mutate = callback; + } + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect() {} + }, + ); + let position = "static"; + vi.stubGlobal("getComputedStyle", () => ({ position })); + let availableWidth = 200; + const nested = { clientWidth: 100, scrollWidth: 80 }; + const text = { + querySelectorAll: () => [nested], + get clientWidth() { + return ( + availableWidth - + (renderer?.root.findAllByProps({ "aria-label": "Show notice details" }).length ? 28 : 0) + ); + }, + scrollWidth: 80, + }; + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "span" ? text : element.type === "button" ? { offsetWidth: 24 } : null, + }, + ); + }); + const details = () => renderer.root.findAllByProps({ "aria-label": "Show notice details" }); + expect(details()).toHaveLength(0); + text.scrollWidth = 300; + await act(() => resize()); + expect(details()).toHaveLength(1); + // It fits without the icon: the icon must not keep its own overflow alive. + availableWidth = 308; + await act(() => resize()); + expect(details()).toHaveLength(0); + text.scrollWidth = 80; + await act(() => resize()); + expect(details()).toHaveLength(0); + nested.scrollWidth = 500; + await act(() => mutate()); + expect(details()).toHaveLength(1); + nested.scrollWidth = 80; + await act(() => mutate()); + expect(details()).toHaveLength(0); + position = "absolute"; + await act(() => resize()); + expect(details()).toHaveLength(1); + position = "static"; + await act(() => resize()); + expect(details()).toHaveLength(0); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index b47790b96039..ec4b44259321 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -242,6 +242,82 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro ); } +/** Keep full descriptions reachable only when their inline copy is clipped. */ +function NoticeDescription({ children, compact }: { children: ReactNode; compact?: boolean }) { + const descriptionRef = useRef(null); + const detailsRef = useRef(null); + const [showDetails, setShowDetails] = useState(false); + + useLayoutEffect(() => { + const description = descriptionRef.current; + if (!description) return; + const measure = () => { + // Ignore the space taken by the details button itself so it cannot + // sustain its own overflow after the description would otherwise fit. + const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0; + const hidden = getComputedStyle(description).position === "absolute"; + setShowDetails( + hidden || + [description, ...description.querySelectorAll("*")].some( + (element) => element.scrollWidth > element.clientWidth + recoveredWidth, + ), + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(description); + // A child can reveal new text without resizing its clipped box. + const mutations = new MutationObserver(measure); + mutations.observe(description, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + mutations.disconnect(); + }; + }, []); + + return ( + + + {children} + + {showDetails ? ( + + + } + > + + + + + {children} + + + + ) : null} + + ); +} + function ComposerBannerStackAlert({ item, attached, @@ -278,44 +354,9 @@ function ComposerBannerStackAlert({ {item.title} {item.description ? ( - - - {item.description} - - - - } - > - - - - - {item.description} - - - - + + {item.description} + ) : null} {item.actions || item.onDismiss ? ( From 9f73ca3677faa975be00f0ac55460279671df726 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 16:44:32 -0700 Subject: [PATCH 031/164] fix(devices): recover simulator streams after failures (#12639) --- .../devices/DevicePreviewRouteScreen.tsx | 4 +- .../features/devices/DeviceStreamWebView.tsx | 153 +++- .../devices/device-stream-document.test.ts | 46 +- .../devices/device-stream-document.ts | 18 +- .../devices/device-stream.browser.test.ts | 117 +++ .../features/devices/device-stream.browser.ts | 54 +- apps/server/src/device/DeviceService.test.ts | 91 +++ apps/server/src/device/DeviceService.ts | 10 +- .../components/device/DeviceLoadingView.tsx | 3 + .../device/DeviceStreamView.test.tsx | 83 +- .../components/device/DeviceStreamView.tsx | 28 +- .../components/device/deviceStream.test.ts | 345 -------- .../client-runtime/src/device/stream.test.ts | 735 ++++++++++++++++++ packages/client-runtime/src/device/stream.ts | 273 +++++-- 14 files changed, 1457 insertions(+), 503 deletions(-) create mode 100644 apps/mobile/src/features/devices/device-stream.browser.test.ts delete mode 100644 apps/web/src/components/device/deviceStream.test.ts create mode 100644 packages/client-runtime/src/device/stream.test.ts diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 82b2b90fecad..824164dc2e2b 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -68,7 +68,7 @@ function DevicePreviewScreen({ const insets = useSafeAreaInsets(); const { themeVariables } = useAppearancePreferences(); const focused = useIsFocused(); - const [foreground, setForeground] = useState(AppState.currentState === "active"); + const [foreground, setForeground] = useState(AppState.currentState !== "background"); const [selectedKey, setSelectedKey] = useState(null); const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); @@ -87,7 +87,7 @@ function DevicePreviewScreen({ ); useEffect(() => { const subscription = AppState.addEventListener("change", (state) => - setForeground(state === "active"), + setForeground(state !== "background"), ); return () => subscription.remove(); }, []); diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx index 9a5cef83ae3f..423fdb1c84a0 100644 --- a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -1,7 +1,19 @@ import deviceStreamScript from "@t3tools/mobile-device-stream"; -import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; -import { Platform } from "react-native"; +import { + useEffect, + useEffectEvent, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import { WebView } from "react-native-webview"; +import type { DeviceStreamStatus } from "@t3tools/client-runtime/device/stream"; + +import { AppText } from "../../components/AppText"; import { deviceStreamDocument, @@ -27,6 +39,7 @@ export function DeviceStreamWebView({ ...props }: DeviceStreamConfiguration & NativeStreamBridge) { const [attempt, setAttempt] = useState(0); + const processRetried = useRef(false); const configuration = JSON.stringify({ access: props.access, platform: props.platform, @@ -41,7 +54,21 @@ export function DeviceStreamWebView({ background={props.colors.background} onUnauthorized={props.onUnauthorized} onInputConnected={props.onInputConnected} - onRetry={() => setAttempt((attempt) => attempt + 1)} + onRetry={() => { + processRetried.current = false; + setAttempt((attempt) => attempt + 1); + void props.onUnauthorized(); + }} + onStreaming={() => { + processRetried.current = false; + }} + onRecoverProcess={() => { + if (processRetried.current) return false; + processRetried.current = true; + setAttempt((attempt) => attempt + 1); + void props.onUnauthorized(); + return true; + }} /> ); } @@ -53,12 +80,38 @@ function DeviceStreamDocumentView({ onUnauthorized, onInputConnected, onRetry, + onStreaming, + onRecoverProcess, }: NativeStreamBridge & { readonly configuration: string; readonly background: string; readonly onRetry: () => void; + readonly onStreaming: () => void; + readonly onRecoverProcess: () => boolean; }) { const webView = useRef(null); + const active = useRef(true); + const failed = useRef(false); + const [status, setStatus] = useState("connecting"); + const [error, setError] = useState(null); + const [started, setStarted] = useState(false); + const fail = (message: string) => { + if (!active.current || failed.current) return; + failed.current = true; + void onInputConnected(false); + webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + setError(message); + setStatus("error"); + }; + // The shared transport owns video timeouts once the document acknowledges startup. + const bootstrapTimedOut = useEffectEvent(() => + fail("Device viewer could not start. Reconnect to try again."), + ); + useEffect(() => { + if (started) return; + const timer = setTimeout(bootstrapTimedOut, 15_000); + return () => clearTimeout(timer); + }, [started]); const source = useMemo( () => ({ html: deviceStreamDocument(configuration, deviceStreamScript), @@ -78,32 +131,80 @@ function DeviceStreamDocumentView({ appSwitcher: () => command("appSwitcher"), rotate: () => command("rotate"), })); + const resetInput = useEffectEvent(() => void onInputConnected(false)); useLayoutEffect(() => { + active.current = true; + resetInput(); const view = webView.current; - return () => view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + return () => { + active.current = false; + view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + }; }, []); + const processTerminated = () => { + if (!active.current || failed.current) return; + void onInputConnected(false); + if (!onRecoverProcess()) fail("Device viewer stopped. Reconnect to try again."); + }; return ( - void onInputConnected(false)} - onShouldStartLoadWithRequest={(request) => - request.url === "about:blank" || request.url === source.baseUrl - } - onMessage={(event) => { - const message = deviceStreamMessage(event.nativeEvent.data); - if (message?.type === "unauthorized") void onUnauthorized(); - else if (message?.type === "input") void onInputConnected(message.connected); - else if (message?.type === "retry") onRetry(); - }} - /> + + fail("Device viewer could not load. Reconnect to try again.")} + onHttpError={() => fail("Device viewer could not load. Reconnect to try again.")} + onContentProcessDidTerminate={processTerminated} + onRenderProcessGone={processTerminated} + onShouldStartLoadWithRequest={(request) => + request.url === "about:blank" || request.url === source.baseUrl + } + onMessage={(event) => { + if (!active.current || failed.current) return; + const message = deviceStreamMessage(event.nativeEvent.data); + if (message?.type === "unauthorized") void onUnauthorized(); + else if (message?.type === "input") void onInputConnected(message.connected); + else if (message?.type === "retry") onRetry(); + else if (message?.type === "status") { + setStarted(true); + if (message.status === "error") fail(message.detail ?? "Device stream failed."); + else { + setStatus(message.status); + if (message.status === "streaming") onStreaming(); + } + } + }} + /> + {status !== "streaming" ? ( + + {status === "connecting" ? : null} + + {status === "error" ? error : "Connecting to device..."} + + {status === "error" ? ( + + Reconnect + + ) : null} + + ) : null} + ); } diff --git a/apps/mobile/src/features/devices/device-stream-document.test.ts b/apps/mobile/src/features/devices/device-stream-document.test.ts index ffc08667a147..95aa6a9dad23 100644 --- a/apps/mobile/src/features/devices/device-stream-document.test.ts +++ b/apps/mobile/src/features/devices/device-stream-document.test.ts @@ -1,5 +1,5 @@ import * as NodeVM from "node:vm"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document"; @@ -13,7 +13,7 @@ describe("native device stream document", () => { ); const script = html.match(/`; + const failure = `window.ReactNativeWebView.postMessage(JSON.stringify({type:"status",status:"error",detail:"Device viewer stopped unexpectedly."}));`; + return ``; } export function deviceStreamMessage(data: string) { @@ -36,6 +37,21 @@ export function deviceStreamMessage(data: string) { ) { return { type: message.type, connected: message.connected } as const; } + if ( + message.type === "status" && + "status" in message && + (message.status === "connecting" || + message.status === "streaming" || + message.status === "error") && + (!("detail" in message) || typeof message.detail === "string") + ) { + return { + type: message.type, + status: message.status, + detail: + "detail" in message && typeof message.detail === "string" ? message.detail : undefined, + } as const; + } } catch { // Ignore messages that are not part of the stream bridge. } diff --git a/apps/mobile/src/features/devices/device-stream.browser.test.ts b/apps/mobile/src/features/devices/device-stream.browser.test.ts new file mode 100644 index 000000000000..5aeefdac0203 --- /dev/null +++ b/apps/mobile/src/features/devices/device-stream.browser.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { start, stop } from "./device-stream.browser"; + +class Element extends EventTarget { + readonly style = {}; + naturalWidth = 0; + naturalHeight = 0; + src = ""; + readonly tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } + setAttribute() {} + removeAttribute(name: string) { + if (name === "src") this.src = ""; + } + append() {} +} + +async function setup() { + vi.useFakeTimers(); + const elements: Element[] = []; + vi.stubGlobal("document", { + documentElement: { style: {} }, + body: { style: {}, replaceChildren() {} }, + createElement: (tag: string) => { + const element = new Element(tag); + elements.push(element); + return element; + }, + }); + const postMessage = vi.fn(); + vi.stubGlobal("window", { ReactNativeWebView: { postMessage }, addEventListener() {} }); + vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime"))); + const sockets: Socket[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + onopen: (() => void) | null = null; + close = vi.fn(); + send = vi.fn(); + constructor() { + sockets.push(this); + } + } + vi.stubGlobal("WebSocket", Socket); + const configuration = { + platform: "ios" as const, + deviceId: "fixture-device", + access: { + httpBase: "https://device.test", + wsBase: "wss://device.test", + credentials: false, + query: {}, + }, + colors: { + background: "white", + foreground: "black", + muted: "gray", + buttonBackground: "gray", + buttonForeground: "black", + buttonBorder: "gray", + }, + }; + start(configuration); + await vi.advanceTimersByTimeAsync(0); + return { + configuration, + elements, + sockets, + messages: () => + postMessage.mock.calls.map( + ([message]) => JSON.parse(message as string) as { type: string; status?: string }, + ), + }; +} + +afterEach(() => { + stop(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("bridges shared first-frame readiness, image failure, and a successful fresh attempt to native", async () => { + const { elements, messages, sockets, configuration } = await setup(); + sockets[0]!.onopen?.(); + expect(messages()).not.toContainEqual({ type: "status", status: "streaming" }); + expect(messages()).toContainEqual({ type: "input", connected: true }); + const image = elements.find((element) => element.tag === "img")!; + image.naturalWidth = 400; + image.naturalHeight = 800; + await vi.advanceTimersByTimeAsync(250); + expect(messages()).toContainEqual({ type: "status", status: "streaming" }); + image.dispatchEvent(new Event("error")); + expect(messages()).toContainEqual({ + type: "status", + status: "error", + detail: "Could not receive the device stream. Reconnect to try again.", + }); + expect(messages()).toContainEqual({ type: "input", connected: false }); + expect(messages()).not.toContainEqual({ type: "unauthorized" }); + expect(image.src).toBe(""); + expect(sockets[0]!.close).toHaveBeenCalledOnce(); + start(configuration); + await vi.advanceTimersByTimeAsync(0); + const replacement = elements.findLast((element) => element.tag === "img")!; + replacement.naturalWidth = 400; + replacement.naturalHeight = 800; + replacement.dispatchEvent(new Event("load")); + expect(messages().at(-1)).toEqual({ type: "status", status: "streaming" }); + image.dispatchEvent(new Event("error")); + expect(sockets[1]!.close).not.toHaveBeenCalled(); + stop(); + expect(replacement.src).toBe(""); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/devices/device-stream.browser.ts b/apps/mobile/src/features/devices/device-stream.browser.ts index efa81d9a043a..0b9e98a51b9f 100644 --- a/apps/mobile/src/features/devices/device-stream.browser.ts +++ b/apps/mobile/src/features/devices/device-stream.browser.ts @@ -12,13 +12,10 @@ declare global { } let activeClient: ReturnType | null = null; -let activeImage: HTMLImageElement | null = null; export function stop() { activeClient?.stop(); activeClient = null; - activeImage?.removeAttribute("src"); - activeImage = null; } export function command(button: "home" | "back" | "appSwitcher" | "rotate") { @@ -70,34 +67,6 @@ export function start(configuration: DeviceStreamConfiguration) { image.alt = ""; image.draggable = false; image.style.display = "none"; - const overlay = document.createElement("div"); - overlay.setAttribute("role", "status"); - Object.assign(overlay.style, { - position: "fixed", - inset: "0", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: "16px", - padding: "24px", - textAlign: "center", - background: colors.background, - }); - const detail = document.createElement("span"); - const retry = document.createElement("button"); - retry.textContent = "Retry"; - Object.assign(retry.style, { - padding: "12px 24px", - borderRadius: "20px", - border: `1px solid ${colors.buttonBorder}`, - background: colors.buttonBackground, - color: colors.buttonForeground, - font: "inherit", - display: "none", - }); - retry.addEventListener("click", () => post({ type: "retry" })); - overlay.append(detail, retry); const inputStatus = document.createElement("div"); inputStatus.setAttribute("role", "status"); inputStatus.textContent = "Reconnecting device controls..."; @@ -114,11 +83,17 @@ export function start(configuration: DeviceStreamConfiguration) { }); frame.append(canvas, image); container.append(frame); - document.body.replaceChildren(container, overlay, inputStatus); + document.body.replaceChildren(container, inputStatus); let pointerId: number | null = null; let inputConnected = false; let streaming = false; + const reportStatus = (status: "connecting" | "streaming" | "error", detail?: string) => { + if (activeClient !== client) return; + streaming = status === "streaming"; + inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; + post({ type: "status", status, detail }); + }; const layout = (screen: DeviceScreenSize | null) => { const landscape = screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right"; @@ -160,19 +135,11 @@ export function start(configuration: DeviceStreamConfiguration) { { ...configuration, preferMjpeg: platform === "ios" }, canvas, { - onStatus: (status, message) => { - streaming = status === "streaming"; - overlay.style.display = streaming ? "none" : "flex"; - inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; - detail.textContent = - status === "error" ? (message ?? "Device stream failed.") : "Connecting to device..."; - retry.style.display = status === "error" ? "block" : "none"; - }, + onStatus: reportStatus, onScreen: layout, - onMjpegFallback: (url) => { + onMjpegFallback: () => { canvas.style.display = "none"; image.style.display = "block"; - image.src = url; }, onUnauthorized: unauthorized, onInputConnected: (connected) => { @@ -183,8 +150,7 @@ export function start(configuration: DeviceStreamConfiguration) { }, ); activeClient = client; - activeImage = image; - image.addEventListener("error", unauthorized); + client.setMjpegImage(image); const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => { const rect = frame.getBoundingClientRect(); client.sendTouch( diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index f1ed9a7f0253..c70f30b1fe87 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -11,6 +11,7 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; @@ -19,6 +20,8 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", @@ -378,3 +381,91 @@ it.effect("keeps shutdown successful when subsequent discovery fails", () => expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false); }).pipe(Effect.scoped), ); + +it.effect.each(["shutdown", "close"] as const)( + "%s releases iOS capture so reopening uses a fresh session", + (operation) => + Effect.gen(function* () { + const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111"); + const threadId = ThreadId.make("capture-recovery"); + let booted = true; + let capture: number | null = null; + let generation = 0; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + if (path === "/api/devices") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted, + }, + ], + }), + ); + } + if (path === "/vendor/serve-sim/grid/api/start") capture ??= ++generation; + else if (path === "/vendor/serve-sim/grid/api/shutdown") { + if (request.body._tag !== "Uint8Array") throw new Error("Missing shutdown body"); + expect(decodeJson(new TextDecoder().decode(request.body.body))).toEqual({ + udid: deviceId, + }); + capture = null; + booted = false; + } else if (path === "/api/devices/shutdown") { + // This route powers off without releasing serve-sim's cached capture. + booted = false; + } else if (path === "/api/devices/boot") booted = true; + else throw new Error(`Unexpected hub path: ${path}`); + return HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })); + }), + ); + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + const input = { threadId, deviceId, platform: "ios" as const }; + yield* service.open(input); + expect(capture).toBe(1); + if (operation === "shutdown") yield* service.shutdown(input); + else yield* service.close({ threadId, deviceId, shutdown: true }); + expect(capture).toBeNull(); + expect((yield* service.state).sessions).toEqual([]); + yield* service.open(input); + expect(capture).toBe(2); + expect((yield* service.state).sessions).toHaveLength(1); + }).pipe( + Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })), + Effect.scoped, + ), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 30e1f18c0497..8b7db9b5e590 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -670,8 +670,14 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* platform: DevicePlatform, ) { const ready = yield* readiness(hostId); - yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/shutdown`).pipe( - HttpClientRequest.bodyJson({ platform, id: deviceId }), + // serve-sim's shutdown closes its in-process capture session before powering off. + // The hub's generic shutdown can leave that session cached across a reboot. + const path = + platform === "ios" ? `${vendorPrefix("ios")}/grid/api/shutdown` : "/api/devices/shutdown"; + yield* HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe( + HttpClientRequest.bodyJson( + platform === "ios" ? { udid: deviceId } : { platform, id: deviceId }, + ), Effect.mapError( (cause) => new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), diff --git a/apps/web/src/components/device/DeviceLoadingView.tsx b/apps/web/src/components/device/DeviceLoadingView.tsx index 70e848c3ce7e..332d3994cca1 100644 --- a/apps/web/src/components/device/DeviceLoadingView.tsx +++ b/apps/web/src/components/device/DeviceLoadingView.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Smartphone } from "lucide-react"; import { Spinner } from "~/components/ui/spinner"; @@ -8,6 +9,7 @@ export function DeviceLoadingView(props: { readonly stage: "opening" | "stream"; readonly message: string; readonly error?: boolean; + readonly children?: ReactNode; }) { return (
: null} {props.message}
+ {props.children} {!props.error ? (
({ refreshDeviceHubAccess: vi.fn(), })); const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }; -vi.mock("@t3tools/client-runtime/device/stream", () => ({ - createDeviceStreamClient: ( - _target: unknown, - _canvas: unknown, - events: { onMjpegFallback: (url: string) => void }, - ) => ({ - start: () => events.onMjpegFallback("http://test/stream.mjpeg"), - stop: vi.fn(), - }), -})); import { DeviceStreamView } from "./DeviceStreamView"; + +class Image extends EventTarget { + src = ""; + naturalWidth = 0; + naturalHeight = 0; + removeAttribute(name: string) { + if (name === "src") this.src = ""; + } +} let renderer: ReactTestRenderer | undefined; afterEach(async () => { await act(async () => renderer?.unmount()); + renderer = undefined; + vi.useRealTimers(); vi.unstubAllGlobals(); }); -it("removes MJPEG requests while hidden and reconnects when shown", async () => { +async function setup() { + vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime"))); + vi.stubGlobal( + "WebSocket", + class { + static OPEN = 1; + readyState = 1; + send() {} + close() {} + }, + ); vi.stubGlobal( "ResizeObserver", class { @@ -34,6 +46,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () => disconnect() {} }, ); + const images: Image[] = []; const view = (visible: boolean) => (
); } diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index a2b8e92d86fe..48a371a7a9c0 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -52,13 +52,20 @@ describe("pullRequestChecksState", () => { }); }); -/** Every element of the tree the row returned, so a nested indicator can be looked for. */ +/** + * Every element of the tree the row returned, so a nested indicator can be looked for. The row + * hands its slots to the shared row lines as props rather than children, so every prop that + * holds an element is walked too. + */ function flatten(node: ReactNode): ReadonlyArray> { const found: unknown[] = []; for (const child of Children.toArray(node)) { if (!isValidElement(child)) continue; found.push(child); - found.push(...flatten((child.props as { readonly children?: ReactNode }).children)); + for (const value of Object.values(child.props as Record)) { + if (isValidElement(value)) found.push(...flatten(value)); + else if (Array.isArray(value)) found.push(...flatten(value.filter(isValidElement))); + } } return found as ReadonlyArray>; } diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 65caae761ced..d46cad36622c 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -4,7 +4,9 @@ import type { PullRequestCheck, PullRequestCheckStatus, PullRequestChecksState, + PullRequestLabel, PullRequestMergeability, + PullRequestReviewDecision, PullRequestState, } from "@t3tools/contracts"; import { @@ -13,14 +15,17 @@ import { CircleDotIcon, CircleXIcon, UserCheckIcon, + UserRoundIcon, + UserRoundXIcon, } from "lucide-react"; -import { Children, isValidElement, type ReactNode, useState } from "react"; +import { Children, type CSSProperties, isValidElement, type ReactNode, useState } from "react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic"; +import { pullRequestLabelColor } from "./pullRequestList.logic"; import { PULL_REQUEST_STATE_PRESENTATION, PullRequestGlyph, @@ -28,17 +33,87 @@ import { type PullRequestGlyphIcon, } from "./pullRequestIcons"; -export function PullRequestApprovalGlyph() { +/** + * A host label as a flat tinted tag in the label's own color: a wash of it behind, the name + * in a mix of it and the theme foreground. The mix leans to the foreground because hosts hand + * out any color at all: at 30% of the label on light and 45% on dark, white, black and + * GitHub's pale yellows all clear 4.5:1 on their wash, selected row included, and + * saturated colors sit well above. + * A label with no usable color falls back to the muted tag. Children ride after the name, + * for an overflow count. The height is pinned so a labeled row is as tall as one without. + */ +export function PullRequestLabelChip({ + label, + size = "sm", + className, + children, +}: { + label: Pick; + size?: "sm" | "default"; + className?: string; + children?: ReactNode; +}) { + const color = pullRequestLabelColor(label.color); + return ( + + {label.name} + {children} + + ); +} + +/** + * The review verdict as one glyph beside the checks glyph, so a row answers both "does it + * build" and "did someone say yes" in the same spot. "Awaiting review" is only drawn when the + * host reports it, which on GitHub means the branch rules require a review nobody has given. + */ +function reviewDecisionPresentation(decision: PullRequestReviewDecision) { + switch (decision) { + case "approved": + return { + Icon: UserCheckIcon, + label: "Approved", + toneClassName: CHECK_STATUS_PRESENTATION.success.toneClassName, + }; + case "changes-requested": + return { + Icon: UserRoundXIcon, + label: "Changes requested", + toneClassName: "text-amber-600/90 dark:text-amber-400/80", + }; + case "review-required": + return { + Icon: UserRoundIcon, + label: "Awaiting review", + toneClassName: "text-muted-foreground/60", + }; + } +} + +export function PullRequestReviewDecisionGlyph({ + decision, +}: { + decision: PullRequestReviewDecision; +}) { + const presentation = reviewDecisionPresentation(decision); return ( }> - - Approved + + {presentation.label} - Approved + {presentation.label} ); } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f54415b1986c..485fa7718e3e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -24,11 +24,13 @@ import { LayersIcon, ListChecksIcon, PenLineIcon, + UsersIcon, Plug2Icon, Maximize2Icon, Minimize2Icon, SearchIcon, UserLockIcon, + type LucideIcon, } from "lucide-react"; import { useCallback, @@ -146,6 +148,7 @@ import { } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; +import { Separator } from "~/components/ui/separator"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -188,6 +191,32 @@ export interface PullRequestsSearch extends PullRequestListPreferences { readonly selectedEnvironmentId?: EnvironmentId; } +/** + * A group reads like the sidebar's shelves: its glyph, its name, how many, then a rule out + * to the edge. The glyph is the one the involvement filter uses for the same idea. + */ +const GROUP_ICONS: Record = { + authored: PenLineIcon, + reviewRequested: EyeIcon, + others: UsersIcon, +}; + +function PullRequestGroupHeader({ + group, +}: { + group: { key: string; label: string; entries: ReadonlyArray }; +}) { + const Icon = GROUP_ICONS[group.key] ?? LayersIcon; + return ( +
+ +

{group.label}

+ {group.entries.length} + +
+ ); +} + // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, @@ -1659,11 +1688,7 @@ function PullRequestsRouteView() {
{displayGroups.map((group) => (
- {group.label ? ( -

- {group.label} -

- ) : null} + {group.label ? : null} {group.entries.map((entry) => { const entryKey = pullRequestEntryKey(entry); return ( From 2efb8178d83f4f7c4ccc3ae1165f2a2ded0b2742 Mon Sep 17 00:00:00 2001 From: Simone Date: Mon, 21 Sep 2026 03:29:55 +0200 Subject: [PATCH 037/164] fix(clients): keep backslashes in copied Codex citations (#12243) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../client-runtime/src/codexFileCitations.ts | 1 + .../src/codexMarkdownDirectives.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/client-runtime/src/codexFileCitations.ts b/packages/client-runtime/src/codexFileCitations.ts index b5042f0806e6..d29996501ea6 100644 --- a/packages/client-runtime/src/codexFileCitations.ts +++ b/packages/client-runtime/src/codexFileCitations.ts @@ -44,6 +44,7 @@ function markdownLabel(value: string): string { function markdownDestination(value: string): string { return value + .replaceAll("\\", "%5C") .replaceAll("<", "%3C") .replaceAll(">", "%3E") .replaceAll("\r", "%0D") diff --git a/packages/client-runtime/src/codexMarkdownDirectives.test.ts b/packages/client-runtime/src/codexMarkdownDirectives.test.ts index b2a22901eac2..efd5262d073c 100644 --- a/packages/client-runtime/src/codexMarkdownDirectives.test.ts +++ b/packages/client-runtime/src/codexMarkdownDirectives.test.ts @@ -8,6 +8,7 @@ import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "./codexMarkdownDirectives.js"; +import { parseMarkdownFileLink } from "./markdownLinks.js"; interface TestNode { readonly type: string; @@ -89,6 +90,24 @@ describe("remarkCodexDirectives", () => { }); }); +describe.each([ + { name: "renderCodexDirectivesForCopy", render: renderCodexDirectivesForCopy }, + { name: "renderCodexFileCitationsAsMarkdown", render: renderCodexFileCitationsAsMarkdown }, +])("$name file citation round trips", ({ render }) => { + it.each([ + "C:\\Users\\test\\[draft]\\report.md", + "\\\\server\\share\\report.md", + "outputs/report.md", + "/tmp/report%5C.md", + ])("preserves the literal path and line: %s", (path) => { + const markdown = render(`:codex-file-citation{path="${path}" line_range_start="7"}`); + const link = parseOrdinaryMarkdown(markdown).children?.[0]?.children?.[0]; + + expect(link?.type).toBe("link"); + expect(parseMarkdownFileLink(link?.url ?? "")).toEqual({ path, line: 7 }); + }); +}); + describe("native Markdown adapters", () => { it("uses the same parser to render file citations as portable links", () => { expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( From f7efb5354a3e6fb5e6b13a658ba57020e2c554ad Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 20 Sep 2026 23:43:39 -0300 Subject: [PATCH 038/164] feat(web): truncate branch names and paths in the middle (#12805) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/BranchToolbar.tsx | 7 +- .../BranchToolbarBranchSelector.tsx | 12 +-- apps/web/src/components/Sidebar.tsx | 11 ++- .../src/components/ThreadCommandSubtitle.tsx | 4 +- .../src/components/chat/ChangedFilesTree.tsx | 8 +- .../src/components/chat/WorktreeSetupCard.tsx | 13 +++- .../pullRequest/PullRequestDetailPanel.tsx | 21 ++++-- .../pullRequest/PullRequestGhosts.tsx | 5 +- .../pullRequest/PullRequestListRow.tsx | 13 +++- .../web/src/components/ui/middle-truncate.tsx | 73 +++++++++++++++++++ 10 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/ui/middle-truncate.tsx diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index cdf3aeffbea1..ffc89c33e794 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -54,6 +54,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; @@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + @@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ - {previousWorktreeLabel} + ) : null} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index a7fa1ce99724..9838593a23f7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -73,6 +73,7 @@ import { } from "./ui/combobox"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; export interface BranchToolbarBranchSelectorHandle { open: () => void; @@ -757,7 +758,7 @@ export function BranchToolbarBranchSelector({ onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
- {itemValue} + {badge && {badge}}
@@ -818,12 +819,11 @@ export function BranchToolbarBranchSelector({ data-composer-label className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" > - - {triggerLabel} - + className="flex w-full max-w-[240px] transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" + /> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a03196fc3b8d..cffe4c7dd0a9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -242,6 +242,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { composerDraftHasUserContent, DraftId, @@ -379,7 +380,7 @@ function SidebarThreadTooltip({ {thread.branch ? (
-
{thread.branch}
+
) : null} {branchMismatch ? ( @@ -1932,9 +1933,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {thread.branch ? ( <> - - {thread.branch} - + ) : ( diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 275105a79099..58c28609dcef 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { cn } from "~/lib/utils"; +import { MiddleTruncate } from "./ui/middle-truncate"; + /** * Flip this while reviewing command-palette thread subtitles. * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default) @@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: { {projectLabel ? : null} - {branchLabel} + ) : null} diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index f0cdc0163ce6..02155ac7c72c 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - - {node.name} - + {node.stat && ( diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 6fbe7a2b9b5e..bbcd92a2f98b 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -19,6 +19,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Spinner } from "~/components/ui/spinner"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { cn } from "~/lib/utils"; @@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { {snapshot.branch ? ( <>
Branch
-
{snapshot.branch}
+
+ +
) : null} {snapshot.baseRef ? ( <>
Base
-
{snapshot.baseRef}
+
+ +
) : null} {snapshot.worktreePath ? ( <>
Path
-
{snapshot.worktreePath}
+
+ +
) : null} {snapshot.setupScript ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 44b70f2e18ff..c1c4b3942804 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -121,6 +121,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; @@ -2267,7 +2268,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + + ) : ( @@ -2280,7 +2283,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + +
} /> @@ -2298,7 +2303,9 @@ export function PullRequestDetailPanel({ {detail.headBranch} + + + } /> {detail.headBranch} @@ -2452,7 +2459,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + + ) : ( @@ -2465,7 +2474,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + +
} /> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 40a0af103bc0..d30fe5109703 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button, InlineButton } from "../ui/button"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActorLabel, @@ -252,7 +253,9 @@ export function PullRequestDetailGhost({ {seed ? ( - {seed.baseBranch} + + + ) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index e77f82717698..ea1577a23558 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestActorAvatar, @@ -148,11 +149,17 @@ export function PullRequestRowAuthor({ ); } -/** `head → base`, in the mono the branches are typed in. */ +/** + * `head → base`, in the mono the branches are typed in, each cut in the middle when the row is + * short of room. The base keeps its width up to a share of the line, so a long head cannot + * squeeze a short `main` out; the arrow stays readable so the two are not read as one name. + */ export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { return ( - - {head} → {base} + + + → + ); } diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx new file mode 100644 index 000000000000..fd8a25447738 --- /dev/null +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -0,0 +1,73 @@ +import type { ComponentProps } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * Truncates in the middle, the way Finder does, for strings that carry meaning at both ends: + * branch names, paths, worktree names, shas. A tail-cut `fix/cache-main-20260918-180825` loses + * the date that tells two branches apart; a path loses its file name. + * + * CSS has no middle ellipsis, so the string is split into a head that truncates and a tail + * that does not. No measuring and no observers, so it costs the same as `truncate` in a long + * list and works inside `content-visibility` skipping. Both halves are real text: selection + * and copy give the whole string, and a screen reader reads it through. + */ +export function MiddleTruncate({ + value, + tail, + showTitle = true, + className, + ...props +}: Omit, "children"> & { + value: string; + /** Characters kept at the end. Defaults to the last path segment, capped, or 10. */ + tail?: number; + /** The full value on hover. Off where a tooltip already carries it. */ + showTitle?: boolean; +}) { + const split = splitForMiddleTruncate(value, tail); + return ( + + {split ? ( + <> + {split.head} + {split.tail} + + ) : ( + {value} + )} + + ); +} + +const DEFAULT_TAIL = 10; +const MAX_SEGMENT_TAIL = 16; + +/** + * Where to cut. A path keeps its last segment when that is short enough to be the useful + * part; anything else keeps a fixed count. Nothing is split when the tail would be most of + * the string, since then the head could never show enough to be worth an ellipsis. + */ +export function splitForMiddleTruncate( + value: string, + tail?: number, +): { head: string; tail: string } | null { + // Code points, not UTF-16 units: a cut inside a surrogate pair would render two broken + // glyphs where an emoji or a CJK extension character used to be. + const chars = Array.from(value); + let keep = tail ?? DEFAULT_TAIL; + if (tail === undefined) { + const slash = chars.lastIndexOf("/"); + if (slash > 0 && slash < chars.length - 1) { + const segment = chars.length - slash - 1; + keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; + } + } + if (keep <= 0 || chars.length <= keep + 4) return null; + const cut = chars.length - keep; + return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") }; +} From dca84efb53b071691a88c6712751fb7de9dcbfdb Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:01:10 -0300 Subject: [PATCH 039/164] fix(web): paste markdown with inline code inside bold, italic, or strikethrough (#12290) --- apps/web/package.json | 1 + apps/web/src/components/ComposerPromptEditorTiptap.tsx | 5 ++++- apps/web/src/composer-rich-text-doc.test.ts | 9 +++++++++ apps/web/src/composer-rich-text-doc.ts | 8 ++++++++ pnpm-lock.yaml | 3 +++ 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..ec7334e1fd86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index c108207304c4..df13d76c2a4f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -46,6 +46,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -754,8 +755,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { dropcursor: false, gapcursor: false, trailingNode: false, + code: false, // Plain mode has no marks: typed markers stay literal characters. - ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), + ...(richText ? {} : { bold: false, italic: false, strike: false }), }), ComposerMentionExtension, ComposerSkillExtension, @@ -764,6 +766,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ + ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ addInputRules() { diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index a8798708974b..8fa311b1999c 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -38,7 +39,9 @@ const schema = getSchemaByResolvedExtensions( dropcursor: false, gapcursor: false, trailingNode: false, + code: false, }), + ComposerCodeExtension, stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), stubAtom("composer-skill", { skillName: { default: "" }, @@ -64,6 +67,8 @@ const schema = getSchemaByResolvedExtensions( function roundTrip(value: string) { const json = buildDocJson(value, (name) => ({ label: name, description: null })); const doc = ProseMirrorNode.fromJSON(schema, json); + // `insertContent` validates every node against the schema; `fromJSON` does not. + doc.check(); return serializeEditorDoc(doc); } @@ -135,6 +140,10 @@ describe("composer rich text document model", () => { "hello **bold** world", "a *italic* word and `code` here", "struck ~~out~~ now", + "**`x`**", + "*`x`*", + "~~`x`~~", + "**a `code` c**", "***bold italic*** keeps nesting", "line one\nline two", "trailing newline\n", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index e6cedcc5abb3..ed83c35b5f5b 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,4 +1,5 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Code } from "@tiptap/extension-code"; import { TaskItem } from "@tiptap/extension-task-item"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; @@ -41,6 +42,13 @@ const TIPTAP_TO_MARK: Record = { code: "code", }; +/** + * Tiptap's code mark excludes every other mark, which rejects the `bold+code` + * spans markdown like `**\`x\`**` parses into and drops the whole insert. + * Code nests inside emphasis here, so it only excludes itself like the rest. + */ +export const ComposerCodeExtension = Code.extend({ excludes: "code" }); + /** * Task list items keep their exact source indent in an attribute so nesting * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0daed3ff962..6b4d8281b7df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,9 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) From a9ab9049bbbab89fe23dcfb26c3169ef70a0597e Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 21 Sep 2026 00:16:10 -0300 Subject: [PATCH 040/164] feat(web): show the pull request refresh spinning in the detail header (#12833) Co-authored-by: Claude Fable 5.1 --- .../pullRequest/PullRequestDetailPanel.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index c1c4b3942804..f19e41599043 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -849,6 +849,9 @@ export function PullRequestDetailPanel({ // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [isInvalidating, setIsInvalidating] = useState(false); + // One word for "the host is being asked again", whichever of the two halves is in flight: + // the invalidation round trip, then the detail read it kicks off. + const refreshing = isInvalidating || detailQuery.isPending; const refreshFromHost = useCallback(async () => { setIsInvalidating(true); try { @@ -1987,18 +1990,29 @@ export function PullRequestDetailPanel({ } > - + {/* The refresh lives in this menu, so while one runs the trigger wears + the spinning glyph in place of the dots: the reader sees the panel + is fetching without a control appearing or the row shifting. */} + {refreshing ? ( + + ) : ( + + )} } /> - More pull request actions + + {refreshing ? "Refreshing pull request" : "More pull request actions"} + - void refreshFromHost()} - > - + void refreshFromHost()}> + Refresh From a4bc7deb992cc3d48dd4a40ee1f84254f190f318 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 20 Sep 2026 22:29:55 -0500 Subject: [PATCH 041/164] fix(web): dismiss composer suggestions with Escape (#12836) --- .../components/ComposerPromptEditorTiptap.tsx | 6 +- apps/web/src/components/chat/ChatComposer.tsx | 52 +++++-- .../chat/useComposerTriggerState.test.tsx | 145 ++++++++++++++++++ .../chat/useComposerTriggerState.ts | 42 +++++ 4 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerTriggerState.test.tsx create mode 100644 apps/web/src/components/chat/useComposerTriggerState.ts diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index df13d76c2a4f..0e452fd4ac26 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -137,7 +137,7 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem?: boolean, ) => boolean; @@ -912,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ? ("ArrowDown" as const) : event.key === "ArrowUp" ? ("ArrowUp" as const) - : null; + : event.key === "Escape" + ? ("Escape" as const) + : null; if (!key) return false; const handled = handler(key, event); if (handled) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3fe6e740fb9f..7b460e44e4ca 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -124,6 +124,7 @@ import { import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerMenuState } from "./useComposerMenuState"; +import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { @@ -2065,9 +2066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); + const { + trigger: composerTrigger, + setTrigger: setComposerTrigger, + resolveTrigger: resolveComposerTrigger, + dismissTrigger: dismissComposerTrigger, + resetTrigger: resetComposerTrigger, + } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); // Active ArrowUp recall. Cleared on edit and on thread switch. const promptHistoryPositionRef = useRef(null); @@ -2586,7 +2591,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + setComposerTrigger, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -3090,7 +3101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = prompt; const { cursor, trigger } = composerStateAtPromptEnd(prompt); setComposerCursor(cursor); - setComposerTrigger(trigger); + resetComposerTrigger(trigger); } lastSyncedPendingInputRef.current = null; return; @@ -3115,7 +3126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = nextCustomAnswer; const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); setComposerCursor(cursor); - setComposerTrigger(trigger); + resetComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, @@ -3123,6 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingUserInput?.requestId, prompt, promptRef, + resetComposerTrigger, ]); // ------------------------------------------------------------------ @@ -3133,10 +3145,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerSubmissionError(null); setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); + }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -3419,6 +3431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, + setComposerTrigger, composerDraftTarget, composerTerminalContexts, setComposerDraftTerminalContexts, @@ -3514,6 +3527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, + setComposerTrigger, ], ); @@ -3542,9 +3556,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: resolveComposerTrigger( + detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + ), }; - }, [readComposerSnapshot]); + }, [readComposerSnapshot, resolveComposerTrigger]); const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( @@ -3897,7 +3913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(null); setComposerHighlightedItemId(null); }, - [composerDraftTarget, promptRef, setComposerDraftPrompt], + [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger], ); const navigatePromptHistory = useCallback( @@ -3956,7 +3972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem = false, ) => { @@ -3967,6 +3983,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; + if (key === "Escape") { + if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + dismissComposerTrigger(trigger); + composerMenuOpenRef.current = false; + return true; + } if (menuIsActive) { const currentItems = composerMenuItemsRef.current; const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; @@ -4356,6 +4378,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, promptRef, setComposerDraftPrompt, + setComposerTrigger, takeStashEntry, importContextRecords, ], @@ -4619,6 +4642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) clearComposerDraftPromptAndImages, clearComposerDraftTerminalContexts, setComposerDraftPrompt, + setComposerTrigger, composerDraftTarget, composerFilesRef, composerImagesRef, @@ -5932,7 +5956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); setComposerHighlightedItemId(null); setComposerCursor(cursor); - setComposerTrigger( + resetComposerTrigger( options?.detectTrigger ? detectComposerTrigger( promptForState, @@ -6041,6 +6065,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerModelPickerOpen, openModelPicker, readComposerSnapshot, + resetComposerTrigger, + setComposerTrigger, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, diff --git a/apps/web/src/components/chat/useComposerTriggerState.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.test.tsx new file mode 100644 index 000000000000..6daf88bfbd67 --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.test.tsx @@ -0,0 +1,145 @@ +import { act, StrictMode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { detectComposerTrigger } from "../../composer-logic"; +import { useComposerTriggerState } from "./useComposerTriggerState"; + +const command = "pnpm install -g @openai/codex@latest"; +const initialPrompt = "pnpm install -g @openai"; +let root: Root; +let composer: ReturnType; + +function ComposerProbe() { + const state = useComposerTriggerState(() => + detectComposerTrigger(initialPrompt, initialPrompt.length), + ); + useLayoutEffect(() => { + composer = state; + }); + return null; +} + +async function updatePrompt(text: string, cursor = text.length) { + await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); +} + +beforeEach(async () => { + // The probe renders no DOM nodes, but ReactDOM still needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => + root.render( + + + , + ), + ); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer suggestion dismissal", () => { + it("closes suggestions and rejects keyboard selection before the next render", async () => { + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + expect(composer.trigger).toEqual(candidate); + + await act(() => { + composer.dismissTrigger(candidate); + expect(composer.resolveTrigger(candidate)).toBeNull(); + }); + expect(composer.trigger).toBeNull(); + }); + + it("stays dismissed while typing a scoped package, including its second @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) { + const text = command.slice(0, cursor); + await updatePrompt(text); + expect(composer.trigger).toBeNull(); + expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull(); + } + }); + + it("stays dismissed while deleting characters or moving within the same word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) { + await updatePrompt(initialPrompt, cursor); + expect(composer.trigger).toBeNull(); + await updatePrompt(initialPrompt.slice(0, cursor)); + expect(composer.trigger).toBeNull(); + } + }); + + it("opens suggestions for a new @ word after a space", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} `); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + expect(composer.trigger?.rangeStart).toBe(command.length + 1); + }); + + it("opens a different token when the caret moves directly to it", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + }); + + it("can reopen after the caret leaves the dismissed word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(initialPrompt, 0); + await updatePrompt(initialPrompt); + + expect(composer.trigger?.query).toBe("openai"); + }); + + it("can reopen at the same position after deleting and retyping @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@")); + await updatePrompt(prefix); + await updatePrompt(`${prefix}@`); + + expect(composer.trigger?.query).toBe(""); + }); + + it("clears dismissal when switching drafts or pending questions", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + await act(() => composer.resetTrigger(candidate)); + + expect(composer.trigger).toEqual(candidate); + expect(composer.resolveTrigger(candidate)).toEqual(candidate); + }); + + it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + await updatePrompt(text); + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${text}x`); + + expect(composer.trigger).toBeNull(); + await updatePrompt("@src"); + expect(composer.trigger?.kind).toBe("path"); + }); +}); diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts new file mode 100644 index 000000000000..255a8c43735c --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -0,0 +1,42 @@ +import { useCallback, useRef, useState } from "react"; + +import type { ComposerTrigger } from "../../composer-logic"; + +/** Keep a dismissed suggestion closed until the caret leaves its token. */ +export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { + const [trigger, setActiveTrigger] = useState(initialTrigger); + const dismissedTriggerRef = useRef(null); + + const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => { + const dismissed = dismissedTriggerRef.current; + return candidate && + dismissed && + candidate.kind === dismissed.kind && + candidate.rangeStart === dismissed.rangeStart + ? null + : candidate; + }, []); + + const setTrigger = useCallback( + (candidate: ComposerTrigger | null) => { + const activeTrigger = resolveTrigger(candidate); + if (candidate === null || activeTrigger !== null) { + dismissedTriggerRef.current = null; + } + setActiveTrigger(activeTrigger); + }, + [resolveTrigger], + ); + + const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = candidate; + setActiveTrigger(null); + }, []); + + const resetTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = null; + setActiveTrigger(candidate); + }, []); + + return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; +} From a6cb1dd2054c96c5be41980e63594ea51554a552 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:09:06 -0400 Subject: [PATCH 042/164] fix(mobile): keep the source worktree when starting a thread on a branch (#12623) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../src/features/threads/new-task-flow-provider.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5c74e52cfac1..4a912f171a55 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -873,12 +873,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { useEffect(() => { if ( + !selectedProjectDraftKey || !defaultWorkspaceModeSettled || workspaceMode !== "worktree" || selectedBranchName !== null ) { return; } + // The draft screen writes a thread's branch and worktree into the draft in + // the same commit this effect runs, so the rendered selection above can be + // stale. Re-read the draft before replacing it. + const live = getComposerDraftSnapshot(selectedProjectDraftKey).workspaceSelection; + if (live && (live.mode !== "worktree" || live.branch !== null)) { + return; + } // The default may only exist as origin/ (isRemote), which // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = @@ -894,6 +902,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { defaultWorkspaceModeSettled, selectBranch, selectedBranchName, + selectedProjectDraftKey, workspaceMode, ]); From 6cc7f7006e24283072cc3e0b3ed8ced80c6d71a5 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 21 Sep 2026 01:41:28 -0300 Subject: [PATCH 043/164] fix(web): keep composer controls visible while they fit (#12837) --- apps/web/src/components/chat/ChatComposer.tsx | 211 +++++++++--------- apps/web/src/components/chat/TraitsPicker.tsx | 97 +++++--- ...restingComposerControlsMeasurement.test.ts | 11 +- .../restingComposerControlsMeasurement.ts | 31 ++- .../components/composerFooterLayout.test.ts | 76 +++++++ .../src/components/composerFooterLayout.ts | 70 +++--- 6 files changed, 327 insertions(+), 169 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7b460e44e4ca..9cbf733e1061 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -53,7 +53,6 @@ import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { - Fragment, memo, type ComponentProps, type ReactNode, @@ -187,8 +186,6 @@ import { import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { - COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, - COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, @@ -197,7 +194,6 @@ import { shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; -import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ComposerContextActionsContext, @@ -994,22 +990,32 @@ const extendReplacementRangeForTrailingSpace = ( return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; -function useRestingComposerControlsLayout(host: HTMLDivElement | null) { +function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) { + const [controls, setControls] = useState(null); const controlsRef = useRef(null); + const attachControls = useCallback((element: HTMLDivElement | null) => { + controlsRef.current = element; + setControls(element); + }, []); const hostRef = useRef(host); hostRef.current = host; - const [layout, setLayout] = useState({ hiddenCount: 0, visible: true }); + const [layout, setLayout] = useState<{ + hiddenCount: number; + iconOnlyCount?: number; + visible: boolean; + }>({ hiddenCount: 0, visible: true }); const measure = useCallback(() => { - const currentHost = hostRef.current; - const controls = controlsRef.current; - // The controls only mount while the composer rests, so the expanded - // composer pays no layout reads here despite the every-render effect. + const currentHost = useControlsAsHost ? controls : hostRef.current; if (currentHost === null || !controls) return; const measurement = measureRestingComposerControls(controls); if (!measurement) return; - const hostWidth = currentHost.clientWidth; + const style = getComputedStyle(currentHost); + const hostWidth = + currentHost.clientWidth - + (Number.parseFloat(style.paddingInlineStart) || 0) - + (Number.parseFloat(style.paddingInlineEnd) || 0); setLayout((current) => { const next = resolveRestingComposerControlsLayout({ @@ -1017,25 +1023,48 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { hostWidth, previous: current, }); - return next.hiddenCount === current.hiddenCount && next.visible === current.visible + return next.hiddenCount === current.hiddenCount && + next.iconOnlyCount === current.iconOnlyCount && + next.visible === current.visible ? current : next; }); - }, []); + }, [controls, useControlsAsHost]); - useLayoutEffect(measure); + useLayoutEffect(measure, [measure, host]); useEffect(() => { - if (!host) return; + const currentHost = useControlsAsHost ? controls : host; + if (!currentHost || !controls) return; const observer = new ResizeObserver(measure); - observer.observe(host); + const observeControls = () => { + observer.disconnect(); + observer.observe(currentHost); + observer.observe(controls); + controls + .querySelectorAll( + "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", + ) + .forEach((element) => observer.observe(element)); + measure(); + }; + observeControls(); + const mutations = new MutationObserver(observeControls); + mutations.observe(controls, { childList: true, subtree: true, characterData: true }); document.fonts.addEventListener("loadingdone", measure); return () => { observer.disconnect(); + mutations.disconnect(); document.fonts.removeEventListener("loadingdone", measure); }; - }, [host, measure]); - - return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible }; + }, [host, controls, useControlsAsHost, measure]); + + return { + controlsRef, + attachControls, + hiddenBlockCount: layout.hiddenCount, + iconOnlyBlockCount: layout.iconOnlyCount ?? 0, + controlsVisible: layout.visible, + }; } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { @@ -1092,7 +1121,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop opticalSize={size === "xs" ? "default" : "large"} /> )} - + {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -1123,7 +1152,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop } > - {runtimeModeOption.label} + {runtimeModeOption.label} {runtimeModeOptions.map((mode) => { @@ -2104,8 +2133,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key: 0, active: false, }); - const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = - usePanelAnimationSettings(); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt; @@ -2120,7 +2147,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); - const composerFooterControlsRef = useRef(null); const composerSurfaceRef = useRef(null); const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); @@ -2628,9 +2654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); const { controlsRef: restingComposerControlsRef, + attachControls: attachRestingComposerControls, hiddenBlockCount: restingControlsHiddenBlockCount, + iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, } = useRestingComposerControlsLayout(restingControlsHost); + const expandedControlsLayout = useRestingComposerControlsLayout(null, true); const pendingPrimaryAction = useMemo( () => activePendingProgress @@ -3177,22 +3206,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); if (typeof ResizeObserver === "undefined") return; - const footerControls = composerFooterControlsRef.current; - const stopFooterControlsFade = footerControls - ? observeResponsiveBreakpointFade({ - target: footerControls, - container: composerForm, - active: panelAnimationsActive, - durationMs: panelAnimationDurationMs, - breakpoint: { - value: composerFooterHasWideActions - ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, - unit: "px", - }, - }) - : undefined; - const observer = new ResizeObserver(() => { const nextCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact((previous) => @@ -3208,7 +3221,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) observer.observe(composerForm); return () => { observer.disconnect(); - stopFooterControlsFade?.(); }; }, [ activeThreadId, @@ -3216,8 +3228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerFooterHasWideActions, isComposerApprovalState, isComposerCollapsedMobile, - panelAnimationDurationMs, - panelAnimationsActive, ]); // ------------------------------------------------------------------ @@ -4926,11 +4936,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerScrollCollapsed, ]); - const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; - const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact; + const restingHiddenBlockCount = composerControlsInStrip + ? restingControlsHiddenBlockCount + : expandedControlsLayout.hiddenBlockCount; + const iconOnlyBlockCount = composerControlsInStrip + ? restingControlsIconOnlyBlockCount + : expandedControlsLayout.iconOnlyBlockCount; const restingProviderTraitsPicker = renderProviderTraitsPicker({ ...providerTraitsPickerInput, - size: "xs", + size: composerControlsInStrip ? "xs" : "sm", hidden: composerControlsHidden || restingHiddenBlockCount > 1, }); const restingBlockDefs = [ @@ -4941,7 +4955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) content: ( <> - {composerControlsInStrip ? restingProviderTraitsPicker : providerTraitsPicker} + {restingProviderTraitsPicker} ), }, @@ -5048,7 +5062,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) triggerClassName={ composerControlsInStrip ? "min-w-13 shrink text-xs! @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none" - : "-ms-2.5" + : "-ms-2.5 min-w-13" } terminalOpen={terminalOpen} open={isComposerModelPickerOpen} @@ -5075,65 +5089,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onOpenProviderSetup={onOpenProviderSetup} /> - {composerControlsCompact ? ( - - ) : ( - <> - {restingBlockDefs.map((def, index) => { - if (!composerControlsInStrip) { - return {def.content}; - } - const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; - return ( - - ); - })} - {composerControlsInStrip ? ( + <> + {restingBlockDefs.map((def, index) => { + const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; + return (
= restingBlockDefs.length - iconOnlyBlockCount ? "true" : "false" + } + aria-hidden={hidden || undefined} + inert={hidden || undefined} className={cn( - "min-w-0 shrink-0", - hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute", + "flex w-max min-w-max shrink-0 items-center gap-1", + hidden && "pointer-events-none invisible absolute", + index >= restingBlockDefs.length - iconOnlyBlockCount && + "[&_[data-composer-control-label]]:pointer-events-none [&_[data-composer-control-label]]:invisible [&_[data-composer-control-label]]:absolute [&_[data-composer-control-label]]:w-max [&_[data-composer-control-label]]:max-w-none [&_[data-composer-control-compact-icon]]:[visibility:inherit] [&_[data-composer-control-compact-icon]]:relative", )} > -
- ) : null} - - )} + ); + })} +
+
+ ); const showTasksTab = @@ -6159,7 +6160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerControlsInStrip && restingControlsHost ? createPortal(
diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index da293c38ddc9..13606e75c870 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -17,7 +17,7 @@ import { } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; import type { VariantProps } from "class-variance-authority"; -import { ZapIcon } from "lucide-react"; +import { GaugeIcon, ZapIcon } from "lucide-react"; import { buttonVariants } from "../ui/button"; import { Menu, @@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ComposerControl, ComposerControlChevron, @@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({ primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, }); + const accessibleLabel = showFastModeIcon ? `${triggerLabel}, Fast mode on` : triggerLabel; const fastModeIcon = showFastModeIcon ? ( <> - - } - > - {isCodexStyle ? ( - // The label truncates itself; clipping the wrapper too would cut off - // the chevron, whose negative end margin overhangs the wrapper edge. - - {fastModeIcon} - {triggerLabel} - - - ) : ( - <> - {fastModeIcon} - {triggerLabel} - - - )} - + + + } + /> + } + > + {isCodexStyle ? ( + // The label truncates itself; clipping the wrapper too would cut off + // the chevron, whose negative end margin overhangs the wrapper edge. + + {fastModeIcon ?? ( + + + + )} + + {triggerLabel} + + + + ) : ( + <> + {fastModeIcon ?? ( + + + + )} + {triggerLabel} + + + )} + + {accessibleLabel} + [{ getBoundingClientRect: () => ({ width: 140 }) }], + querySelectorAll: () => [ + { + dataset: {}, + querySelectorAll: () => [], + getBoundingClientRect: () => ({ width: 140 }), + }, + ], }; vi.stubGlobal("getComputedStyle", (element: unknown) => { if (element === label) return { flexGrow: input.flexGrow }; @@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => { expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 0, + iconOnlyCount: 0, visible: true, }); }); @@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => { expect(measurement.naturalFixedWidth).toBe(192); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 1, + iconOnlyCount: 1, visible: true, }); }); @@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => { expect(measurement.naturalFixedWidth).toBe(212); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 1, + iconOnlyCount: 1, visible: true, }); }); diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts index fc39951723fa..82aa882db53a 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number { return minWidth + elementInlineMarginWidth(picker); } +function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: number } { + const compact = block.dataset.composerBlockIconOnly === "true"; + let natural = elementOuterWidth(block); + let iconOnly = natural; + for (const label of block.querySelectorAll("[data-composer-control-label]")) { + // Labels remain mounted at natural width when icons replace them. Reading + // both variants from one tree avoids duplicate controls or write/read probes. + const labelStyle = getComputedStyle(label); + const inFlow = labelStyle.position !== "absolute"; + // Phone widths already hide the build label with sr-only. Its clipping + // remains in effect even when compact styles replace its one-pixel width. + if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue; + const labelWidth = label.scrollWidth; + const renderedWidth = inFlow ? label.getBoundingClientRect().width : 0; + const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0; + natural += labelWidth - renderedWidth + (inFlow ? 0 : gap); + iconOnly -= renderedWidth + (inFlow ? gap : 0); + } + for (const icon of block.querySelectorAll("[data-composer-control-compact-icon]")) { + const width = elementOuterWidth(icon); + const gap = Number.parseFloat(getComputedStyle(icon.parentElement!).columnGap) || 0; + natural -= compact ? width + gap : 0; + iconOnly += compact ? 0 : width + gap; + } + return { natural, iconOnly: Math.min(natural, iconOnly) }; +} + /** * Read the natural widths of the resting composer controls from the DOM. * @@ -67,6 +94,7 @@ export function measureRestingComposerControls( const overflow = controls.querySelector("[data-resting-controls-overflow]"); const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; const blocks = Array.from(controls.querySelectorAll("[data-resting-block]")); + const widths = blocks.map(controlBlockWidths); return { gap, naturalFixedWidth: @@ -75,7 +103,8 @@ export function measureRestingComposerControls( minimumFixedWidth: (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + separatorAndGapWidth, - blockWidths: blocks.map(elementOuterWidth), + blockWidths: widths.map((width) => width.natural), + iconOnlyBlockWidths: widths.map((width) => width.iconOnly), overflowWidth: overflow ? elementOuterWidth(overflow) : 0, }; } diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 5c0e4327d959..6cb17427c02e 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -449,3 +449,79 @@ describe("resolveScrollToEndClearance", () => { } }); }); + +describe("progressive composer controls", () => { + const measurement = { + gap: 4, + naturalFixedWidth: 140, + minimumFixedWidth: 80, + blockWidths: [80, 140], + iconOnlyBlockWidths: [40, 60], + overflowWidth: 24, + }; + + it("keeps labels while they fit and removes trailing labels before controls", () => { + for (const [hostWidth, iconOnlyCount, hiddenCount] of [ + [368, 0, 0], + [367, 1, 0], + [288, 1, 0], + [287, 2, 0], + [248, 2, 0], + [247, 2, 1], + [211, 2, 2], + ] as const) { + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth })).toEqual({ + hiddenCount, + iconOnlyCount, + visible: true, + }); + } + }); + + it("requires slack to restore labels and controls", () => { + for (const [hostWidth, previous, promoted] of [ + [ + 368, + { hiddenCount: 0, iconOnlyCount: 1, visible: true }, + { hiddenCount: 0, iconOnlyCount: 0, visible: true }, + ], + [ + 288, + { hiddenCount: 0, iconOnlyCount: 2, visible: true }, + { hiddenCount: 0, iconOnlyCount: 1, visible: true }, + ], + [ + 248, + { hiddenCount: 1, iconOnlyCount: 2, visible: true }, + { hiddenCount: 0, iconOnlyCount: 2, visible: true }, + ], + ] as const) { + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth, previous })).toEqual( + previous, + ); + expect( + resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth: hostWidth + 1, + previous, + }), + ).toEqual(promoted); + } + }); + + it("settles through fractional label-width changes at each threshold", () => { + for (const hostWidth of [368, 288, 248]) { + let previous = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); + for (let index = 0; index < 10; index += 1) { + const next = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth, + previous, + naturalFixedWidth: 140 + (index % 2) * 0.5, + }); + if (index > 1) expect(next).toEqual(previous); + previous = next; + } + } + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 42f466c2ae9d..c89edde9f449 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -113,18 +113,29 @@ export interface RestingComposerControlsMeasurement { minimumFixedWidth: number; blockWidths: readonly number[]; overflowWidth: number; + iconOnlyBlockWidths?: readonly number[]; } function restingComposerControlsWidth( input: RestingComposerControlsMeasurement, hiddenCount: number, fixedWidth = input.naturalFixedWidth, + iconOnlyCount = 0, ): number { const { blockWidths, gap } = input; const visibleCount = blockWidths.length - hiddenCount; return ( fixedWidth + - blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + + blockWidths + .slice(0, visibleCount) + .reduce( + (sum, width, index) => + sum + + (index >= blockWidths.length - iconOnlyCount + ? (input.iconOnlyBlockWidths?.[index] ?? width) + : width), + 0, + ) + (hiddenCount > 0 ? input.overflowWidth : 0) + gap * (visibleCount + (hiddenCount > 0 ? 1 : 0)) ); @@ -145,10 +156,11 @@ export function resolveRestingComposerControlsNaturalWidth( } /** - * Decide how many trailing resting control blocks move into the overflow - * menu, and whether the cluster can show at all, from natural widths. + * Fit footer controls using natural widths: remove trailing labels first, + * then move trailing blocks into overflow. Resting and expanded share this + * decision, including the slack needed to safely restore controls. * - * Trailing blocks hide before the model picker shrinks. Once they are all in + * Trailing blocks compact before the model picker shrinks. Once they are all in * the overflow menu, the picker may contract to its minimum readable width; * below that the whole cluster hides rather than clipping. */ @@ -157,39 +169,41 @@ const RESTING_CONTROLS_SLACK_PX = 1; export function resolveRestingComposerControlsLayout( input: RestingComposerControlsMeasurement & { hostWidth: number; - previous?: { hiddenCount: number; visible: boolean }; + previous?: { hiddenCount: number; iconOnlyCount?: number; visible: boolean }; }, -): { hiddenCount: number; visible: boolean } { +): { hiddenCount: number; iconOnlyCount?: number; visible: boolean } { const { blockWidths, hostWidth, previous } = input; - let hiddenCount = 0; + const iconSteps = input.iconOnlyBlockWidths ? blockWidths.length : 0; + const previousStep = previous + ? previous.hiddenCount > 0 + ? iconSteps + Math.min(previous.hiddenCount, blockWidths.length) + : Math.min(previous.iconOnlyCount ?? 0, iconSteps) + : 0; + let step = 0; + const widthAtStep = (candidate: number, fixedWidth = input.naturalFixedWidth) => + restingComposerControlsWidth( + input, + Math.max(0, candidate - iconSteps), + fixedWidth, + Math.min(candidate, iconSteps), + ); + // Promotions need a pixel of slack: recovering a flexible picker's natural + // width can jitter by a fraction of a pixel across renders. Demotions are + // immediate so a threshold cannot clip or flip React between layouts. while ( - hiddenCount < blockWidths.length && - restingComposerControlsWidth(input, hiddenCount) > hostWidth + step < iconSteps + blockWidths.length && + widthAtStep(step) > hostWidth - (step < previousStep ? RESTING_CONTROLS_SLACK_PX : 0) ) { - hiddenCount += 1; + step += 1; } - // Growing the overflow menu is unconditional, or the controls would clip. - // Shrinking it has to earn a pixel of slack first: the picker is flexible, - // so its natural width is recovered from a truncated label whose - // scrollWidth is integral while the rendered box is fractional. The - // composer re-measures on every render, so without that margin a host - // sitting exactly on a threshold flips a block in and out until React - // gives up with "Maximum update depth exceeded". - if (previous) { - const previousHiddenCount = Math.min(previous.hiddenCount, blockWidths.length); - while ( - hiddenCount < previousHiddenCount && - restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX - ) { - hiddenCount += 1; - } - } - const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth); + const hiddenCount = Math.max(0, step - iconSteps); + const iconOnlyCount = Math.min(step, iconSteps); + const minimumWidth = widthAtStep(step, input.minimumFixedWidth); const visible = previous && !previous.visible ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX : minimumWidth <= hostWidth; - return { hiddenCount, visible }; + return { hiddenCount, ...(input.iconOnlyBlockWidths ? { iconOnlyCount } : {}), visible }; } export function resolveScrollToEndClearance(input: { From 5c5fa5ffb6ab45cadb6f0d873af0242f4f6d2c1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 21:45:18 -0700 Subject: [PATCH 044/164] feat(devices): show installed and running tool versions per host (#12816) --- .../devices/DevicePreviewRouteScreen.tsx | 13 ++++ .../server/src/device/DeviceToolchain.test.ts | 62 +++++++++++++++- apps/server/src/device/DeviceToolchain.ts | 44 ++++++++++++ apps/server/src/device/LocalDeviceHost.ts | 23 ++++++ apps/server/src/device/SshDeviceHost.test.ts | 8 +++ apps/server/src/device/SshDeviceHost.ts | 43 +++++++---- .../server/src/device/sshDeviceScript.test.ts | 15 +++- apps/server/src/device/sshDeviceScript.ts | 33 ++++++++- .../components/device/DeviceToolVersions.tsx | 71 +++++++++++++++++++ .../settings/DeviceHostsSettings.tsx | 7 ++ .../settings/IntegrationsSettings.tsx | 9 +++ .../settings/deviceHostConnectionChecks.ts | 12 +++- packages/client-runtime/src/state/device.ts | 16 ++++- packages/contracts/src/device.ts | 14 ++++ 14 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/device/DeviceToolVersions.tsx diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 824164dc2e2b..628b30cd6032 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,3 +1,4 @@ +import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -116,6 +117,18 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + { + id: "device-tools", + title: "Device tool versions", + icon: "info.circle", + onPress: () => + Alert.alert( + "Device tool versions", + deviceToolVersionLabels( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ).join("\n"), + ), + }, { id: "reload", title: "Reload stream", diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 9f70ca91a8e1..d7812a92b48e 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -1,3 +1,4 @@ +import * as PlatformError from "effect/PlatformError"; import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; -import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; +import { + deviceToolVersions, + DEVICE_HUB_VERSION, + ensureDeviceHub, + isDeviceHubInstalled, +} from "./DeviceToolchain.ts"; it.effect("failed installation cleans staging and exposes only a safe failure message", () => Effect.gen(function* () { @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("inventory reports only completed versions without installing the required version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped(); + for (const [version, sentinel] of [ + ["0.9.0", "0.9.0"], + [DEVICE_HUB_VERSION, "wrong"], + [".staging-123", ".staging-123"], + ]) { + const dir = path.join(base, "tools", "expo-device-hub", version!); + yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"), + "", + ); + yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); + } + const tools = yield* deviceToolVersions(base); + expect(tools?.hub).toEqual({ + requiredVersion: DEVICE_HUB_VERSION, + installedVersions: ["0.9.0"], + runningVersion: null, + }); + expect(tools?.agent.installedVersions).toEqual([]); + expect(yield* isDeviceHubInstalled(base)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("unreadable inventory stays unknown instead of reporting no installs", () => + Effect.gen(function* () { + const tools = yield* deviceToolVersions("/unreadable"); + expect(tools).toBeUndefined(); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + description: "denied", + }), + ), + }), + ), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 0493902302b3..81cdb79217fe 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,3 +1,4 @@ +import type { DeviceToolVersions } from "@t3tools/contracts"; /** * Pinned installs of the two external tools device support is built on. * @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); + +/** Read completed installs without downloading or starting either tool. */ +export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( + baseDir: string, + running: { hub?: string; agent?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { + const directory = path.join(baseDir, "tools", spec.name); + const names = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + const versions = yield* Effect.filter(names, (version) => + /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) + ? Effect.gen(function* () { + const paths = toolPaths(path, baseDir, { ...spec, version }); + const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); + return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath)); + }) + : Effect.succeed(false), + ); + return { + requiredVersion: spec.version, + installedVersions: versions.sort(), + runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, + }; + }); + return yield* Effect.gen(function* () { + return { + hub: yield* inspect(HUB_SPEC), + agent: yield* inspect(AGENT_DEVICE_SPEC), + } satisfies DeviceToolVersions; + }).pipe(Effect.orElseSucceed(() => undefined)); +}); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b7341e04f8ad..e84851d0722b 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -31,6 +31,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -50,6 +51,8 @@ import { ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, + deviceToolVersions, + DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -76,6 +79,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), + version: Schema.optional(Schema.String), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); @@ -232,7 +236,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(Path.Path, path), ), ]); + const running = yield* Ref.get(runningRef); + const daemon = running?.agentDevice + ? yield* readDaemonFile().pipe(Effect.option) + : Option.none(); + const hubAlive = running + ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : false; + const agentAlive = + Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; + const tools = yield* deviceToolVersions(config.baseDir, { + ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), + ...(agentAlive && Option.isSome(daemon) && daemon.value.version + ? { agent: daemon.value.version } + : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); return { + tools, id: hostId, kind: "local", label: "This machine", diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index d968d3383fae..71a15190ddc8 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; + const owners: string[] = []; let forwards = 0; let failForward = true; let rejectConfig = true; @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ ); const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; modes.push(mode); + owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ nodePath: "/node", platforms: [{ platform: "ios", available: true }], @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ ), ); yield* host.ensureReady(() => Effect.void); + yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe( + Effect.provide(ServerConfig.layerTest(home, home)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + expect(new Set(owners).size).toBe(1); + expect(owners[0]).toMatch(/^[a-f0-9]{24}$/); expect(forwards).toBe(1); expect(modes.filter((mode) => mode === "start")).toHaveLength(2); yield* host.platformAvailability("ios"); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 4ccda0fdefe9..dadb38a02e6c 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto"; import { type DeviceHostSummary, DevicePlatformAvailability, + DeviceToolVersions, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s const Probe = Schema.Struct({ nodePath: Schema.String, + tools: Schema.optional(DeviceToolVersions), platforms: Schema.Array(DevicePlatformAvailability), }); const Started = Schema.Struct({ @@ -70,8 +72,23 @@ const bootstrap = ( ), ); -export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { - const result = yield* bootstrap(config, "probe", "probe"); +const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { + const fs = yield* FileSystem.FileSystem; + const server = yield* ServerConfig.ServerConfig; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + return NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${hostId}`) + .digest("hex") + .slice(0, 24); +}); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* ( + config: SshDeviceHostConfig, + owner?: string, +) { + const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe"); const value = yield* decodeProbe(result.stdout.trim()).pipe( Effect.mapError( (cause) => @@ -82,8 +99,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi id: config.id, label: config.label, kind: "ssh", - hubInstalled: false, - agentDeviceInstalled: false, + tools: value.tools, + hubInstalled: + value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false, + agentDeviceInstalled: + value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false, platforms: value.platforms, } satisfies DeviceHostSummary; }); @@ -106,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const parentScope = yield* Scope.Scope; const ssh = yield* resolveSshCommand; - const environmentId = yield* fs - .readFileString(server.environmentIdPath) - .pipe(Effect.orElseSucceed(() => server.stateDir)); - const owner = NodeCrypto.createHash("sha256") - .update(`${environmentId}\0${server.stateDir}\0${config.id}`) - .digest("hex") - .slice(0, 24); + const owner = yield* ownerFor(config.id); const provide = ( effect: Effect.Effect< A, E, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | ServerConfig.ServerConfig >, ) => effect.pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, server), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const lock = yield* Semaphore.make(1); @@ -183,6 +201,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary = { ...summary, platforms: remote.platforms, + tools: remote.tools, hubInstalled: true, agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, }; diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index eadd85fd047d..89021d8c0474 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir const file=path.join(state,'daemon.json'); if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } -else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } +else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } `, ); + await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; let invocation = 0; const invoke = async ( owner: string, - mode: "start" | "agent-start" | "stop-agent" | "stop", + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", upgraded = false, ) => { const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); @@ -107,6 +108,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr }); return result.stdout ? JSON.parse(result.stdout) : null; }; + const inventory = await invoke("one", "probe"); + expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); + expect(inventory.tools.hub.runningVersion).toBeNull(); + expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); + await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); const template = NodePath.join(home, "hub-template"); await NodeFSP.cp(hubDir, template, { recursive: true }); await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); @@ -114,7 +120,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.symlink("2147483647:exited-installer", installLock); await NodeFSP.writeFile( NodePath.join(bin, "npm"), - `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, { mode: 0o755 }, ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); @@ -135,6 +141,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr ]); expect(concurrentAgent.hubPort).toBe(first.hubPort); expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const running = await invoke("one", "probe"); + expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); + expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); const second = await invoke("two", "agent-start"); const reused = await invoke("one", "agent-start"); expect(reused.hubPort).toBe(first.hubPort); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index bbdb828c1a74..4c156870a4f0 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -39,6 +39,35 @@ const state = path.join(root, 'hosts', owner); const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const toolVersions = (name, requiredVersion, entry, record) => { + const directory = path.join(root, 'tools'); + const prefix = name + '@'; + let names = []; + try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; } + let unreadable = false; + const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; + const dir = path.join(directory, prefix + version); + try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; } + }).sort(); + if (unreadable) return null; + let runningVersion = null; + if (record?.entryPath && record?.pid) { + const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; + runningVersion = installedVersions.find(version => { + const install = path.join(directory, prefix + version); + return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep); + }) ?? null; + } + return { requiredVersion, installedVersions, runningVersion }; +}; +const versions = () => { + const result = { + hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), + agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), + }; + return result.hub && result.agent ? result : undefined; +}; const stopHub = hub => { if (!hub || hub.owner !== owner) return; const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; @@ -111,7 +140,7 @@ async function install(name, version, entry) { if (mode === 'probe') { if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); - console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; } fs.mkdirSync(state, { recursive: true, mode: 0o700 }); // Serialize starts and stops for this environment/host owner, including agent startup. @@ -185,7 +214,7 @@ async function install(name, version, entry) { } const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; - console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx new file mode 100644 index 000000000000..2ed11f7d9204 --- /dev/null +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -0,0 +1,71 @@ +import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; + +export function DeviceToolVersions({ + tools, + kind, +}: { + tools: ToolVersions | undefined; + kind?: keyof ToolVersions; +}) { + const selected = kind ? tools?.[kind] : undefined; + const version = + selected?.runningVersion ?? + (selected?.installedVersions.includes(selected.requiredVersion) + ? selected.requiredVersion + : selected?.installedVersions + .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .at(-1)); + const label = kind === "hub" ? "Device hub" : "Agent device"; + return ( + + + {kind + ? version + ? `v${version}` + : selected + ? "Not installed" + : "Version unknown" + : "Versions"} + + + {kind ? label : "Device tools"} + {tools ? ( +
+ {( + [ + ["Device hub", tools.hub], + ["Agent device", tools.agent], + ] as const + ) + .filter(([name]) => !kind || name === label) + .map(([name, tool]) => ( +
+

{name}

+
+
Running
+
{tool.runningVersion ?? "Not running"}
+
Required
+
{tool.requiredVersion}
+
Installed
+
+ {tool.installedVersions.join(", ") || "None"} +
+
+
+ ))} +
+ ) : ( +

Versions have not been checked.

+ )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index 50841ee5b53a..d81ce316705b 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { AppleIcon, AndroidIcon } from "../Icons"; import { Spinner } from "../ui/spinner"; @@ -236,6 +237,12 @@ function DeviceHostList({ ))}

{host.target}

+ value.id === host.id)?.tools + } + /> {check?.status === "local" ? (

Already available locally

) : null} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 60757724f2e4..70c67853e903 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; import { DeviceHostsSettings } from "./DeviceHostsSettings"; @@ -692,6 +693,10 @@ function DeviceIntegrationControls({ description={deviceHubDescription} control={ <> + host.kind === "local")?.tools} + /> {pending === "hub" ? : null} + host.kind === "local")?.tools} + /> {pending === "agent" ? : null} } + | { + status: "connected"; + platforms: ReadonlyArray; + tools?: DeviceHostSummary["tools"]; + } | { status: "failed"; error: string }; const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); @@ -48,7 +52,11 @@ export async function checkDeviceHostConnections( target.environmentId, result.kind === "local" ? { status: "local" } - : { status: "connected", platforms: result.platforms }, + : { + status: "connected", + platforms: result.platforms, + ...(result.tools ? { tools: result.tools } : {}), + }, ); } catch (error) { report(target.environmentId, { diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index 1e6523497ef3..5e08de264de1 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -1,4 +1,4 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -70,3 +70,17 @@ export function createDeviceEnvironmentAtoms( }), }; } + +/** Unknown inventory is distinct from a completed check that found no install. */ +export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { + if (!tools) return ["Device tool versions have not been checked."]; + return ( + [ + ["Device hub", tools.hub], + ["Agent tools", tools.agent], + ] as const + ).map(([name, tool]) => { + const installed = tool.installedVersions.length ? tool.installedVersions.join(", ") : "none"; + return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; + }); +} diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index a93198a1e639..4c1b47862f37 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -74,11 +74,25 @@ export const DevicePlatformAvailability = Schema.Struct({ }); export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; +export const DeviceToolVersion = Schema.Struct({ + requiredVersion: Schema.String, + installedVersions: Schema.Array(Schema.String), + runningVersion: Schema.NullOr(Schema.String), +}); +export type DeviceToolVersion = typeof DeviceToolVersion.Type; + +export const DeviceToolVersions = Schema.Struct({ + hub: DeviceToolVersion, + agent: DeviceToolVersion, +}); +export type DeviceToolVersions = typeof DeviceToolVersions.Type; + export const DeviceHostSummary = Schema.Struct({ id: DeviceHostId, kind: Schema.Literals(["local", "ssh"]), label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), + tools: Schema.optional(DeviceToolVersions), hubInstalled: Schema.Boolean, agentDeviceInstalled: Schema.Boolean, }); From 3b836f9942143cdcf1ad7f121e78b59476585781 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 21:45:18 -0700 Subject: [PATCH 045/164] feat(devices): show automatic update progress and host retry (#12817) --- .../devices/DevicePreviewRouteScreen.tsx | 18 ++++- apps/server/src/auth/RpcAuthorization.test.ts | 13 +++- apps/server/src/auth/RpcAuthorization.ts | 5 ++ apps/server/src/device/DeviceHost.ts | 4 +- apps/server/src/device/DeviceService.test.ts | 56 +++++++++++++++- apps/server/src/device/DeviceService.ts | 46 +++++++++++-- apps/server/src/device/LocalDeviceHost.ts | 16 ++++- apps/server/src/device/SshDeviceHost.ts | 18 ++++- apps/server/src/ws.ts | 17 +++-- .../components/device/DeviceHostUpdates.tsx | 67 +++++++++++++++++++ .../web/src/components/device/DevicePanel.tsx | 4 +- .../web/src/components/device/DeviceSetup.tsx | 2 + .../settings/IntegrationsSettings.tsx | 2 + packages/contracts/src/device.test.ts | 21 ++++++ packages/contracts/src/device.ts | 16 ++++- 15 files changed, 281 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/device/DeviceHostUpdates.tsx create mode 100644 packages/contracts/src/device.test.ts diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 628b30cd6032..9af958c82b78 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -74,6 +74,7 @@ function DevicePreviewScreen({ const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); const [shuttingDown, setShuttingDown] = useState(false); + const retryHost = useAtomCommand(deviceEnvironment.list); const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); const streamRef = useRef(null); const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); @@ -117,6 +118,19 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + ...(state.data?.hosts + .filter( + (host) => + state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed", + ) + .map((host) => ({ + id: `retry-${host.id}`, + title: `Retry ${host.label}`, + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { retryHostId: host.id } }); + }, + })) ?? []), { id: "device-tools", title: "Device tool versions", @@ -126,7 +140,9 @@ function DevicePreviewScreen({ "Device tool versions", deviceToolVersionLabels( state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, - ).join("\n"), + ).join("\n") + + "\n" + + (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""), ), }, { diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..c972e5372f4c 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; +import { + RPC_REQUIRED_SCOPES, + requiredScopeForRpcMethod, + requiredScopeForDeviceList, +} from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { it("declares exactly one scope for every RPC in the server group", () => { @@ -71,3 +75,10 @@ describe("RPC authorization scopes", () => { } }); }); + +it("requires operate permission for host retry while preserving read-only listing", () => { + expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe( + AuthOrchestrationOperateScope, + ); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20f2893b8335..819d98cfc92c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,4 +1,5 @@ import { + type DeviceListInput, AuthAccessReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -180,3 +181,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope } return requiredScope; } + +/** Retrying can install or restart tools even though ordinary listing is readable. */ +export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => + input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope; diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index a947f8dc9e68..0aa784b7149d 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -88,11 +88,11 @@ export class DeviceHost extends Context.Service< * concurrent callers share one start, and a ready host returns immediately. */ readonly ensureReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect< DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 183f24d95c5b..b3ff08aea1ce 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -66,7 +66,7 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, - runtimeFailure?: NodeRuntimeUnavailableError, + runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -96,7 +96,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; starts.push("start"); - yield* onPhase("starting"); + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => @@ -571,3 +571,55 @@ it.effect.each([ Effect.scoped, ), ); + +it.effect("retry keeps device and agent consent unchanged", () => + Effect.gen(function* () { + const { service, starts, agentStarts } = yield* fixture(); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + yield* service.configure({ enabled: true }); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts).toEqual([]); + yield* service.configure({ agentAccessEnabled: true }); + const before = agentStarts.length; + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts.length).toBe(before + 1); + }).pipe(Effect.scoped), +); + +it.effect("publishes update detail for the correct host", () => + Effect.gen(function* () { + const { service } = yield* fixture(); + const changes = yield* service.subscribe; + yield* service.configure({ enabled: true }); + const states = yield* PubSub.takeAll(changes); + expect( + states.some( + (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", + ), + ).toBe(true); + }).pipe(Effect.scoped), +); + +it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => + Effect.gen(function* () { + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: "private diagnostics", + }), + ); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(state.supportsHostRetry).toBe(true); + expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "failed", + detail: "Could not connect to this host over SSH.", + }); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 45a2f928ecaa..bb06ef66731b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; readonly close: (input: DeviceCloseInput) => Effect.Effect; readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; @@ -202,6 +203,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* let publishedHosts = new Map(hosts); const stateRef = yield* SynchronizedRef.make({ state: { + supportsHostRetry: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -257,7 +259,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); } const ready = yield* host - .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) + .ensureReady((status, detail) => + setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -269,7 +273,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") - : `Device host ${error.hostId} failed while ${error.step}.`, + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -308,7 +314,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .ensureAgentReady((phase, detail) => + setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -321,8 +329,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") : error._tag === "DeviceHostTimeoutError" - ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` - : `Device host ${error.hostId} failed while ${error.step}.`, + ? `Agent tools did not start within ${error.timeoutMs} ms.` + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -444,7 +454,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { status: "failed", detail: error.message }), + setHostStatus(host.id, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), ), ), { concurrency: 4 }, @@ -452,6 +465,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return (yield* SynchronizedRef.get(stateRef)).state; }).pipe(Effect.withSpan("DeviceService.list")); + const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( + function* (hostId) { + yield* resolveHost(hostId); + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + yield* Effect.gen(function* () { + const ready = + (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId)); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(hostId, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), + ), + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }, + ); + const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( function* (input) { const currentSettings = yield* readDeviceSettings; @@ -832,6 +865,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + retryHost, agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index e84851d0722b..0f7992c61379 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -1,3 +1,4 @@ +import { deviceToolInstallMessage } from "@t3tools/contracts"; /** * The device host that is this machine. * @@ -556,7 +557,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { @@ -573,7 +574,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub)); + } const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -628,7 +632,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase( + "installing", + deviceToolInstallMessage("agent tools", inventory.tools?.agent), + ); + } const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index dadb38a02e6c..9ac2fa2580fa 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -3,6 +3,7 @@ import { type DeviceHostSummary, DevicePlatformAvailability, DeviceToolVersions, + deviceToolInstallMessage, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -371,7 +372,12 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( stopped = false; if (ready) return ready; summary = yield* provide(probe(config)); - yield* onPhase("installing"); + yield* onPhase( + summary.hubInstalled ? "starting" : "installing", + summary.hubInstalled + ? undefined + : deviceToolInstallMessage("device hub", summary.tools?.hub), + ); return yield* connect().pipe( Effect.tapError(() => connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, @@ -421,7 +427,15 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( current: Effect.sync(() => ready), ensureReady, ensureAgentReady: (onPhase) => - onPhase("installing").pipe( + ensureReady(onPhase).pipe( + Effect.flatMap(() => + onPhase( + summary.agentDeviceInstalled ? "starting" : "installing", + summary.agentDeviceInstalled + ? undefined + : deviceToolInstallMessage("agent tools", summary.tools?.agent), + ), + ), Effect.flatMap(() => changeAgent(true)), Effect.flatMap((value) => value?.agentDevice diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d118bd3d012c..9194846d62bb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -146,7 +146,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -3453,10 +3453,17 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { "rpc.aggregate": "device", }), - [WS_METHODS.deviceList]: (_input) => - observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { - "rpc.aggregate": "device", - }), + [WS_METHODS.deviceList]: (input) => + observeRpcEffect( + WS_METHODS.deviceList, + authorizeEffect( + requiredScopeForDeviceList(input), + input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list, + ), + { + "rpc.aggregate": "device", + }, + ), [WS_METHODS.deviceOpen]: (input) => observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { "rpc.aggregate": "device", diff --git a/apps/web/src/components/device/DeviceHostUpdates.tsx b/apps/web/src/components/device/DeviceHostUpdates.tsx new file mode 100644 index 000000000000..bda9fca88f93 --- /dev/null +++ b/apps/web/src/components/device/DeviceHostUpdates.tsx @@ -0,0 +1,67 @@ +import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { deviceEnvironment } from "~/state/device"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** Shared by setup, Settings, and the Device panel so automatic updates stay visible. */ +export function DeviceHostUpdates({ + state, + environmentId, +}: { + state: DeviceServiceState; + environmentId: EnvironmentId; +}) { + const retry = useAtomCommand(deviceEnvironment.list); + const [pending, setPending] = useState(null); + if (state.hostStatus === "disabled") return null; + return ( +
+ {state.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + if (!status || !["installing", "starting", "failed"].includes(status.status)) return null; + const failed = status.status === "failed"; + return ( +
+
+

{host.label}

+

+ {status.detail ?? + (failed + ? "Device support could not start." + : status.status === "installing" + ? "Installing device tools…" + : "Starting device tools…")} +

+ {failed ? ( +

+ Check the host connection and network access, then retry. Your device settings are + saved. +

+ ) : null} +
+ {failed && state.supportsHostRetry ? ( + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 6f1ae81eddb9..130353943352 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { DevicePlatform, DeviceServiceState, @@ -253,6 +254,7 @@ export function DevicePanel(props: { {state.hostStatusDetail}
) : null} + {bootingDevices.length > 0 ? (
Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute. @@ -319,7 +321,7 @@ export function DevicePanel(props: { ? "Opening device…" : "Starting device…" : state.hostStatus === "installing" - ? "Installing device support…" + ? (state.hostStatusDetail ?? "Installing device support…") : "Finding devices…" } /> diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 67bf14181b21..5ef41b4cefd9 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; import { Check, CircleAlert } from "lucide-react"; import { useState } from "react"; @@ -89,6 +90,7 @@ export function DeviceSetup(props: { + {step === 0 ? (

Enable the device hub

diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 70c67853e903..5f2e149d17fd 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; @@ -784,6 +785,7 @@ function DeviceIntegrationControls({ {state.hostStatusDetail}

) : null} + {environmentId ? : null} ); diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts new file mode 100644 index 000000000000..11587fa5996e --- /dev/null +++ b/packages/contracts/src/device.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; +import { deviceToolInstallMessage } from "./device.ts"; + +describe("device tool install progress", () => { + it("distinguishes a new install from an upgrade and chooses versions numerically", () => { + expect( + deviceToolInstallMessage("device hub", { + requiredVersion: "0.11.0", + installedVersions: [], + runningVersion: null, + }), + ).toBe("Installing device hub 0.11.0…"); + expect( + deviceToolInstallMessage("device hub", { + requiredVersion: "0.11.0", + installedVersions: ["0.9.0", "0.10.0"], + runningVersion: null, + }), + ).toBe("Updating device hub from 0.10.0 to 0.11.0…"); + }); +}); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 4c1b47862f37..afcc04dbefb3 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -127,6 +127,7 @@ export const DeviceSession = Schema.Struct({ export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ + supportsHostRetry: Schema.optional(Schema.Boolean), hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), @@ -150,7 +151,10 @@ export const DeviceServiceState = Schema.Struct({ }); export type DeviceServiceState = typeof DeviceServiceState.Type; -export const DeviceListInput = Schema.Struct({}); +export const DeviceListInput = Schema.Struct({ + /** Retry this host only, including agent tools if access was already granted. */ + retryHostId: Schema.optional(DeviceHostId), +}); export type DeviceListInput = typeof DeviceListInput.Type; export const DeviceConfigureInput = Schema.Struct({ @@ -560,3 +564,13 @@ export const DeviceToolError = Schema.Union([ DeviceActionUnavailableError, ]); export type DeviceToolError = typeof DeviceToolError.Type; + +export function deviceToolInstallMessage(name: string, tool: DeviceToolVersion | undefined) { + if (!tool) return `Installing ${name}…`; + const previous = + tool.runningVersion ?? + [...tool.installedVersions].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1); + return previous && !tool.installedVersions.includes(tool.requiredVersion) + ? `Updating ${name} from ${previous} to ${tool.requiredVersion}…` + : `Installing ${name} ${tool.requiredVersion}…`; +} From 61b25b88d7922a480790066341867f3f8ae42fcb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 21:45:19 -0700 Subject: [PATCH 046/164] feat(devices): add read-only update discovery and remote ownership (#12818) --- .../devices/DevicePreviewRouteScreen.tsx | 39 ++++++++++++--- apps/server/src/device/DeviceHost.ts | 1 + apps/server/src/device/DeviceService.test.ts | 37 +++++++++++++++ apps/server/src/device/DeviceService.ts | 35 ++++++++++++++ apps/server/src/device/SshDeviceHost.ts | 11 ++++- apps/server/src/ws.ts | 12 +++-- .../components/device/DeviceToolVersions.tsx | 20 +++++++- .../settings/DeviceHostsSettings.tsx | 47 ++++++++++++++----- .../settings/IntegrationsSettings.tsx | 27 +++++++++-- docs/user/devices.md | 6 +++ packages/client-runtime/src/state/device.ts | 13 +++++ packages/contracts/src/device.ts | 4 ++ 12 files changed, 222 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 9af958c82b78..8d2a67000050 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,4 +1,8 @@ -import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; +import { + deviceToolVersionLabels, + deviceToolUpdateOwnership, + deviceToolUpdatePolicy, +} from "@t3tools/client-runtime/state/device"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -131,6 +135,18 @@ function DevicePreviewScreen({ void retryHost({ environmentId, input: { retryHostId: host.id } }); }, })) ?? []), + ...(state.data?.supportsToolInspection + ? [ + { + id: "check-device-tools", + title: "Check device tool versions", + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { inspectOnly: true } }); + }, + }, + ] + : []), { id: "device-tools", title: "Device tool versions", @@ -138,17 +154,26 @@ function DevicePreviewScreen({ onPress: () => Alert.alert( "Device tool versions", - deviceToolVersionLabels( - state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, - ).join("\n") + + deviceToolUpdateOwnership + + "\n\n" + + deviceToolUpdatePolicy( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ) + + "\n\n" + + deviceToolVersionLabels( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ).join("\n") + "\n" + - (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""), + (state.data?.hosts.find((host) => host.id === preview?.session.hostId) + ?.toolInspectionError ?? + state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? + ""), ), }, { id: "reload", title: "Reload stream", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !preview || shuttingDown, onPress: () => { setInputConnected(false); @@ -178,7 +203,7 @@ function DevicePreviewScreen({ { id: "rotate", title: "Rotate device", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !inputConnected, onPress: () => streamRef.current?.rotate(), }, diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 0aa784b7149d..14537985b9b8 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< { readonly id: DeviceHostId; readonly summary: Effect.Effect; + readonly inspect?: Effect.Effect; readonly platformAvailability: ( platform: DevicePlatform, ) => Effect.Effect; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index b3ff08aea1ce..ce57c724f8be 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* ( bootError?: string, failListAfterShutdown = false, runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, + inspectError = false, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* ( run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), }; const host: DeviceHost.DeviceHost["Service"] = { + ...(inspectError + ? { + inspect: Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: new Error("offline"), + }), + ), + } + : {}), id: LOCAL_DEVICE_HOST_ID, summary: Effect.succeed({ id: LOCAL_DEVICE_HOST_ID, @@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos }); }).pipe(Effect.scoped), ); + +it.effect("version discovery does not grant consent or start device tools", () => + Effect.gen(function* () { + const { service, starts, agentStarts, requests } = yield* fixture(); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts).toHaveLength(1); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => + Effect.gen(function* () { + const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts[0]?.hubInstalled).toBe(true); + expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); + expect(starts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index bb06ef66731b..ddbe07ad9de4 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly inspect: Effect.Effect; readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; readonly close: (input: DeviceCloseInput) => Effect.Effect; @@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const stateRef = yield* SynchronizedRef.make({ state: { supportsHostRetry: true, + supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return (yield* SynchronizedRef.get(stateRef)).state; }).pipe(Effect.withSpan("DeviceService.list")); + const inspect = Effect.gen(function* () { + yield* Effect.forEach( + hosts.values(), + (host) => + Effect.gen(function* () { + const result = yield* (host.inspect ?? host.summary).pipe(Effect.result); + if (hosts.get(host.id) !== host) return; + if (result._tag === "Failure") { + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => + value.id === host.id + ? { + ...value, + toolInspectionError: + "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.", + } + : value, + ), + })); + return; + } + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)), + })); + }), + { concurrency: 4 }, + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }); + const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( function* (hostId) { yield* resolveHost(hostId); @@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ...DeviceService.of({ testHost, retryHost, + inspect, agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 9ac2fa2580fa..4ed6d740215c 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.gen(function* () { stopped = false; if (ready) return ready; - summary = yield* provide(probe(config)); + summary = yield* provide(probe(config, owner)); yield* onPhase( summary.hubInstalled ? "starting" : "installing", summary.hubInstalled @@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return { id: config.id, summary: Effect.sync(() => summary), + inspect: provide(probe(config, owner)).pipe( + Effect.tap((value) => + Effect.sync(() => { + summary = value; + }), + ), + ), current: Effect.sync(() => ready), ensureReady, ensureAgentReady: (onPhase) => @@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), stop, platformAvailability: (platform) => - provide(probe(config)).pipe( + provide(probe(config, owner)).pipe( Effect.map((value) => { summary = { ...summary, platforms: value.platforms }; return value.platforms.find((p) => p.platform === platform)!; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9194846d62bb..05b680b441f7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3456,10 +3456,14 @@ const makeWsRpcLayer = ( [WS_METHODS.deviceList]: (input) => observeRpcEffect( WS_METHODS.deviceList, - authorizeEffect( - requiredScopeForDeviceList(input), - input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list, - ), + input.inspectOnly + ? deviceService.inspect + : authorizeEffect( + requiredScopeForDeviceList(input), + input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, + ), { "rpc.aggregate": "device", }, diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx index 2ed11f7d9204..0501c8370bdc 100644 --- a/apps/web/src/components/device/DeviceToolVersions.tsx +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -1,12 +1,19 @@ +import type { ReactNode } from "react"; import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; export function DeviceToolVersions({ tools, + action, kind, + owner, + error, }: { tools: ToolVersions | undefined; + action?: ReactNode; kind?: keyof ToolVersions; + owner?: string | undefined; + error?: string | undefined; }) { const selected = kind ? tools?.[kind] : undefined; const version = @@ -33,7 +40,9 @@ export function DeviceToolVersions({ : selected ? "Not installed" : "Version unknown" - : "Versions"} + : error + ? "Versions unavailable" + : "Versions"} {kind ? label : "Device tools"} @@ -65,6 +74,15 @@ export function DeviceToolVersions({ ) : (

Versions have not been checked.

)} +

+ {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed. +

+ {error ? ( +

+ {error} +

+ ) : null} + {action ?
{action}
: null}
); diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index d81ce316705b..ffed54e14643 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner"; import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; import { randomUUID } from "../../lib/utils"; import { useState } from "react"; -import { useDeviceState } from "../../state/device"; +import { deviceEnvironment, useDeviceState } from "../../state/device"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; @@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null

) : null} ; busy: boolean; @@ -175,6 +178,8 @@ function DeviceHostList({ testConnection: ReturnType["testConnection"]; }) { const { state } = useDeviceState(environmentId); + const retry = useAtomCommand(deviceEnvironment.list); + const [retrying, setRetrying] = useState(null); return ( <> {hosts.length === 0 ? ( @@ -238,9 +243,11 @@ function DeviceHostList({

{host.target}

value.id === host.id)?.toolInspectionError} tools={ - (check?.status === "connected" ? check.tools : undefined) ?? - state.hosts.find((value) => value.id === host.id)?.tools + state.hosts.find((value) => value.id === host.id)?.tools ?? + (check?.status === "connected" ? check.tools : undefined) } /> {check?.status === "local" ? ( @@ -290,14 +297,32 @@ function DeviceHostList({
- + {status?.status === "failed" && + state.supportsHostRetry && + state.hostStatus !== "disabled" ? ( + + ) : ( + + )}
); })} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 5f2e149d17fd..851ca7439f99 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -685,6 +685,21 @@ function DeviceIntegrationControls({ } }; + const checkVersions = state.supportsToolInspection ? ( + + ) : null; + return ( <> host.kind === "local")?.tools} /> @@ -758,6 +774,7 @@ function DeviceIntegrationControls({ control={ <> host.kind === "local")?.tools} /> @@ -780,12 +797,12 @@ function DeviceIntegrationControls({ } /> - {state.hostStatus === "failed" && state.hostStatusDetail ? ( -

- {state.hostStatusDetail} -

+ {environmentId ? ( + host.kind === "local") }} + environmentId={environmentId} + /> ) : null} - {environmentId ? : null} ); diff --git a/docs/user/devices.md b/docs/user/devices.md index b388688bae29..02e06d2bdffa 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds, installation, and connectivity to development servers such as Metro separately. A simulator on another machine cannot reach Metro through your environment's localhost without forwarding or another reachable address. + +## Device tool updates + +The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Integrations → Check device tool versions reads installed versions without installing tools or starting devices. + +To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index 5e08de264de1..a9f5d5bb2ebd 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; }); } + +export function deviceToolUpdatePolicy(tools: DeviceToolVersions | undefined) { + if (!tools) return "Versions have not been checked. Reconnect the host and check versions."; + const outdated = [tools.hub, tools.agent].filter( + (tool) => + tool.installedVersions.length > 0 && !tool.installedVersions.includes(tool.requiredVersion), + ); + return outdated.length > 0 + ? "Update pending. Required tools will install automatically when next used. The host needs network access; an older install is not used as a fallback." + : "Required tools are installed automatically when needed. Checking versions does not install or start anything."; +} +export const deviceToolUpdateOwnership = + "This environment's T3 server chooses device tool versions for itself and its SSH hosts. Update that server to receive newer tool versions; updating only your browser or mobile app does not update a remote server."; diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index afcc04dbefb3..20b49f850123 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({ label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), tools: Schema.optional(DeviceToolVersions), + toolInspectionError: Schema.optional(Schema.String), hubInstalled: Schema.Boolean, agentDeviceInstalled: Schema.Boolean, }); @@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ supportsHostRetry: Schema.optional(Schema.Boolean), + supportsToolInspection: Schema.optional(Schema.Boolean), hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), @@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({ export type DeviceServiceState = typeof DeviceServiceState.Type; export const DeviceListInput = Schema.Struct({ + /** Read inventory without installing tools or starting helpers. */ + inspectOnly: Schema.optional(Schema.Boolean), /** Retry this host only, including agent tools if access was already granted. */ retryHostId: Schema.optional(DeviceHostId), }); From 790be6d755026d61bd13a970dd77176c12ca0425 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 22:08:51 -0700 Subject: [PATCH 047/164] fix(devices): safely reclaim obsolete managed tool versions (#12819) --- apps/server/src/device/DeviceService.ts | 2 +- apps/server/src/device/LocalDeviceHost.ts | 11 ++ .../src/device/deviceToolMaintenance.test.ts | 159 ++++++++++++++++++ .../src/device/deviceToolMaintenance.ts | 148 ++++++++++++++++ .../server/src/device/sshDeviceScript.test.ts | 3 + apps/server/src/device/sshDeviceScript.ts | 3 + 6 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/device/deviceToolMaintenance.test.ts create mode 100644 apps/server/src/device/deviceToolMaintenance.ts diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index ddbe07ad9de4..f99522b2bd7b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -1111,7 +1111,7 @@ export const make = Effect.gen(function* () { return { ...service, agentCli: resolveNodeExecutable("Device automation").pipe( - Effect.flatMap(() => ensureAgentDevice(config.baseDir)), + Effect.andThen(ensureAgentDevice(config.baseDir)), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 0f7992c61379..e704d28bbc34 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -1,3 +1,4 @@ +import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts"; import { deviceToolInstallMessage } from "@t3tools/contracts"; /** * The device host that is this machine. @@ -593,6 +594,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); + yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const candidate = helperPaths(hubTool); const [axExists, cliExists] = yield* Effect.all([ fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), @@ -655,6 +661,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); + yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const next = { ...running, agentDevice }; yield* Ref.set(runningRef, next); return { ...toReady(next), agentDevice }; diff --git a/apps/server/src/device/deviceToolMaintenance.test.ts b/apps/server/src/device/deviceToolMaintenance.test.ts new file mode 100644 index 000000000000..4c46be11bba4 --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.test.ts @@ -0,0 +1,159 @@ +import * as Effect from "effect/Effect"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +// @effect-diagnostics nodeBuiltinImport:off - tests the same standalone script used by local and SSH hosts. +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { + it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + const name = "expo-device-hub"; + const directory = (version: string) => + flat ? NodePath.join(root, `${name}@${version}`) : NodePath.join(root, name, version); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0"]) { + await NodeFSP.mkdir(directory(version), { recursive: true }); + if (version === "0.5.0") continue; + const sentinel = NodePath.join(directory(version), ".install-complete"); + await NodeFSP.writeFile(sentinel, version); + await NodeFSP.utimes( + sentinel, + Number(version.split(".")[1]), + Number(version.split(".")[1]), + ); + } + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + await pruneTools(root, [['${name}', '0.6.0']], ${flat}); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await exec(process.execPath, [ + "-e", + script, + NodePath.join(directory("0.2.0"), "active-helper.cjs"), + ]); + await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow(); + await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow(); + for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"]) + expect((await NodeFSP.stat(directory(version))).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps every install when the process scan fails", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-scan-")); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0"]) { + const dir = flat + ? NodePath.join(root, `expo-device-hub@${version}`) + : NodePath.join(root, "expo-device-hub", version); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version); + } + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + ` + require('node:child_process').spawnSync = () => ({ status: 1, stdout: '' }); + pruneTools(${JSON.stringify(root)}, [['expo-device-hub', '0.3.0']], ${flat}).catch(() => process.exitCode = 1); + `, + ]); + const parent = flat ? root : NodePath.join(root, "expo-device-hub"); + expect((await NodeFSP.readdir(parent)).length).toBe(3); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("does not prune before the required version has completed installation", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + try { + const dir = flat + ? NodePath.join(root, "expo-device-hub@0.1.0") + : NodePath.join(root, "expo-device-hub/0.1.0"); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0"); + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`, + ]); + expect((await NodeFSP.stat(dir)).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); + +it.effect("maintenance failures retain safe context and the original process result", () => + Effect.gen(function* () { + const output = { + code: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "private child diagnostics", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + for (const [operation, run] of [["prune", pruneLocalDeviceTools]] as const) { + const error = yield* run("/tools", process.execPath, "hub").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(output) }), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "DeviceToolMaintenanceError", + operation, + tool: "hub", + exitCode: 1, + cause: output, + }); + expect(error.message).toBe(`Device tool ${operation} failed for hub (exit code 1).`); + expect(error.message).not.toContain(output.stderr); + } + }).pipe(Effect.provide(NodePathLayer.layer)), +); + +it("serializes competing maintenance processes after reclaiming a stale lock", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-contention-")); + try { + const lock = NodePath.join(root, ".maintenance-lock"); + await NodeFSP.mkdir(lock); + await NodeFSP.writeFile( + NodePath.join(lock, "stale-owner.json"), + JSON.stringify({ pid: 2147483647, identity: "dead" }), + ); + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + const marker = maintenancePath.join(root, 'critical-section'); + for (let attempt = 0; attempt < 8; attempt++) await withToolMaintenance(root, () => { + maintenanceFs.writeFileSync(marker, String(process.pid), { flag: 'wx' }); + for (let check = 0; check < 100; check++) { + if (maintenanceFs.readFileSync(marker, 'utf8') !== String(process.pid)) throw Error('Overlapping maintenance'); + } + maintenanceFs.unlinkSync(marker); + }); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script]))); + await expect(NodeFSP.stat(lock)).rejects.toThrow(); + expect(await NodeFSP.readdir(root)).toEqual([]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts new file mode 100644 index 000000000000..43cc64e8faca --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics preferSchemaOverJson:off - JSON string literals safely embed paths and arguments in generated JavaScript. +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as ProcessRunner from "../processRunner.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +/** Shared with the SSH bootstrap. Cleanup runs only after successful startup. */ +export const deviceToolMaintenanceScript = String.raw` +const maintenanceFs = require('node:fs'); +const maintenancePath = require('node:path'); +const maintenanceAlive = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { return error.code !== 'ESRCH'; } +}; +async function withToolMaintenance(root, operation) { + maintenanceFs.mkdirSync(root, { recursive: true }); + const lock = maintenancePath.join(root, '.maintenance-lock'); + const nonce = require('node:crypto').randomUUID(); + const ownerFile = process.pid + '.' + nonce + '.json'; + const candidate = lock + '.' + nonce; + const holder = { pid: process.pid }; + const deadline = Date.now() + 30000; + const removeEmptyLock = () => { + try { maintenanceFs.rmdirSync(lock); } + catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; } + }; + maintenanceFs.mkdirSync(candidate); + try { + maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify(holder)); + while (true) { + try { + // Publish a populated directory atomically; rename cannot replace another populated lock. + maintenanceFs.renameSync(candidate, lock); + break; + } + catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error; + let files = []; + try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + if (files.length === 1) { + const previousFile = maintenancePath.join(lock, files[0]); + let previous; + try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {} + if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) { + // The unique filename belongs only to that owner. Never unlink a replacement owner's file. + try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + } + // A concurrent acquirer publishes its owner file with the directory, so this cannot remove it. + removeEmptyLock(); + if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.'); + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + try { return operation(); } + finally { + maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile)); + removeEmptyLock(); + } + } finally { + maintenanceFs.rmSync(candidate, { recursive: true, force: true }); + } +} +function pruneTools(root, specs, flat) { + return withToolMaintenance(root, () => { + // Keep installs used by any running helper, including older T3 releases. + const scan = process.platform === 'win32' + ? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000 }) + : require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 }); + if (scan.status !== 0 || !scan.stdout) return; + for (const [name, required] of specs) { + const parent = flat ? root : maintenancePath.join(root, name); + let names; + try { names = maintenanceFs.readdirSync(parent); } catch { continue; } + const completed = []; + for (const item of names) { + const version = flat ? (item.startsWith(name + '@') ? item.slice(name.length + 1) : '') : item; + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue; + const directory = maintenancePath.join(parent, item); + try { + if (!maintenanceFs.lstatSync(directory).isDirectory()) continue; + if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue; + completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs }); + } catch {} + } + // Never prune until the required install has completed. Retain the last other successful install. + if (!completed.some(value => value.version === required)) continue; + const previous = completed.filter(value => value.version !== required).sort((a, b) => b.modified - a.modified || b.version.localeCompare(a.version, 'en', { numeric: true }))[0]?.version; + for (const { version, directory } of completed) { + if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue; + maintenanceFs.rmSync(directory, { recursive: true, force: true }); + } + } + }); +} +`; + +class DeviceToolMaintenanceError extends Schema.TaggedError()( + "DeviceToolMaintenanceError", + { + operation: Schema.Literal("prune"), + tool: Schema.Literals(["hub", "agent"]), + exitCode: Schema.NullOr(Schema.Int), + cause: Schema.Defect(), + }, +) { + override get message() { + return `Device tool ${this.operation} failed for ${this.tool} (exit code ${this.exitCode ?? "unknown"}).`; + } +} + +const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* ( + nodePath: string, + script: string, + operation: "prune", + tool: "hub" | "agent", +) { + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner.run({ + command: nodePath, + args: [ + "-e", + deviceToolMaintenanceScript + + "\n" + + script + + ".catch(error => { console.error(error.message); process.exitCode = 1; });", + ], + }); + if (result.code !== 0) + return yield* Effect.fail( + new DeviceToolMaintenanceError({ operation, tool, exitCode: result.code, cause: result }), + ); +}); + +export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* ( + baseDir: string, + nodePath: string, + tool: "hub" | "agent", +) { + const path = yield* Path.Path; + yield* runMaintenance( + nodePath, + `pruneTools(${JSON.stringify(path.join(baseDir, "tools"))}, ${JSON.stringify(tool === "hub" ? [["expo-device-hub", DEVICE_HUB_VERSION]] : [["agent-device", AGENT_DEVICE_VERSION]])}, false)`, + "prune", + tool, + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 89021d8c0474..8d9bc77d891c 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -125,6 +125,9 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + // Unavailable advisory bookkeeping must not prevent either helper from starting. + await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked"); + await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory"); try { const [manual, concurrent] = await Promise.all([ invoke("one", "start"), diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 4c156870a4f0..de412b5873d5 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -1,3 +1,4 @@ +import { deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; @@ -28,6 +29,7 @@ const mode = ${JSON.stringify(mode)}; const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; ` + + deviceToolMaintenanceScript + String.raw` const fs = require('node:fs'); const path = require('node:path'); @@ -214,6 +216,7 @@ async function install(name, version, entry) { } const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; + await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {}); console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } From 30e3649c3281fe88b3d8d125293f1bb58132a3c2 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 21 Sep 2026 02:10:36 -0300 Subject: [PATCH 048/164] fix(web): match thread notification icons to sidebar status (#12806) --- .../ThreadNotificationCoordinator.tsx | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 5feda71ea8f3..1401965e90c7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -2,6 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import { + CircleAlertIcon, + CircleCheckIcon, + MessageCircleQuestionIcon, + ShieldQuestionIcon, +} from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -154,7 +160,28 @@ function EnvironmentNotifications({ type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", title, description: thread.title, - data: { hideCopyButton: true }, + data: { + hideCopyButton: true, + leadingIcon: + kind === "completion" ? ( + + ) : status === "approval" ? ( + + ) : status === "failed" ? ( + + ) : ( + + ), + }, actionProps: { children: "Open thread", onClick: () => { From f391b88c3754047c3c9434b76328699c083723b0 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 21 Sep 2026 02:11:26 -0300 Subject: [PATCH 049/164] fix(web): move sidebar shelves as one block (#11772) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Cursor --- .../web/src/components/Sidebar.motion.test.ts | 175 ++++++++++++++++-- apps/web/src/components/Sidebar.motion.ts | 83 +++++++-- 2 files changed, 226 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 8313bed9daab..9e92c4d2c76e 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -281,10 +281,16 @@ describe("sidebar list motion", () => { layout([b, fresh]); motion.update(true); expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(fresh.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(83px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); const clone = a.clones[0]!; expect(clone.style).toMatchObject({ position: "absolute", @@ -299,10 +305,16 @@ describe("sidebar list motion", () => { expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); expect(clone.children[1]!.attributes).toEqual(icon.attributes); - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 1, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(-83px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); expect(parent.children.includes(clone)).toBe(true); motion.update(true); expect(clone.animations).toHaveLength(1); @@ -311,6 +323,106 @@ describe("sidebar list motion", () => { expect(parent.children.includes(clone)).toBe(false); }); + it("rides entering rows on the shelf displacement so an opened shelf moves as one block", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const z = new TestRow("z", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + // The shelf is anchored below the list, so two revealed rows lift the + // header and its existing rows by the same 72px. + layout([a, header, x, y, z]); + for (const row of [header, x, y, z]) row.offsetTop -= 72; + motion.update(true); + expectMove(header, 72); + expectMove(x, 72); + expect(a.animate).not.toHaveBeenCalled(); + expect(y.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expect(z.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("rides new rows on a retained header's unfinished travel", () => { + const header = new TestRow("header", 32); + const above = new TestRow("above", 39); + const incoming = new TestRow("incoming", 36); + const { motion, layout } = fixture([header]); + motion.update(true); + layout([above, header]); + motion.update(true); + header.animations[0]!.progress = 0.25; + layout([above, header, incoming]); + motion.update(true); + expect(incoming.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(-30px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("cancels in-flight row travel when a bulk update skips animation", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + const first = a.animations[0]!; + first.progress = 0.4; + const incoming = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); + layout(incoming); + motion.update(true); + expect(first.cancel).toHaveBeenCalledOnce(); + expect(incoming.every((row) => row.animate.mock.calls.length === 0)).toBe(true); + }); + + it("keeps in-flight shelf entry travel when the shelf closes mid-animation", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + header.animations[0]!.progress = 0.25; + x.animations[0]!.progress = 0.25; + entry.progress = 0.25; + layout([a, header, x]); + motion.update(true); + expect(entry.cancel).toHaveBeenCalledOnce(); + const clone = y.clones[0]!; + // Remaining entry travel (36 * 0.75) is baked into the clone's box so the + // fade starts from the row's current visual top instead of jumping to 0. + expect(clone.style.top).toBe("110px"); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.25, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(9px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expectMove(header, -9); + expectMove(x, -9); + }); + it("clears exit clones on pickup and does not fade the release commit", () => { const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; const { motion, layout, parent } = fixture([a, b]); @@ -329,10 +441,16 @@ describe("sidebar list motion", () => { expect(c.animations).toHaveLength(0); layout([c, a]); motion.update(true); - expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(a.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(0px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.dispose(); expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); }); @@ -349,15 +467,40 @@ describe("sidebar list motion", () => { motion.update(true); expect(marker.clones).toHaveLength(0); const clone = a.clones[0]!; - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.4, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(40px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.update(false); expect(parent.children).toEqual([]); expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); }); + it("retargets an in-flight entry when a later layout shift moves the row", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + entry.progress = 0.25; + for (const row of [header, x, y]) row.offsetTop += 40; + motion.update(true); + expect(entry.cancel).not.toHaveBeenCalled(); + // Remaining 27px of the 36px entry plus the new 40px shift. + expectMove(y, -13); + }); + it("skips fades when a large list change would clone too many rows", () => { const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`)); const { motion, layout, parent } = fixture(rows); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index d70c839d4941..dba6072e9d01 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,4 +1,8 @@ const motionTiming = { duration: 150, easing: "ease-out" }; +// Rows normally ride their displaced neighbour's travel. Absent a moving +// neighbour, a row still travels on its own, clamped so a tall card does not +// slide its full height. +const rowTravel = (height: number) => Math.min(height, 40); // A project filter change or a bulk snooze swaps a large part of the list at // once. Fades are the expensive part: every removed row gets a deep clone and // every clone and entering row gets its own animation, and the layout reads @@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { "(prefers-reduced-motion: reduce)", ); const running = new Map(); - const entering = new Map(); + const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release // commit can glide every row from where dnd-kit left it into its slot. @@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const remainingOffset = (node: HTMLElement) => { const current = running.get(node); - return current ? current.offset * (1 - progress(current.animation)) : 0; + const run = current ? current.offset * (1 - progress(current.animation)) : 0; + const entry = entering.get(node); + const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; + return run + enter; }; const clearFades = () => { - for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); + for (const entry of entering.values()) entry.animation.cancel(); + for (const animation of exiting.values()) animation.cancel(); for (const node of exiting.keys()) node.remove(); entering.clear(); exiting.clear(); }; - const fadeOut = (node: HTMLElement, position: RowPosition) => { + const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => { if (position.height === 0) return; // React owns the removed row; only a noninteractive copy stays for the fade. const clone = node.cloneNode(true) as HTMLElement; @@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }); parent.append(clone); const entry = entering.get(node); + const entryProgress = entry ? progress(entry.animation) : 1; const animation = clone.animate( - [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], + [ + { opacity: entryProgress, transform: "translateY(0px)" }, + { opacity: 0, transform: `translateY(${travel}px)` }, + ], motionTiming, ); exiting.set(clone, animation); @@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }; const move = (node: HTMLElement, offset: number) => { cancel(node); - if (offset === 0) return; + const entry = entering.get(node); + // The newer transform supersedes entry travel; its original opacity keeps fading. + if (entry) entry.travel = 0; + if (offset === 0 && !entry) return; const animation = node.animate( [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], motionTiming, @@ -144,15 +159,42 @@ export function createSidebarListMotion(parent: HTMLUListElement) { positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; + const movedDelta = new Map(); + const nextOrder = [...next.keys()]; + const oldOrder = positions === null ? [] : [...positions.keys()]; + const ridingDelta = ( + order: readonly HTMLElement[], + index: number, + retained: (node: HTMLElement) => boolean, + ) => { + for (let cursor = index - 1; cursor >= 0; cursor--) { + const node = order[cursor]!; + const delta = movedDelta.get(node); + if (delta !== undefined) return delta; + if (retained(node)) return remainingOffset(node); + } + return undefined; + }; if (!shouldAnimate) clearFades(); else { + // A shelf that opens above its collapsed anchor shifts every retained + // row by the same amount. Entering rows take that same displacement so + // the shelf arrives as one moving block instead of rows popping into + // their final slots; exiting rows leave by it. + for (const [node, position] of next) { + const previousTop = positions!.get(node)?.top; + if (previousTop === undefined || previousTop === position.top) continue; + movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + } for (const [node, position] of positions!) { - if (!next.has(node)) fadeOut(node, position); + if (next.has(node)) continue; + const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); + fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } } - for (const [node, animation] of entering) { + for (const [node, entry] of entering) { if (!next.has(node)) { - animation.cancel(); + entry.animation.cancel(); entering.delete(node); } } @@ -160,26 +202,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!shouldAnimate || !next.has(node)) cancel(node); } if (shouldAnimate) { - for (const [node, position] of next) { - const previousTop = positions?.get(node)?.top; + for (const [index, node] of nextOrder.entries()) { + const position = next.get(node)!; + const previousTop = positions!.get(node)?.top; if (previousTop === undefined) { if (position.height > 0) { - const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); - entering.set(node, animation); + const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); + const travel = delta === undefined ? -rowTravel(position.height) : delta; + const animation = node.animate( + [ + { opacity: 0, transform: `translateY(${travel}px)` }, + { opacity: 1, transform: "translateY(0px)" }, + ], + motionTiming, + ); + entering.set(node, { animation, travel }); animation.addEventListener( "finish", () => { - if (entering.get(node) === animation) entering.delete(node); + if (entering.get(node)?.animation === animation) entering.delete(node); }, { once: true }, ); } continue; } - if (previousTop === position.top) continue; + const delta = movedDelta.get(node); // Computed progress includes the effect's easing. Only our own // translate is carried forward; dnd-kit's transforms are never read. - move(node, previousTop + remainingOffset(node) - position.top); + if (delta !== undefined) move(node, delta); } } if (released !== null) { From 6b0a04ade021588dd9f1afbfa0e75cccdb7b2da4 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 21 Sep 2026 15:15:42 +1000 Subject: [PATCH 050/164] fix(web): offer undo after unpinning a thread (#10744) --- apps/web/src/hooks/showUndoToast.test.ts | 95 +++++++++++++++++++ apps/web/src/hooks/showUndoToast.ts | 61 ++++++++++++ apps/web/src/hooks/threadUndo.test.ts | 44 +++++++++ apps/web/src/hooks/threadUndo.ts | 21 ++++ apps/web/src/hooks/useThreadActions.ts | 23 ++++- .../src/hooks/useThreadActions.undo.test.ts | 69 ++++++++++++++ docs/user/thread-sidebar.md | 2 + 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/showUndoToast.test.ts create mode 100644 apps/web/src/hooks/showUndoToast.ts create mode 100644 apps/web/src/hooks/threadUndo.test.ts create mode 100644 apps/web/src/hooks/threadUndo.ts create mode 100644 apps/web/src/hooks/useThreadActions.undo.test.ts diff --git a/apps/web/src/hooks/showUndoToast.test.ts b/apps/web/src/hooks/showUndoToast.test.ts new file mode 100644 index 000000000000..a3798d5dc700 --- /dev/null +++ b/apps/web/src/hooks/showUndoToast.test.ts @@ -0,0 +1,95 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import * as Cause from "effect/Cause"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../components/ui/toast"; +import { showUndoToast } from "./showUndoToast"; +import * as ThreadUndo from "./threadUndo"; + +afterEach(() => vi.restoreAllMocks()); + +function setup() { + const add = vi.spyOn(toastManager, "add").mockReturnValue("undo-toast"); + const close = vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const undo = vi.fn(async () => AsyncResult.success(undefined)); + const claim = ThreadUndo.begin("pin", "env/thread"); + const options = { + title: "Thread unpinned", + description: "Thread", + failureTitle: "Restore failed", + undo, + claim, + }; + return { add, close, undo, claim, options }; +} + +function click(add: ReturnType["add"], index = 0) { + const handler = add.mock.calls[index]?.[0].actionProps?.onClick; + if (!handler) throw new Error("Undo action is missing"); + return handler({} as Parameters[0]); +} + +describe("showUndoToast", () => { + it("ignores a stale toast and lets the latest action run only once", async () => { + const { add, close, undo, options } = setup(); + showUndoToast(options); + ThreadUndo.invalidate("pin", "env/thread"); + showUndoToast({ ...options, claim: ThreadUndo.begin("pin", "env/thread") }); + await click(add); + expect(undo).not.toHaveBeenCalled(); + await click(add, 1); + await click(add, 1); + expect(undo).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledExactlyOnceWith("undo-toast"); + }); + + it("releases the claim on close and rejects a later click", async () => { + const { add, undo, claim, options } = setup(); + showUndoToast(options); + add.mock.calls[0]?.[0].onClose?.(); + expect(claim.isCurrent()).toBe(false); + await click(add); + expect(undo).not.toHaveBeenCalled(); + }); + + it("does not show a toast for a late completion after a newer action", () => { + const { add, options } = setup(); + ThreadUndo.invalidate("pin", "env/thread"); + showUndoToast(options); + expect(add).not.toHaveBeenCalled(); + }); + + it("reports a failed restore and releases its claim", async () => { + const { add, claim, options } = setup(); + showUndoToast({ + ...options, + undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), + }); + await click(add); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }), + ); + expect(claim.isCurrent()).toBe(false); + }); + + it("reports a rejected restore promise", async () => { + const { add, options } = setup(); + showUndoToast({ + ...options, + undo: async () => { + throw new Error("disconnected"); + }, + }); + await click(add); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", description: "disconnected" }), + ); + }); + + it("does not report interrupted restores as errors", async () => { + const { add, options } = setup(); + showUndoToast({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) }); + await click(add); + expect(add).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/showUndoToast.ts b/apps/web/src/hooks/showUndoToast.ts new file mode 100644 index 000000000000..3b9393f098cc --- /dev/null +++ b/apps/web/src/hooks/showUndoToast.ts @@ -0,0 +1,61 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import type * as ThreadUndo from "./threadUndo"; + +/** Shows a single-use Undo while its thread action still owns the claim. */ +export function showUndoToast({ + title, + description, + undo, + failureTitle, + claim, +}: { + title: string; + description: string | undefined; + undo: () => Promise>; + failureTitle: string; + claim: ReturnType; +}) { + if (!claim.isCurrent()) return; + let undoStarted = false; + const reportFailure = (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }; + const toastId = toastManager.add({ + ...stackedThreadToast({ + type: "success", + title, + description, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: async () => { + if (undoStarted || !claim.isCurrent()) return; + undoStarted = true; + claim.finish(); + toastManager.close(toastId); + try { + const result = await undo(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure(squashAtomCommandFailure(result)); + } + } catch (error) { + reportFailure(error); + } + }, + }, + }), + onClose: claim.finish, + }); +} diff --git a/apps/web/src/hooks/threadUndo.test.ts b/apps/web/src/hooks/threadUndo.test.ts new file mode 100644 index 000000000000..58762c625449 --- /dev/null +++ b/apps/web/src/hooks/threadUndo.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import * as ThreadUndo from "./threadUndo"; + +describe("thread action ownership", () => { + it("keeps claims for different action kinds independent", () => { + const pin = ThreadUndo.begin("pin", "env/shared"); + const archive = ThreadUndo.begin("archive", "env/shared"); + ThreadUndo.invalidate("pin", "env/shared"); + expect(pin.isCurrent()).toBe(false); + expect(archive.isCurrent()).toBe(true); + archive.finish(); + }); + + it("does not revive the first Undo after a later pin and unpin", () => { + const firstUnpin = ThreadUndo.begin("pin", "env/thread"); + ThreadUndo.invalidate("pin", "env/thread"); + const secondUnpin = ThreadUndo.begin("pin", "env/thread"); + expect(firstUnpin.isCurrent()).toBe(false); + expect(secondUnpin.isCurrent()).toBe(true); + firstUnpin.finish(); + expect(secondUnpin.isCurrent()).toBe(true); + secondUnpin.finish(); + expect(firstUnpin.isCurrent()).toBe(false); + }); + + it("rejects a late unpin completion after a newer pin started", () => { + const pendingUnpin = ThreadUndo.begin("pin", "env/late"); + ThreadUndo.invalidate("pin", "env/late"); + expect(pendingUnpin.isCurrent()).toBe(false); + }); + + it("expires an Undo without invalidating another environment or thread", () => { + const first = ThreadUndo.begin("pin", "one/thread"); + const otherEnvironment = ThreadUndo.begin("pin", "two/thread"); + const otherThread = ThreadUndo.begin("pin", "one/other"); + first.finish(); + expect(first.isCurrent()).toBe(false); + expect(otherEnvironment.isCurrent()).toBe(true); + expect(otherThread.isCurrent()).toBe(true); + otherEnvironment.finish(); + otherThread.finish(); + }); +}); diff --git a/apps/web/src/hooks/threadUndo.ts b/apps/web/src/hooks/threadUndo.ts new file mode 100644 index 000000000000..a9e5c179fc87 --- /dev/null +++ b/apps/web/src/hooks/threadUndo.ts @@ -0,0 +1,21 @@ +// Shared across hook instances so sidebar, header and menu actions invalidate each other. +const currentActions = new Map(); + +/** Claims one kind of thread action; a later claim of that kind expires its Undo. */ +export function begin(kind: string, threadKey: string) { + const key = JSON.stringify([kind, threadKey]); + const token = Symbol(); + currentActions.set(key, token); + const isCurrent = () => currentActions.get(key) === token; + return { + isCurrent, + finish: () => { + if (isCurrent()) currentActions.delete(key); + }, + }; +} + +/** Expires only this action kind, leaving unrelated thread actions intact. */ +export function invalidate(kind: string, threadKey: string) { + currentActions.delete(JSON.stringify([kind, threadKey])); +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 55ff9992ce57..9a8dfe48c586 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -42,6 +42,8 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; +import * as ThreadUndo from "./threadUndo"; +import { showUndoToast } from "./showUndoToast"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Schema.TaggedError()( @@ -582,6 +584,7 @@ export function useThreadActions() { const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) ? (opts.orderKey ?? topOfPinnedRunOrderKey()) : undefined; + ThreadUndo.invalidate("pin", scopedThreadKey(target)); return pinThreadMutation({ environmentId: target.environmentId, input: { @@ -605,12 +608,27 @@ export function useThreadActions() { ), ); } - return unpinThreadMutation({ + const thread = readThreadShell(target); + const orderKey = thread?.pinOrderKey ?? undefined; + const action = ThreadUndo.begin("pin", scopedThreadKey(target)); + const result = await unpinThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag === "Success" && action.isCurrent()) { + showUndoToast({ + title: "Thread unpinned", + description: thread?.title, + claim: action, + undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), + failureTitle: "Failed to undo unpin", + }); + } else { + action.finish(); + } + return result; }, - [unpinThreadMutation], + [pinThread, unpinThreadMutation], ); const confirmAndUnpinThread = useCallback( @@ -648,6 +666,7 @@ export function useThreadActions() { ), ); } + ThreadUndo.invalidate("pin", scopedThreadKey(target)); return reorderPinnedThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, orderKey }, diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts new file mode 100644 index 000000000000..6185a292c577 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -0,0 +1,69 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useThreadActions } from "./useThreadActions"; +import { threadEnvironment } from "../state/threads"; +import { toastManager } from "../components/ui/toast"; + +const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() })); +vi.mock("react", async (original) => ({ + ...(await original()), + useCallback: (callback: unknown) => callback, + useMemo: (create: () => unknown) => create(), + useRef: (value: unknown) => ({ current: value }), +})); +vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) })); +vi.mock("./useSettings", () => ({ useClientSettings: () => false })); +vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); +vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); +vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + readEnvironmentSupportsPinning: () => true, + readEnvironmentSupportsPinReorder: () => true, + readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }), +})); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === threadEnvironment.pin + ? commands.pin + : command === threadEnvironment.unpin + ? commands.unpin + : vi.fn(), +})); + +afterEach(() => vi.restoreAllMocks()); + +describe("unpin Undo", () => { + it("ignores an old toast across hook instances and still restores the latest unpin", async () => { + commands.pin.mockResolvedValue({ _tag: "Success", value: undefined }); + commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined }); + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const sidebar = useThreadActions(); + const header = useThreadActions(); + const target = { + environmentId: EnvironmentId.make("undo-env"), + threadId: ThreadId.make("thread"), + }; + await sidebar.unpinThread(target); + const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick; + await header.pinThread(target, { orderKey: "a1" }); + await header.unpinThread(target); + const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick; + expect(staleUndo).toBeTypeOf("function"); + expect(latestUndo).toBeTypeOf("function"); + const event = {} as Parameters>[0]; + staleUndo?.(event); + expect(commands.pin).toHaveBeenCalledTimes(1); + latestUndo?.(event); + expect(commands.pin).toHaveBeenCalledTimes(2); + expect(commands.pin).toHaveBeenLastCalledWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey: "a0" }, + }); + latestUndo?.(event); + expect(commands.pin).toHaveBeenCalledTimes(2); + }); +}); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 650954d7a586..e921b4c57a4a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -29,6 +29,8 @@ and worktree while you stay in the new thread composer. This requires a Git proj Pin a thread from its menu to keep it above your active work. +On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position. + On web and desktop, you can also drag files from your computer onto any thread row: the thread opens and the files are attached in its composer, ready for your next message. The same per-message file limits apply as when attaching From 5781b5240bd5d2e21c651f6b228975ac40cbd67b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 22:26:38 -0700 Subject: [PATCH 051/164] feat(web): undo settle, snooze and archive, with a mod+z shortcut (#12848) Co-authored-by: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 18 +- apps/web/src/components/Sidebar.tsx | 31 +-- apps/web/src/hooks/showUndoToast.test.ts | 34 ++- apps/web/src/hooks/showUndoToast.ts | 54 ++-- apps/web/src/hooks/useThreadActionMenu.ts | 26 +- apps/web/src/hooks/useThreadActions.ts | 236 +++++++++++++----- .../src/hooks/useThreadActions.undo.test.ts | 212 ++++++++++++++-- apps/web/src/keybindings.test.ts | 27 ++ apps/web/src/keybindings.ts | 4 + apps/web/src/lib/editableFocus.ts | 17 ++ docs/user/keybindings.md | 12 +- docs/user/thread-sidebar.md | 6 +- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 14 files changed, 520 insertions(+), 159 deletions(-) create mode 100644 apps/web/src/lib/editableFocus.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 667eb0e11f6c..c9540e4a0a84 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,6 +229,8 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { undoLatestThreadAction } from "../hooks/showUndoToast"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); const getShortcutContext = useCallback( - () => ({ + (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), previewFocus: isPreviewFocused(), previewOpen: previewPanelOpen, + editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, isWeb: !isElectron, isDesktop: isElectron, @@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = getShortcutContext(); + const shortcutContext = getShortcutContext(event.target); if ( !shortcutContext.terminalFocus && @@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.undo") { + // Only claim the chord when there is an Undo to run; otherwise the + // page keeps its native behavior for the key. + if (event.repeat) return; + if (undoLatestThreadAction()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + if (command === "thread.pin") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffe4c7dd0a9..23c55fe8ecce 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -208,12 +208,7 @@ import { type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { - resolveSnoozePresets, - snoozeWakeDescription, - snoozeWakeLabel, - type SnoozePreset, -} from "./Sidebar.snooze"; +import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; @@ -3082,7 +3077,9 @@ export default function Sidebar() { settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); + const result = await settleThread(threadRef, { + undoToast: opts.coSettlingKeys === undefined, + }); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { @@ -3734,7 +3731,9 @@ export default function Sidebar() { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread(threadRef, preset.snoozedUntil, { + undoToast: opts.coSnoozingKeys === undefined, + }); if (result._tag === "Failure") { // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) @@ -3780,23 +3779,9 @@ export default function Sidebar() { ); return; } - if (outcome.status !== "success") return; - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, - }), - ); })(); }, - [attemptUnsnooze, performSnooze, timestampFormat], + [performSnooze], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); diff --git a/apps/web/src/hooks/showUndoToast.test.ts b/apps/web/src/hooks/showUndoToast.test.ts index a3798d5dc700..ced0b2bc5504 100644 --- a/apps/web/src/hooks/showUndoToast.test.ts +++ b/apps/web/src/hooks/showUndoToast.test.ts @@ -3,7 +3,7 @@ import * as Cause from "effect/Cause"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { toastManager } from "../components/ui/toast"; -import { showUndoToast } from "./showUndoToast"; +import { showUndoToast, undoLatestThreadAction } from "./showUndoToast"; import * as ThreadUndo from "./threadUndo"; afterEach(() => vi.restoreAllMocks()); @@ -93,3 +93,35 @@ describe("showUndoToast", () => { expect(add).toHaveBeenCalledOnce(); }); }); + +describe("undoLatestThreadAction", () => { + it("runs the newest live Undo once and then reports nothing to undo", () => { + const { options } = setup(); + const older = vi.fn(async () => AsyncResult.success(undefined)); + const newer = vi.fn(async () => AsyncResult.success(undefined)); + showUndoToast({ ...options, undo: older, claim: ThreadUndo.begin("settle", "env/a") }); + showUndoToast({ ...options, undo: newer, claim: ThreadUndo.begin("snooze", "env/b") }); + expect(undoLatestThreadAction()).toBe(true); + expect(newer).toHaveBeenCalledOnce(); + expect(older).not.toHaveBeenCalled(); + expect(undoLatestThreadAction()).toBe(true); + expect(older).toHaveBeenCalledOnce(); + expect(undoLatestThreadAction()).toBe(false); + }); + + it("skips a superseded toast and a closed toast", () => { + const { add, options } = setup(); + const superseded = vi.fn(async () => AsyncResult.success(undefined)); + const closed = vi.fn(async () => AsyncResult.success(undefined)); + const live = vi.fn(async () => AsyncResult.success(undefined)); + showUndoToast({ ...options, undo: live, claim: ThreadUndo.begin("archive", "env/live") }); + showUndoToast({ ...options, undo: closed, claim: ThreadUndo.begin("archive", "env/closed") }); + add.mock.calls[1]?.[0].onClose?.(); + showUndoToast({ ...options, undo: superseded, claim: ThreadUndo.begin("pin", "env/stale") }); + ThreadUndo.invalidate("pin", "env/stale"); + expect(undoLatestThreadAction()).toBe(true); + expect(superseded).not.toHaveBeenCalled(); + expect(closed).not.toHaveBeenCalled(); + expect(live).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/showUndoToast.ts b/apps/web/src/hooks/showUndoToast.ts index 3b9393f098cc..14d532411e7f 100644 --- a/apps/web/src/hooks/showUndoToast.ts +++ b/apps/web/src/hooks/showUndoToast.ts @@ -7,6 +7,20 @@ import { import { stackedThreadToast, toastManager } from "../components/ui/toast"; import type * as ThreadUndo from "./threadUndo"; +// Undo toasts still on screen, oldest first, so the `thread.undo` shortcut +// mirrors the newest toast's button without knowing which action it was. +const liveUndos: Array<() => Promise | null> = []; + +/** Runs the newest Undo whose claim still holds; false when nothing is left to undo. */ +export function undoLatestThreadAction(): boolean { + // A superseded entry drops itself when tried, so keep going until one + // runs or the list is empty. + while (liveUndos.length > 0) { + if (liveUndos[liveUndos.length - 1]?.() !== null) return true; + } + return false; +} + /** Shows a single-use Undo while its thread action still owns the claim. */ export function showUndoToast({ title, @@ -23,6 +37,7 @@ export function showUndoToast({ }) { if (!claim.isCurrent()) return; let undoStarted = false; + let toastId: string | undefined; const reportFailure = (error: unknown) => { toastManager.add( stackedThreadToast({ @@ -32,7 +47,26 @@ export function showUndoToast({ }), ); }; - const toastId = toastManager.add({ + const forget = () => { + const index = liveUndos.indexOf(run); + if (index !== -1) liveUndos.splice(index, 1); + }; + const run = () => { + forget(); + if (undoStarted || !claim.isCurrent()) return null; + undoStarted = true; + claim.finish(); + if (toastId !== undefined) toastManager.close(toastId); + return undo() + .then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure(squashAtomCommandFailure(result)); + } + }) + .catch(reportFailure); + }; + liveUndos.push(run); + toastId = toastManager.add({ ...stackedThreadToast({ type: "success", title, @@ -41,21 +75,13 @@ export function showUndoToast({ actionProps: { children: "Undo", onClick: async () => { - if (undoStarted || !claim.isCurrent()) return; - undoStarted = true; - claim.finish(); - toastManager.close(toastId); - try { - const result = await undo(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportFailure(squashAtomCommandFailure(result)); - } - } catch (error) { - reportFailure(error); - } + await run(); }, }, }), - onClose: claim.finish, + onClose: () => { + claim.finish(); + forget(); + }, }); } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index b4bf973f0231..8a24c5110fa7 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; +import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, type ThreadActionMenuId, @@ -162,29 +162,9 @@ export function useThreadActionMenu(input: { : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); if (!preset) return; const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); - } - return; + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); } - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => { - void unsnoozeThread(threadRef).then((undone) => { - if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { - failureToast("Failed to wake thread", squashAtomCommandFailure(undone)); - } - }); - }, - }, - }), - ); return; } const reportFailure = async ( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 9a8dfe48c586..40a06ba9676a 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; +import { snoozeWakeDescription } from "../components/Sidebar.snooze"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -222,6 +223,7 @@ export function useThreadActions() { const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); + const timestampFormat = useClientSettings((settings) => settings.timestampFormat); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -252,6 +254,30 @@ export function useThreadActions() { return resolveThreadRouteRef(currentRouteParams); }, [router]); + const unarchiveThread = useCallback( + async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => { + ThreadUndo.invalidate("archive", scopedThreadKey(target)); + const result = await unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Failure") { + return result; + } + refreshArchivedThreadsForEnvironment(target.environmentId); + if (opts.navigate) { + return settlePromise(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(target), + }), + ); + } + return result; + }, + [router, unarchiveThreadMutation], + ); + const archiveThread = useCallback( async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); @@ -272,11 +298,13 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; + const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); if (archiveResult._tag === "Failure") { + action.finish(); return archiveResult; } const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); @@ -285,6 +313,14 @@ export function useThreadActions() { } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); + showUndoToast({ + title: "Thread archived", + description: thread.title, + claim: action, + // Undo also brings the reader back when archiving moved them to a draft. + undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), + failureTitle: "Failed to undo archive", + }); if (shouldNavigateToDraft) { const navigationResult = await settlePromise(() => @@ -298,21 +334,13 @@ export function useThreadActions() { return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], - ); - - const unarchiveThread = useCallback( - async (target: ScopedThreadRef) => { - const result = await unarchiveThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); - if (result._tag === "Success") { - refreshArchivedThreadsForEnvironment(target.environmentId); - } - return result; - }, - [unarchiveThreadMutation], + [ + archiveThreadMutation, + getCurrentRouteThreadRef, + markThreadVisited, + resolveThreadTarget, + unarchiveThread, + ], ); const deleteThread = useCallback( @@ -508,38 +536,6 @@ export function useThreadActions() { ], ); - const settleThread = useCallback( - async (target: ScopedThreadRef) => { - // Version skew: never send the command to a server that predates it — - // the raw protocol rejection would read as a random failure. - if (!readEnvironmentSupportsSettlement(target.environmentId)) { - return AsyncResult.failure( - Cause.fail( - new ThreadSettlementUnsupportedError({ - environmentId: target.environmentId, - threadId: target.threadId, - }), - ), - ); - } - const resolved = resolveThreadTarget(target); - const wokeAt = resolved - ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) - : null; - // Settle is a high-frequency lifecycle action and stays silent — no - // toast. - const result = await settleThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); - if (result._tag === "Success" && wokeAt !== null) { - markThreadVisited(scopedThreadKey(target), wokeAt); - } - return result; - }, - [markThreadVisited, resolveThreadTarget, settleThreadMutation], - ); - const unsettleThread = useCallback( async (target: ScopedThreadRef) => { if (!readEnvironmentSupportsSettlement(target.environmentId)) { @@ -552,6 +548,7 @@ export function useThreadActions() { ), ); } + ThreadUndo.invalidate("settle", scopedThreadKey(target)); // reason "user" pins the thread active: auto-settle (PR merged / // inactivity) stays suppressed until real activity clears the pin. return unsettleThreadMutation({ @@ -631,6 +628,89 @@ export function useThreadActions() { [pinThread, unpinThreadMutation], ); + const settleThread = useCallback( + async ( + target: ScopedThreadRef, + // Batch callers settle a selection at once and stay silent as before. + opts: { undoToast?: boolean } = {}, + ) => { + // Version skew: never send the command to a server that predates it — + // the raw protocol rejection would read as a random failure. + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + const resolved = resolveThreadTarget(target); + const wokeAt = resolved + ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) + : null; + // Settling also drops the pin and the snooze server-side, so Undo + // has to put those back as well. + const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null; + const wasPinned = resolved?.thread.pinnedAt != null; + const snoozedUntil = resolved?.thread.snoozedUntil ?? null; + // An older unpin/snooze Undo would re-pin or re-snooze, and the server + // treats either as a promotion that un-settles; settling supersedes them. + ThreadUndo.invalidate("pin", scopedThreadKey(target)); + ThreadUndo.invalidate("snooze", scopedThreadKey(target)); + const action = ThreadUndo.begin("settle", scopedThreadKey(target)); + const result = await settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag !== "Success") { + action.finish(); + return result; + } + if (wokeAt !== null) { + markThreadVisited(scopedThreadKey(target), wokeAt); + } + if (opts.undoToast === false) { + action.finish(); + return result; + } + showUndoToast({ + title: "Thread settled", + description: resolved?.thread.title, + claim: action, + undo: async () => { + const unsettled = await unsettleThread(target); + if (unsettled._tag !== "Success") return unsettled; + if (wasPinned) { + const pinned = await pinThread( + target, + pinOrderKey == null ? {} : { orderKey: pinOrderKey }, + ); + if (pinned._tag !== "Success") return pinned; + } + if (snoozedUntil !== null) { + return snoozeThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil }, + }); + } + return unsettled; + }, + failureTitle: "Failed to undo settle", + }); + return result; + }, + [ + markThreadVisited, + pinThread, + resolveThreadTarget, + settleThreadMutation, + snoozeThreadMutation, + unsettleThread, + ], + ); + const confirmAndUnpinThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -695,8 +775,34 @@ export function useThreadActions() { [reorderActiveThreadMutation], ); + const unsnoozeThread = useCallback( + async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsSnooze(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSnoozeUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + ThreadUndo.invalidate("snooze", scopedThreadKey(target)); + return unsnoozeThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }, + [unsnoozeThreadMutation], + ); + const snoozeThread = useCallback( - async (target: ScopedThreadRef, snoozedUntil: string) => { + async ( + target: ScopedThreadRef, + snoozedUntil: string, + // Batch callers report one toast for the whole selection instead. + opts: { undoToast?: boolean } = {}, + ) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( @@ -722,32 +828,26 @@ export function useThreadActions() { ), ); } - return snoozeThreadMutation({ + const action = ThreadUndo.begin("snooze", scopedThreadKey(target)); + const result = await snoozeThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, snoozedUntil }, }); - }, - [resolveThreadTarget, snoozeThreadMutation], - ); - - const unsnoozeThread = useCallback( - async (target: ScopedThreadRef) => { - if (!readEnvironmentSupportsSnooze(target.environmentId)) { - return AsyncResult.failure( - Cause.fail( - new ThreadSnoozeUnsupportedError({ - environmentId: target.environmentId, - threadId: target.threadId, - }), - ), - ); + if (result._tag !== "Success" || opts.undoToast === false) { + action.finish(); + return result; } - return unsnoozeThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId, reason: "user" }, + // Snooze hides the row, so the toast is the only confirmation. + showUndoToast({ + title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, + description: resolved?.thread.title, + claim: action, + undo: () => unsnoozeThread(target), + failureTitle: "Failed to wake thread", }); + return result; }, - [unsnoozeThreadMutation], + [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread], ); const confirmAndDeleteThread = useCallback( diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index 6185a292c577..eacffd146e20 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -1,69 +1,233 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { useThreadActions } from "./useThreadActions"; import { threadEnvironment } from "../state/threads"; import { toastManager } from "../components/ui/toast"; -const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() })); +const commands = vi.hoisted(() => ({ + pin: vi.fn(), + unpin: vi.fn(), + archive: vi.fn(), + unarchive: vi.fn(), + settle: vi.fn(), + unsettle: vi.fn(), + snooze: vi.fn(), + unsnooze: vi.fn(), +})); +const router = vi.hoisted(() => ({ + navigate: vi.fn(async () => {}), + state: { matches: [{ params: {} as Record }] }, +})); vi.mock("react", async (original) => ({ ...(await original()), useCallback: (callback: unknown) => callback, useMemo: (create: () => unknown) => create(), useRef: (value: unknown) => ({ current: value }), })); -vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) })); +vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); vi.mock("./useSettings", () => ({ useClientSettings: () => false })); vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); +vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() })); +const threadShell = vi.hoisted(() => ({ + title: "Thread", + pinOrderKey: "a0", + pinnedAt: null as string | null, + snoozedUntil: null as string | null, + projectId: "project", + environmentId: "undo-env", + session: null, +})); vi.mock("../state/entities", async (original) => ({ ...(await original()), readEnvironmentSupportsPinning: () => true, readEnvironmentSupportsPinReorder: () => true, - readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }), + readEnvironmentSupportsSettlement: () => true, + readEnvironmentSupportsSnooze: () => true, + readThreadShell: () => threadShell, })); vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: (command: unknown) => - command === threadEnvironment.pin - ? commands.pin - : command === threadEnvironment.unpin - ? commands.unpin - : vi.fn(), + useAtomCommand: (command: unknown) => { + switch (command) { + case threadEnvironment.pin: + return commands.pin; + case threadEnvironment.unpin: + return commands.unpin; + case threadEnvironment.archive: + return commands.archive; + case threadEnvironment.unarchive: + return commands.unarchive; + case threadEnvironment.settle: + return commands.settle; + case threadEnvironment.unsettle: + return commands.unsettle; + case threadEnvironment.snooze: + return commands.snooze; + case threadEnvironment.unsnooze: + return commands.unsnooze; + default: + return vi.fn(); + } + }, })); +const target = { + environmentId: EnvironmentId.make("undo-env"), + threadId: ThreadId.make("thread"), +}; +const event = {} as Parameters["onClick"]>>[0]; + +function undoOf( + add: { mock: { calls: Array<[Parameters[0]]> } }, + index: number, +) { + const onClick = add.mock.calls[index]?.[0].actionProps?.onClick; + expect(onClick).toBeTypeOf("function"); + return () => onClick?.(event); +} + +beforeEach(() => { + for (const command of Object.values(commands)) { + command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + } + router.navigate.mockClear(); + router.state.matches[0]!.params = {}; + threadShell.pinnedAt = null; + threadShell.snoozedUntil = null; +}); afterEach(() => vi.restoreAllMocks()); describe("unpin Undo", () => { it("ignores an old toast across hook instances and still restores the latest unpin", async () => { - commands.pin.mockResolvedValue({ _tag: "Success", value: undefined }); - commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined }); const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); vi.spyOn(toastManager, "close").mockImplementation(() => {}); const sidebar = useThreadActions(); const header = useThreadActions(); - const target = { - environmentId: EnvironmentId.make("undo-env"), - threadId: ThreadId.make("thread"), - }; await sidebar.unpinThread(target); - const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick; + const staleUndo = undoOf(add, 0); await header.pinThread(target, { orderKey: "a1" }); await header.unpinThread(target); - const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick; - expect(staleUndo).toBeTypeOf("function"); - expect(latestUndo).toBeTypeOf("function"); - const event = {} as Parameters>[0]; - staleUndo?.(event); + const latestUndo = undoOf(add, 1); + await staleUndo(); expect(commands.pin).toHaveBeenCalledTimes(1); - latestUndo?.(event); + await latestUndo(); expect(commands.pin).toHaveBeenCalledTimes(2); expect(commands.pin).toHaveBeenLastCalledWith({ environmentId: target.environmentId, input: { threadId: target.threadId, orderKey: "a0" }, }); - latestUndo?.(event); + await latestUndo(); expect(commands.pin).toHaveBeenCalledTimes(2); }); }); + +describe("archive Undo", () => { + it("unarchives and returns to the thread when archiving left it", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + const actions = useThreadActions(); + await actions.archiveThread(target); + expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" })); + await undoOf(add, 0)(); + expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + expect(router.navigate).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/$environmentId/$threadId", + params: { environmentId: target.environmentId, threadId: target.threadId }, + }), + ); + }); + + it("stays put when the archived thread was not open", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.archiveThread(target); + await undoOf(add, 0)(); + expect(commands.unarchive).toHaveBeenCalledOnce(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("shows no Undo when the archive failed", async () => { + commands.archive.mockResolvedValue({ _tag: "Failure", cause: new Error("nope") }); + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + await useThreadActions().archiveThread(target); + expect(add).not.toHaveBeenCalled(); + }); +}); + +describe("settle and snooze Undo", () => { + it("un-settles from the toast and expires the Undo after a manual un-settle", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.settleThread(target); + expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" })); + const undo = undoOf(add, 0); + await actions.unsettleThread(target); + await undo(); + expect(commands.unsettle).toHaveBeenCalledOnce(); + }); + + it("re-pins and re-snoozes a thread that settling had cleared", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const snoozedUntil = "2030-01-01T09:00:00.000Z"; + threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; + threadShell.snoozedUntil = snoozedUntil; + const actions = useThreadActions(); + await actions.settleThread(target); + await undoOf(add, 0)(); + expect(commands.unsettle).toHaveBeenCalledOnce(); + expect(commands.pin).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey: "a0" }, + }); + expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil }, + }); + }); + + it("expires an older unpin Undo when the thread is settled", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.unpinThread(target); + const staleUnpinUndo = undoOf(add, 0); + await actions.settleThread(target); + await staleUnpinUndo(); + expect(commands.pin).not.toHaveBeenCalled(); + }); + + it("stays silent for batch settles", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + await useThreadActions().settleThread(target, { undoToast: false }); + expect(add).not.toHaveBeenCalled(); + }); + + it("wakes the thread from the snooze toast", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString()); + expect(add).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringMatching(/^Snoozed until /) }), + ); + await undoOf(add, 0)(); + expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }); +}); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d9c3eccbeb4e..82348be3b6e4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -226,6 +226,33 @@ describe("settle thread shortcut", () => { }); }); +describe("thread undo shortcut", () => { + it("resolves mod+z with nothing editable focused", () => { + assert.equal( + resolveShortcutCommand(event({ key: "z", metaKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false, editableFocus: false }, + }), + "thread.undo", + ); + }); + + it("leaves native undo alone inside text fields and terminals", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Win32", + context: { editableFocus: true }, + }), + ); + assert.isNull( + resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + }); +}); + describe("copy thread reference shortcut", () => { it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { assert.equal( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 84d325c38d97..b615d3564437 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -36,6 +36,9 @@ export interface ShortcutMatchContext { previewOpen: boolean; isWeb: boolean; isDesktop: boolean; + /** A text field, textarea, select or rich-text editor owns the keyboard. + Optional: only chords that collide with native editing consult it. */ + editableFocus?: boolean; [key: string]: boolean; } @@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc previewOpen: false, isWeb: !isElectron, isDesktop: isElectron, + editableFocus: false, ...options?.context, }; } diff --git a/apps/web/src/lib/editableFocus.ts b/apps/web/src/lib/editableFocus.ts new file mode 100644 index 000000000000..ad704a39f616 --- /dev/null +++ b/apps/web/src/lib/editableFocus.ts @@ -0,0 +1,17 @@ +const EDITABLE_SELECTOR = [ + "input", + "textarea", + "select", + '[contenteditable=""]', + '[contenteditable="true"]', + '[contenteditable="plaintext-only"]', + '[role="textbox"]', +].join(","); + +/** + * Whether a text-editing element owns the keyboard. Shortcuts that share + * their chord with native editing (mod+z) must yield when this is true. + */ +export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean { + return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 9c2f92a3e336..78c792d29851 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -84,9 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`. ## When conditions Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`, -`previewOpen`, `modelPickerOpen`, `isWeb`, and `isDesktop`. `isWeb` is true in a -browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to -`false`. +`previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`. +`editableFocus` is true while a text field, the composer, or another editor has +the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the +desktop app. Unknown keys evaluate to `false`. `mod+1` through `mod+9` jump to the first nine threads, and to models while the model picker is open. Those defaults use `isDesktop` so they do not steal the @@ -110,6 +111,11 @@ a shortcut. `thread.stop` interrupts the running turn in the focused thread. It has no default shortcut; assign one in **Settings → Keybindings**. +`thread.undo` (`mod+z` by default) reverses the most recent thread action that is +still offering **Undo** in a notification, such as an unpin, settle, snooze, or +archive. Its default rule skips text fields and terminals so native undo keeps +working there. + `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your [new-thread defaults](./thread-sidebar.md#start-a-thread). diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e921b4c57a4a..3d3215a193f6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -29,7 +29,11 @@ and worktree while you stay in the new thread composer. This requires a Git proj Pin a thread from its menu to keep it above your active work. -On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position. +On web and desktop, unpinning, settling, snoozing, and archiving a thread each show +a notification with **Undo** for five seconds. Undo restores the thread's previous +state, including its pinned position, and reopens an archived thread you were +viewing. `mod+z` triggers the most recent Undo when no text field is focused; see +[Keybindings](./keybindings.md#commands-with-special-behavior). On web and desktop, you can also drag files from your computer onto any thread row: the thread opens and the files are attached in its composer, ready for diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 44d76ded1703..5093ba9a25ad 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [ "thread.copyReference", "thread.settle", "thread.pin", + "thread.undo", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index d76d4c248a97..ee159b6838a3 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, + { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, From 1eeeabb26f654c679efe265cd03edf69c3df3a6f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 23:24:41 -0700 Subject: [PATCH 052/164] fix(mobile): use a proper pull request icon on iOS (#12855) --- apps/mobile/src/components/AppSymbol.ios.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx index 8f3f5f8abf75..e58d2af7dbb2 100644 --- a/apps/mobile/src/components/AppSymbol.ios.tsx +++ b/apps/mobile/src/components/AppSymbol.ios.tsx @@ -1,3 +1,4 @@ +import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import { SymbolView as ExpoSymbolView } from "expo-symbols"; import { withUniwind } from "uniwind"; import type { AppSymbolViewProps } from "./AppSymbol"; @@ -6,10 +7,24 @@ export type { SFSymbol } from "expo-symbols"; export type { AppSymbolName } from "./AppSymbol"; /** - * Keep the iOS implementation isolated from the Android Tabler fallback so - * Metro does not initialize the icon package when iOS renders SF Symbols. + * Use SF Symbols on iOS except for pull requests, which have no matching + * native glyph. Import only that Tabler icon to keep the bundle small. */ function AppSymbolView(props: AppSymbolViewProps) { + const name = typeof props.name === "string" ? props.name : props.name.ios; + if (name === "arrow.triangle.pull") { + return ( + + ); + } + return ; } From b379b5b1407b4c718095cd13395173b6cc005218 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:28:00 -0700 Subject: [PATCH 053/164] test(web): remove redundant favicon test (#12856) --- apps/web/src/lib/favicon.test.ts | 42 -------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 apps/web/src/lib/favicon.test.ts diff --git a/apps/web/src/lib/favicon.test.ts b/apps/web/src/lib/favicon.test.ts deleted file mode 100644 index 8fe9e65eb4d2..000000000000 --- a/apps/web/src/lib/favicon.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { faviconUrlForOrigin } from "./favicon"; - -describe("faviconUrlForOrigin", () => { - it("never sends private origin hostnames to the public provider", () => { - for (const url of [ - "http://localhost:3000/", - "http://127.0.0.1:3000/", - "http://0.0.0.0:3000/", - "http://devbox:3000/", - "https://24x.xf.local/", - "http://printer.home.arpa/", - "http://192.168.1.20:3000/", - "http://[::]/", - "http://[::ffff:192.168.1.20]/", - "http://100.65.180.100:3000/", - "https://devbox.example.ts.net/", - "http://192.0.2.1/", - "http://198.51.100.1/", - "http://203.0.113.1/", - "http://224.0.0.1/", - "http://240.0.0.1/", - "http://[2001:db8::1]/", - "http://[ff02::1]/", - "http://app.test../", - "https://24x.xf.local../", - "http://printer.home.arpa../", - "https://devbox.example.ts.net../", - "http://127.0.0.1../", - "http://127.1../", - "http://10.1../", - "http://172.16.1../", - "http://192.168.1../", - ]) { - expect(faviconUrlForOrigin(url)).toBeNull(); - } - expect(faviconUrlForOrigin("https://example.com/path", 32)).toBe( - "https://www.google.com/s2/favicons?domain=example.com&sz=32", - ); - }); -}); From 1de563c1491c7d82563e4553bf5bf689ce6adbb9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 00:14:19 -0700 Subject: [PATCH 054/164] feat(devices): offer manual updates in tool version details (#12877) --- apps/server/src/auth/RpcAuthorization.test.ts | 7 ++ apps/server/src/auth/RpcAuthorization.ts | 4 +- apps/server/src/device/DeviceService.test.ts | 73 ++++++++++++++++- apps/server/src/device/DeviceService.ts | 34 +++++++- apps/server/src/ws.ts | 10 ++- .../components/device/DeviceToolVersions.tsx | 2 +- .../settings/IntegrationsSettings.tsx | 80 +++++++++++++++---- packages/contracts/src/device.ts | 3 + 8 files changed, 188 insertions(+), 25 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index c972e5372f4c..2c747fc71310 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -82,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin AuthOrchestrationOperateScope, ); }); + +it("requires operate permission for tool updates even alongside a read-only check", () => { + expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 819d98cfc92c..68a167083762 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -184,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope /** Retrying can install or restart tools even though ordinary listing is readable. */ export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => - input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope; + input.retryHostId || input.updateTool + ? AuthOrchestrationOperateScope + : AuthOrchestrationReadScope; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index ce57c724f8be..7a81d757c0ac 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + DeviceOperationError, LOCAL_DEVICE_HOST_ID, ThreadId, type DeviceServiceState, @@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* ( failListAfterShutdown = false, runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, inspectError = false, + installTool?: Parameters[3], ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + const service = yield* makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + installTool, + ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, @@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i expect(starts).toEqual([]); }).pipe(Effect.scoped), ); + +it.effect( + "manual updates install only the selected tool without enabling access or starting helpers", + () => + Effect.gen(function* () { + const installed: string[] = []; + const { service, starts, agentStarts, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + (tool) => + Effect.sync(() => { + installed.push(tool); + }), + ); + const before = yield* service.state; + const state = yield* service.updateTool("agent"); + expect(installed).toEqual(["agent"]); + expect(state.supportsToolUpdate).toBe(true); + expect(state.hostStatus).toBe(before.hostStatus); + expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); + expect(state.revision).toBeGreaterThan(before.revision); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + yield* service.updateTool("hub"); + expect(installed).toEqual(["agent", "hub"]); + }).pipe(Effect.scoped), +); + +it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => + Effect.gen(function* () { + let attempts = 0; + const { service, starts, agentStarts } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + () => + Effect.suspend(() => + ++attempts === 1 + ? Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause: new Error("offline"), + }), + ) + : Effect.void, + ), + ); + const before = yield* service.state; + const result = yield* service.updateTool("agent").pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(yield* service.state).toEqual(before); + yield* service.updateTool("agent"); + expect(attempts).toBe(2); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index f99522b2bd7b..2435fbca34ab 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -38,7 +38,7 @@ import { import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; -import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly updateTool: (tool: "hub" | "agent") => Effect.Effect; readonly inspect: Effect.Effect; readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; @@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent configuration is unavailable in this device service.", }), ), + installTool?: (tool: "hub" | "agent") => Effect.Effect, ) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); @@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const stateRef = yield* SynchronizedRef.make({ state: { supportsHostRetry: true, + supportsToolUpdate: installTool !== undefined, supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", @@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + updateTool: (tool) => + lifecycleLock.withPermit( + Effect.gen(function* () { + if (!installTool) + return yield* Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }), + ); + yield* installTool(tool); + return yield* inspect; + }), + ), retryHost, inspect, agentCli: Effect.fail( @@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () { ), ), configureAgent, + (tool) => + (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause, + }), + ), + ), ); const hostContext = yield* Effect.context>>(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 05b680b441f7..90271e00adfb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3456,13 +3456,15 @@ const makeWsRpcLayer = ( [WS_METHODS.deviceList]: (input) => observeRpcEffect( WS_METHODS.deviceList, - input.inspectOnly + input.inspectOnly && !input.updateTool ? deviceService.inspect : authorizeEffect( requiredScopeForDeviceList(input), - input.retryHostId - ? deviceService.retryHost(input.retryHostId) - : deviceService.list, + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, ), { "rpc.aggregate": "device", diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx index 0501c8370bdc..d9a79f4bddb5 100644 --- a/apps/web/src/components/device/DeviceToolVersions.tsx +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -57,7 +57,7 @@ export function DeviceToolVersions({ .filter(([name]) => !kind || name === label) .map(([name, tool]) => (
-

{name}

+ {!kind ?

{name}

: null}
Running
{tool.runningVersion ?? "Not running"}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 851ca7439f99..b6b4a77b7c04 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -642,7 +642,9 @@ function DeviceIntegrationControls({ ); const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); - const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); + const [pending, setPending] = useState< + "hub" | "check" | "agent" | "update-hub" | "update-agent" | null + >(null); const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; const [platformsRevealed, setPlatformsRevealed] = useState(false); // Keep diagnostics visible through subsequent agent setup and refresh phases. @@ -685,20 +687,64 @@ function DeviceIntegrationControls({ } }; - const checkVersions = state.supportsToolInspection ? ( - - ) : null; + const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>( + null, + ); + const localTools = state.hosts.find((host) => host.kind === "local")?.tools; + const versionActions = (tool: "hub" | "agent") => { + const version = localTools?.[tool]; + const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion); + return ( +
+
+ {state.supportsToolUpdate && needsUpdate ? ( + + ) : null} + {state.supportsToolInspection ? ( + + ) : null} +
+ {updateError?.tool === tool ? ( +

+ {updateError.message} +

+ ) : null} +
+ ); + }; return ( <> @@ -710,7 +756,7 @@ function DeviceIntegrationControls({ control={ <> host.kind === "local")?.tools} /> @@ -774,7 +820,7 @@ function DeviceIntegrationControls({ control={ <> host.kind === "local")?.tools} /> diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 20b49f850123..63859cfd3619 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ supportsHostRetry: Schema.optional(Schema.Boolean), + supportsToolUpdate: Schema.optional(Schema.Boolean), supportsToolInspection: Schema.optional(Schema.Boolean), hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, @@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({ export type DeviceServiceState = typeof DeviceServiceState.Type; export const DeviceListInput = Schema.Struct({ + /** Install this server's pinned tool without enabling access or starting helpers. */ + updateTool: Schema.optional(Schema.Literals(["hub", "agent"])), /** Read inventory without installing tools or starting helpers. */ inspectOnly: Schema.optional(Schema.Boolean), /** Retry this host only, including agent tools if access was already granted. */ From 371b52d9dad76876f84609a4cd0f80eaa757b69c Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 21 Sep 2026 14:39:40 -0300 Subject: [PATCH 055/164] feat(web): answer pull request actions on the row at once (#12843) Co-authored-by: Claude Fable 5.1 --- .../pullRequest/PullRequestDetailPanel.tsx | 11 +- .../pullRequest/pullRequestList.logic.test.ts | 94 +++++++++++ .../pullRequest/pullRequestList.logic.ts | 126 +++++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 151 ++++++++++++++++-- 4 files changed, 367 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f19e41599043..a02cb52173bd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -458,7 +458,12 @@ export function PullRequestDetailPanel({ * An action changed this pull request on the host, so a list showing it is now out of date. * Told rather than assumed: only the page knows whether it is showing one. */ - onActed?: () => void; + /** + * Each host action as it goes: "sent" the moment it leaves, so a list can answer before the + * host does; "done" or "failed" when the host has spoken. Undefined for one the caller cannot + * name, which is only ever "done". + */ + onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; /** @@ -961,6 +966,7 @@ export function PullRequestDetailPanel({ method?: PullRequestMergeMethod, updateMethod?: PullRequestUpdateMethod, ) => { + onActed?.(action, "sent"); const result = await runAction({ environmentId, input: { @@ -988,6 +994,7 @@ export function PullRequestDetailPanel({ title: ACTION_FAILURE_LABELS[action], description: readableFailure(failure, hint), }); + onActed?.(action, "failed"); return false; } toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); @@ -1001,7 +1008,7 @@ export function PullRequestDetailPanel({ } else { refreshDetail(); } - onActed?.(); + onActed?.(action, "done"); return true; }; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 9154e38026d2..357ea20ad76d 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -32,6 +32,10 @@ import { resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, type EnvironmentPullRequestEntry, + applyPullRequestOverrides, + pullRequestOverrideAfterAction, + reusePullRequestEntries, + settlePullRequestOverrides, } from "./pullRequestList.logic"; import { pullRequestListPreferences, @@ -1600,3 +1604,93 @@ describe("the priority groups against a paginated feed", () => { ).toEqual([6123]); }); }); + +describe("pull request list overrides", () => { + const entry = (number: number, state: "open" | "closed" | "merged") => + ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + state, + isDraft: false, + updatedAt: "2026-07-01T00:00:00Z", + labels: [], + }) as unknown as PullRequestListEntry; + const key = (row: { number: number }) => `#${row.number}`; + + it("maps the actions that change a row's state and nothing else", () => { + const now = new Date("2026-07-02T00:00:00Z"); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", now, 7)).toEqual({ + state: "closed", + updatedAt: "2026-07-02T00:00:00.000Z", + token: 7, + at: now.getTime(), + }); + expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", now, 1)?.state).toBe( + "open", + ); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", now, 1)?.state).toBe("merged"); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", now, 1)?.isDraft).toBe(true); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", now, 1)).toBeNull(); + }); + + it("writes the override over the row and drops it from a list whose state it left", () => { + const rows = [entry(1, "open"), entry(2, "open")]; + const overrides = new Map([ + ["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at: 0 }], + ]); + expect( + applyPullRequestOverrides(rows, overrides, key, "open").map((row) => row.number), + ).toEqual([2]); + const all = applyPullRequestOverrides(rows, overrides, key, "all"); + expect(all.map((row) => [row.number, row.state])).toEqual([ + [1, "closed"], + [2, "open"], + ]); + expect(applyPullRequestOverrides(rows, new Map(), key, "open")).toBe(rows); + }); + + it("hands back the held object for a row a refresh did not change", () => { + const previous = [entry(1, "open"), entry(2, "open")]; + const next = [{ ...entry(1, "open") }, { ...entry(2, "open"), state: "merged" as const }]; + const reused = reusePullRequestEntries(previous, next, key); + expect(reused[0]).toBe(previous[0]); + expect(reused[1]).toBe(next[1]); + expect( + reusePullRequestEntries(previous, [{ ...entry(1, "open") }, { ...entry(2, "open") }], key), + ).toBe(previous); + }); +}); + +describe("pull request list override settlement", () => { + const entry = (number: number, state: "open" | "closed" | "merged") => + ({ number, state, isDraft: false, labels: [] }) as unknown as PullRequestListEntry; + const key = (row: { number: number }) => `#${row.number}`; + + it("keeps an override until an answer agrees with it", () => { + const at = 1_000_000; + const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at }; + const overrides = new Map([["#1", closed]]); + // A read from before the action still says open: the override stands. + expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 5_000)).toBe( + overrides, + ); + // Absent from the answer says nothing: the row may live in another group or page. + expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, at + 5_000).size).toBe(1); + // Present as closed: confirmed. + expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, at + 5_000).size).toBe( + 0, + ); + // Present as open a good while later: the host's news, which outranks the note. + expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 90_000).size).toBe( + 0, + ); + }); + + it("does not hand back the old order when only the order changed", () => { + const previous = [entry(1, "open"), entry(2, "open")]; + const swapped = reusePullRequestEntries(previous, [entry(2, "open"), entry(1, "open")], key); + expect(swapped).not.toBe(previous); + expect(swapped.map((row) => row.number)).toEqual([2, 1]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index f001e33829c5..f4cb3a31f46f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import type { ProjectId, + PullRequestAction, PullRequestActor, PullRequestDiffStat, PullRequestInvolvement, @@ -16,6 +17,7 @@ import type { PullRequestListCursors, PullRequestListFilters, PullRequestListState, + PullRequestState, } from "@t3tools/contracts"; import { toSortableTimestamp } from "../../lib/threadSort"; @@ -1140,3 +1142,127 @@ export function withDiffStat< const stat = statsByRow.get(pullRequestDiffStatKey(entry)); return stat === undefined ? entry : { ...entry, ...stat }; } + +/** + * What a row should say the moment an action is sent, before any host has answered. The host + * is the record and a later read replaces this, but the reader pressed the button and should + * see the row answer at once: a closed pull request leaves an "open" list on the click, not + * after the reads that follow. + */ +export interface PullRequestListOverride { + readonly state: PullRequestState; + readonly isDraft?: boolean; + readonly updatedAt: string; + /** Which action wrote it, so a failure takes back its own note and not a later one's. */ + readonly token: number; + /** When it was written, in the reader's clock. */ + readonly at: number; +} + +export function pullRequestOverrideAfterAction( + entry: Pick, + action: PullRequestAction, + now: Date, + token: number, +): PullRequestListOverride | null { + const stamp = { updatedAt: now.toISOString(), token, at: now.getTime() }; + switch (action) { + case "close": + return { state: "closed", ...stamp }; + case "reopen": + return { state: "open", ...stamp }; + case "merge": + return { state: "merged", ...stamp }; + case "draft": + return { state: entry.state, isDraft: true, ...stamp }; + case "ready": + return { state: entry.state, isDraft: false, ...stamp }; + default: + return null; + } +} + +/** The rows with their pending answers written over them, and the ones the list's state filter no longer holds dropped. */ +export function applyPullRequestOverrides( + entries: ReadonlyArray, + overrides: ReadonlyMap, + keyOf: (entry: Entry) => string, + state: PullRequestListState, +): ReadonlyArray { + if (overrides.size === 0) return entries; + const out: Entry[] = []; + for (const entry of entries) { + const override = overrides.get(keyOf(entry)); + if (override === undefined) { + out.push(entry); + continue; + } + if (state !== "all" && override.state !== state) continue; + out.push({ ...entry, ...override }); + } + return out; +} + +/** + * A fresh answer with the rows it did not change handed back as the objects already held, so a + * memoized row whose data is the same does not render again. Every refresh otherwise rebuilds + * every entry, and a hundred rows repaint for the one that moved. + */ +export function reusePullRequestEntries( + previous: ReadonlyArray, + next: ReadonlyArray, + keyOf: (entry: Entry) => string, +): ReadonlyArray { + if (previous.length === 0) return next; + const held = new Map(previous.map((entry) => [keyOf(entry), entry])); + let reused = 0; + const out = next.map((entry) => { + const before = held.get(keyOf(entry)); + if (before !== undefined && JSON.stringify(before) === JSON.stringify(entry)) { + reused += 1; + return before; + } + return entry; + }); + return reused === next.length && + previous.length === next.length && + previous.every((entry, index) => keyOf(entry) === keyOf(next[index]!)) + ? previous + : out; +} + +/** How long a read that disagrees is taken for a stale one rather than for news. */ +const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000; + +/** + * The overrides an answer has confirmed, dropped; the rest kept. A read that started before + * the action can land after it and still say the old thing, so an override is not cleared + * because an answer arrived but because the answer agrees: the row is there in the state the + * override said. A row that is absent says nothing — the authored and reviewing groups are read + * apart from the feed, and a page is only a page — so absence never confirms. A row present in + * another state is taken for a stale read for a minute, and for the host's news after that, + * which is how a pull request reopened elsewhere comes back. + */ +export function settlePullRequestOverrides( + overrides: ReadonlyMap, + answered: ReadonlyArray, + keyOf: (entry: Entry) => string, + now: number, +): ReadonlyMap { + if (overrides.size === 0) return overrides; + const byKey = new Map(answered.map((entry) => [keyOf(entry), entry])); + const kept = new Map(); + for (const [key, override] of overrides) { + const row = byKey.get(key); + if (row === undefined) { + kept.set(key, override); + continue; + } + const agrees = + row.state === override.state && + (override.isDraft === undefined || row.isDraft === override.isDraft); + const outranked = now - override.at > PULL_REQUEST_OVERRIDE_TRUST_MS; + if (!agrees && !outranked) kept.set(key, override); + } + return kept.size === overrides.size ? overrides : kept; +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 485fa7718e3e..59368351359d 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -4,6 +4,7 @@ import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contr import type { EnvironmentId, ProjectId, + PullRequestAction, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, @@ -77,6 +78,11 @@ import { type PullRequestStatsPolicy, type PullRequestStatsScope, type PullRequestPartitionsSnapshot, + applyPullRequestOverrides, + type PullRequestListOverride, + pullRequestOverrideAfterAction, + reusePullRequestEntries, + settlePullRequestOverrides, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, @@ -914,6 +920,36 @@ function PullRequestsRouteView() { key: string; entries: ReadonlyArray; } | null>(null); + // What the reader just did to a row, shown before any host confirms it. A closed pull request + // leaves an "open" list on the click; the reads that follow are slow, and until one lands the + // row says what was asked of it. Cleared when a whole-page answer arrives after the action. + const [overrides, setOverrides] = useState>( + () => new Map(), + ); + const overrideToken = useRef(0); + /** Writes the action's outcome onto the row; the token names this write for a later rollback. */ + const overrideEntry = ( + entry: EnvironmentPullRequestEntry, + action: PullRequestAction, + ): number | null => { + const token = ++overrideToken.current; + const override = pullRequestOverrideAfterAction(entry, action, new Date(), token); + if (override === null) return null; + setOverrides((current) => new Map(current).set(pullRequestEntryKey(entry), override)); + return token; + }; + /** A rollback for one write only: a later action's note over the same row is left alone. */ + const revertOverride = (key: string, token: number | null) => { + if (token === null) return; + setOverrides((current) => { + if (current.get(key)?.token !== token) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + }; + /** The detail panel's own writes, by row, so its failure takes back its own note. */ + const detailOverrideTokens = useRef(new Map()); // A reload recreates the registry the queries live in, so with nothing held the page would // cold-start into skeletons even though almost every row is unchanged. The last answer for // this set of environments is kept across reloads and hydrated here as the carried rows: they @@ -1077,8 +1113,20 @@ function PullRequestsRouteView() { // since the last read at the bottom of the page — below rows a week older — where "the // latest" is exactly what a refresh was for. The host answers in the order the page // reads, so its order stands; a row that moved was updated, and moving is the news. - return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentParsed.text) }; + return { + key: filterKey, + entries: reusePullRequestEntries( + previous.entries, + rankPullRequestMatches(answered.entries, sentParsed.text), + pullRequestEntryKey, + ), + }; }); + // The host's word outranks the reader's, once it has actually said it: an override is + // cleared by an answer that agrees with it, not by any answer that happens to land. + setOverrides((current) => + settlePullRequestOverrides(current, answered.entries, pullRequestEntryKey, Date.now()), + ); }, [ answered, filterKey, @@ -1455,10 +1503,34 @@ function PullRequestsRouteView() { setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [statsQuery.stats]); const displayGroups = useMemo(() => { - const enriched = groups.map((group) => ({ - ...group, - entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), - })); + // The reader's pending answers go on here, after grouping: the authored and reviewing + // groups are read separately from the feed, and a row closed a moment ago has to leave + // whichever group it was in. + const enriched = groups.map((group) => { + const answered = applyPullRequestOverrides( + group.entries.map((entry) => withDiffStat(entry, statsByRow)), + overrides, + pullRequestEntryKey, + search.state, + ); + // A row whose draft flag just changed has to pass the local filters again: a draft + // filter that let it in may not let the new one in. + return { + ...group, + entries: + hasLocalFilters && overrides.size > 0 + ? answered.filter( + (entry) => + !overrides.has(pullRequestEntryKey(entry)) || + matchesPullRequestFilters( + entry, + localFilters, + pullRequestEntryViewer(entry, viewers), + ), + ) + : answered, + }; + }); // Searching keeps its relevance order and priority groups unless the reader explicitly asks // for another sort. The readiness queue is the default browse order, not a way to bury a // closer text match. @@ -1470,7 +1542,29 @@ function PullRequestsRouteView() { entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), search.involvement, ); - }, [groups, search.involvement, sort, statsByRow, typedParsed.text]); + }, [ + groups, + hasLocalFilters, + localFilters, + overrides, + search.involvement, + search.state, + sort, + statsByRow, + typedParsed.text, + viewers, + ]); + /** What is actually on screen once the reader's pending answers are on the rows. */ + const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0); + const heldPullRequestsBySurface = useMemo( + () => + new Map( + groups.flatMap((group) => + group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const), + ), + ), + [groups], + ); const listedPullRequestsBySurface = useMemo( () => new Map( @@ -1645,7 +1739,7 @@ function PullRequestsRouteView() { // so that case waits with the skeletons rather than answering for the hosts. A search says so // in its own words and is left to. const carriedToNothing = - showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0; + showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0; const listBody = ( <> {!capabilityKnown ? ( @@ -1657,7 +1751,7 @@ function PullRequestsRouteView() { /> ) : firstLoad ? ( - ) : listQuery.error && entries.length === 0 ? ( + ) : listQuery.error && shownCount === 0 ? ( ) : carriedToNothing ? ( - ) : entries.length === 0 ? ( + ) : shownCount === 0 ? ( 0} refreshing={refreshing} @@ -1724,7 +1818,7 @@ function PullRequestsRouteView() {
)} - {listQuery.error && entries.length > 0 ? ( + {listQuery.error && shownCount > 0 ? (
{listQuery.error} Showing the last pull requests loaded. ); From 2d377bfb1c8c85a61679fa6cdb6e8181cfb13626 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 21 Sep 2026 17:11:14 -0500 Subject: [PATCH 058/164] fix(web): dismiss selection actions when pressing buttons (#12950) --- apps/web/src/lib/selectionActions.test.ts | 35 +++++++++++++++++++++-- apps/web/src/lib/selectionActions.ts | 4 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/selectionActions.test.ts b/apps/web/src/lib/selectionActions.test.ts index 56f51863b690..d677b2858771 100644 --- a/apps/web/src/lib/selectionActions.test.ts +++ b/apps/web/src/lib/selectionActions.test.ts @@ -24,7 +24,12 @@ function createSelectionSurface({ interactiveActions = false } = {}) { }); const element = Object.assign(new EventTarget(), { ownerDocument: document, - contains: (target: unknown): boolean => target === element, + contains: (target: unknown): boolean => target === element || target === buttonIcon, + closest: () => null, + }); + const button = new EventTarget(); + const buttonIcon = Object.assign(new EventTarget(), { + closest: (selector: string) => (selector === "button, [role=button]" ? button : null), }); const onSelection = vi.fn(); const onDismiss = vi.fn(); @@ -58,7 +63,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { const press = event("pointerdown", { button, isPrimary }); Object.defineProperty(press, "target", { value: target }); document.dispatchEvent(press); - if (target === element && !consumed) element.dispatchEvent(press); + if (element.contains(target) && !consumed) element.dispatchEvent(press); }; const focus = (target: EventTarget) => { document.activeElement = target; @@ -92,6 +97,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { view, document, element, + buttonIcon, actionElement, field, onSelection, @@ -177,6 +183,31 @@ describe("selection action gestures", () => { expect(surface.onSelection).not.toHaveBeenCalled(); }); + it.each([1, 2])( + "dismisses selection actions when a button is pressed after %i clicks", + (detail) => { + const surface = createSelectionSurface(); + surface.down(); + surface.up({ detail }); + surface.flush(); + surface.onSelection.mockClear(); + surface.onDismiss.mockClear(); + + surface.down({ target: surface.buttonIcon }); + surface.up(); + surface.change(); + surface.flush(1000); + expect(surface.onDismiss).toHaveBeenCalledWith("interaction"); + expect(surface.onSelection).not.toHaveBeenCalled(); + + surface.down(); + surface.change(); + surface.up(); + surface.flush(); + expect(surface.onSelection).toHaveBeenCalledOnce(); + }, + ); + it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => { const surface = createSelectionSurface(); surface.down({ isPrimary: false }); diff --git a/apps/web/src/lib/selectionActions.ts b/apps/web/src/lib/selectionActions.ts index 91e6c2e6a10b..5954a770aa65 100644 --- a/apps/web/src/lib/selectionActions.ts +++ b/apps/web/src/lib/selectionActions.ts @@ -101,6 +101,10 @@ export function observeSelectionActions({ }; const onSelectionStart = (event: PointerEvent) => { if (!event.isPrimary) return; + const target = event.target as Element | null; + // Buttons can preserve an existing text selection while opening a menu. + // Their release must not reopen selection actions beside the button. + if (target?.closest("button, [role=button]")) return; // A surface may consume a press for a link or terminal mouse reporting. gestureActive = event.button === 0 && !event.defaultPrevented; dismissed = !gestureActive; From d2a90b921a5843654a58448d0bc59828e1e3ccc4 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 22 Sep 2026 08:33:28 +1000 Subject: [PATCH 059/164] fix(web): name message copy actions accurately (#12865) --- .../src/components/chat/MessageCopyButton.tsx | 4 +- .../components/chat/MessagesTimeline.test.tsx | 2 +- docs/internals/consistency-action-names.md | 39 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 docs/internals/consistency-action-names.md diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 4a5c3f170d20..aaaa171f10de 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -37,7 +37,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ copyToClipboard(text)} ref={ref} @@ -51,7 +51,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ {isCopied ? : } -

Copy to clipboard

+

Copy message

); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dae27ccdba47..016e621be1dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1320,7 +1320,7 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain('aria-label="Copy link"'); + expect(markup).toContain('aria-label="Copy message"'); expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-footer="true"'); }); diff --git a/docs/internals/consistency-action-names.md b/docs/internals/consistency-action-names.md new file mode 100644 index 000000000000..7277c1bbf74f --- /dev/null +++ b/docs/internals/consistency-action-names.md @@ -0,0 +1,39 @@ +# Action names agree across presentations + +Proposed invariant: a command's visible label, tooltip, menu wording and accessible +name describe the same action on the same object. An icon-only presentation must +retain that meaning. This applies to shared web/Electron controls and corresponding +React Native iOS/Android actions, wherever those actions are available. + +Separate strings can drift while each presentation still looks plausible. A message +copy action called “Copy link” promises a different clipboard payload to someone +using a screen reader. Use wording that identifies the actual target, including +when a message contains links or structured context. Additional clipboard formats +do not turn copying a message into copying a link. + +This proposal draws on Apple's [Writing](https://developer.apple.com/design/human-interface-guidelines/writing) +and [Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) +guidance. It is a product constraint, not a claim of Apple certification or complete +application compliance. + +## Boundaries + +Native menus and assistive technologies may use different wording or omit a tooltip. +Labels need semantic agreement, not identical strings across platforms. A contextual +“Copy” is sufficient when its target is unambiguous; do not globally expand it. +Temporary “Copied” feedback may replace an action label without implying a different +target. Clipboard transport, success timing and error reporting are separate concerns. + +## Observable cases + +- User and assistant message copy controls identify the message, in both tooltip + and accessible name. Collapsing a long message does not change the action's meaning. +- Copying a message with structured context still identifies the message; it does + not promise to copy only a context link. +- Code and plan copy actions retain their own targets. A contextual plan menu can + say “Copy to clipboard”; a code control must not be announced as copying a message. +- An icon, labeled button or native menu for an equivalent command conveys the same + operation. Completed feedback never names a different object. + +Verify visible wording alongside the runtime accessible name and resulting action. +Source inspection and screenshots alone do not establish accessibility-tree behavior. From 80d9c181da41ee3890791c9b65018ce093e7bc9e Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:33:44 +0200 Subject: [PATCH 060/164] fix(contracts): old message-sent events without turnId no longer stop the server from starting (#12763) --- packages/contracts/src/orchestration.test.ts | 31 ++++++++++++++++++++ packages/contracts/src/orchestration.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d89e1fb2957c..7c7bade4502e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -812,6 +812,37 @@ it.effect("decodes thread pull request links with snapshot and stack", () => }), ); +// A stored event that fails to decode stops the event store read, and with it +// server startup, so rows written before `turnId` existed must still load. +it.effect("decodes a legacy message-sent event persisted without turnId", () => + Effect.gen(function* () { + const event = yield* decodeOrchestrationEvent({ + sequence: 539, + eventId: "event-message-legacy-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.message-sent", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-message-legacy-1", + causationEventId: null, + correlationId: "cmd-message-legacy-1", + metadata: {}, + payload: { + threadId: "thread-1", + messageId: "message-1", + role: "user", + text: "written before turn ids were recorded", + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + assert.strictEqual(event.type, "thread.message-sent"); + if (event.type !== "thread.message-sent") return; + assert.strictEqual(event.payload.turnId, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9a9ed05d4e6d..3e323e4964d5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1893,7 +1893,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), context: Schema.optional(OrchestrationMessageContext), - turnId: Schema.NullOr(TurnId), + // Events persisted before the field existed carry no key at all. + turnId: Schema.NullOr(TurnId).pipe(Schema.withDecodingDefault(Effect.succeed(null))), streaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, From e1cbb705273a261d26b489f35a7ddca6567d9663 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:34:12 +0200 Subject: [PATCH 061/164] fix(web): the custom snooze calendar starts the week where the locale does (#12745) --- .../web/src/components/CustomSnoozeDialog.tsx | 2 ++ apps/web/src/timestampFormat.test.ts | 26 +++++++++++++++++ apps/web/src/timestampFormat.ts | 29 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index d80ebf0d36fb..453efb1cbe53 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; import { Calendar } from "./ui/calendar"; +import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -149,6 +150,7 @@ function CustomSnoozeDialog() { required selected={date} defaultMonth={date} + {...(weekStartsOn === undefined ? {} : { weekStartsOn })} disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }} onSelect={(selected) => { setDate(selected); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 8c6287010d8f..d748853000c5 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -52,6 +52,32 @@ describe("formatShortTimestamp", () => { }); }); +describe("resolveWeekStartsOn", () => { + it.each([ + ["en-US", 0], + ["en-GB", 1], + ["pl-PL", 1], + ["ar-EG", 6], + ])("starts the %s week on weekday %i", async (locale, weekday) => { + const { resolveWeekStartsOn } = await import("./timestampFormat"); + expect(resolveWeekStartsOn(locale)).toBe(weekday); + }); + + it("leaves the default to the caller for a malformed locale", async () => { + const { resolveWeekStartsOn } = await import("./timestampFormat"); + expect(resolveWeekStartsOn("not a locale")).toBeUndefined(); + }); + + it("follows the locale the desktop host reports", async () => { + vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } }); + vi.resetModules(); + const { weekStartsOn } = await import("./timestampFormat"); + expect(weekStartsOn).toBe(1); + vi.unstubAllGlobals(); + vi.resetModules(); + }); +}); + describe("formatChatTimestampTooltip", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 9dd463bb50fa..5106a00bdf2f 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -53,6 +53,35 @@ function readHostSystemLocale(): string | null { const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); +const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; +type WeekdayIndex = (typeof WEEKDAY_INDEXES)[number]; + +type LocaleWithWeekInfo = Intl.Locale & { + readonly weekInfo?: { readonly firstDay: number }; + getWeekInfo?: () => { readonly firstDay: number }; +}; + +/** + * First weekday of a locale as a `Date#getDay` index (0 is Sunday), or + * `undefined` when the runtime has no week data, so callers keep their own + * default. Without a locale it reads the runtime's. + */ +export function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefined { + try { + const resolved: LocaleWithWeekInfo = new Intl.Locale( + locale ?? Intl.DateTimeFormat().resolvedOptions().locale, + ); + // Week info counts Monday as 1 and Sunday as 7. + const firstDay = resolved.getWeekInfo?.().firstDay ?? resolved.weekInfo?.firstDay; + return firstDay === undefined ? undefined : WEEKDAY_INDEXES[firstDay % 7]; + } catch { + return undefined; + } +} + +/** Week start for calendars, from the same locale timestamps are shown in. */ +export const weekStartsOn = resolveWeekStartsOn(timestampLocale); + const timestampFormatterCache = new Map(); function getTimestampFormatter( From 76cc9b08f19d89012f16d18f47478c4f7b9b0a6f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:22:36 +0000 Subject: [PATCH 062/164] chore(mobile): bump app version to 1.3.0 Co-authored-by: codex --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 11fb377145e3..aef812ce354b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.2.1", + version: "1.3.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay From 1262d2f3ab2940c1d47ca4f5c4ca42e5c7259765 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:15:07 -0700 Subject: [PATCH 063/164] feat(server): let t3.json limit or disable submodule init in new worktrees (#12953) Co-authored-by: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriver.ts | 2 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 78 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 28 ++++++- apps/server/src/ws.ts | 7 ++ docs/user/project-settings.md | 4 + packages/contracts/src/environment.ts | 7 ++ packages/contracts/src/t3ProjectFile.test.ts | 6 ++ packages/contracts/src/t3ProjectFile.ts | 8 +- packages/shared/src/t3ProjectFile.test.ts | 1 + 9 files changed, 137 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..040862eaf6a8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -128,6 +128,8 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but t3.json sets `worktreeSubmodules` to `"none"`. */ + readonly onSubmodulesDisabled?: () => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..045f6d15cfe6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2353,6 +2353,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("honors the t3.json worktreeSubmodules setting", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: "recursive" | "top-level" | "none", + branch: string, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + yield* git(cwd, ["commit", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { progress: { onSubmodulesDisabled: () => Ref.set(disabled, true) } }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: true, + inner: false, + nested: false, + }); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..18587c9f36ef 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -3092,11 +3094,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level through + // t3.json, read from the checkout that was just created. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleMode = hasSubmodules + ? yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as("recursive" as const)) + : Effect.succeed(file.worktreeSubmodules ?? "recursive"); + }), + Effect.orElseSucceed(() => "recursive" as const), + ) + : "none"; + if (hasSubmodules && submoduleMode === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled(); + } + if (submoduleMode !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3104,7 +3124,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 90271e00adfb..909216d89349 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1500,6 +1500,13 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: () => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + "disabled in t3.json", + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 0d5d7e66b1fb..6144bd960100 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -52,6 +52,10 @@ on each selected environment, and reset returns to the environment's shared list For workspace mode, a project's `t3.json` preference applies when the project has no override. Browser access changes apply when an agent session next starts. +New worktrees initialize git submodules recursively. If that step is slow because the repository +declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop +at the ones the repository declares itself, or `"none"` to leave them for a setup script. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 70888f2988e6..b5a3290f6314 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -58,6 +58,13 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ */ export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +/** + * How a new worktree populates git submodules: every nested level, only the + * ones this repository declares, or not at all. + */ +export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]); +export type WorktreeSubmodules = typeof WorktreeSubmodules.Type; export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; /** How a server can replace itself with another version when asked over RPC. diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index 4352449c7ade..64128a4990b0 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -60,4 +60,10 @@ describe("T3ProjectFile", () => { expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); }); + + it("decodes worktreeSubmodules and rejects unknown modes", () => { + expect(decode({ worktreeSubmodules: "none" }).worktreeSubmodules).toBe("none"); + expect(decode({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe("top-level"); + expect(() => decode({ worktreeSubmodules: "shallow" })).toThrow(); + }); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 6bd4168054d5..2e31e5271f7d 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ThreadEnvMode } from "./environment.ts"; +import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ @@ -86,6 +86,12 @@ export const T3ProjectFile = Schema.Struct({ 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.', }), ), + worktreeSubmodules: Schema.optionalKey( + WorktreeSubmodules.annotate({ + description: + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle.', + }), + ), scripts: Schema.optionalKey( Schema.Array(T3ProjectFileScript) .annotate({ diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index 924a01c4414b..42045cc4284f 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -36,6 +36,7 @@ describe("buildT3ProjectFileJsonSchema", () => { "defaultThreadEnvMode", "iconPath", "scripts", + "worktreeSubmodules", ]); expect(schema.required).toBeUndefined(); expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); From 742173a13250bcba880b700aaead34e1b0810dff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:15:07 -0700 Subject: [PATCH 064/164] feat(settings): resolve t3.json inside the project settings resolver (#12954) Co-authored-by: Claude Fable 5 --- .../SettingsServerControlsRouteScreen.tsx | 25 ++++-- .../settings/settings-scoped-server.test.ts | 33 ++++++++ .../settings/settings-scoped-server.ts | 15 +++- .../threads/new-task-flow-provider.tsx | 49 +++++------- .../files/projectFilesQueryState.ts | 7 +- .../settings/ProjectDefaultsSettings.tsx | 45 ++++------- .../settings/SettingInheritance.test.ts | 37 +++++++++ .../settings/SettingInheritance.tsx | 47 +++++++++--- .../settings/SettingsScopeContext.tsx | 62 ++++++++++++++- .../settings/scopedSettings.test.ts | 47 ++++++++++++ .../src/components/settings/scopedSettings.ts | 28 ++++++- .../components/settings/settingsLayout.tsx | 12 +-- apps/web/src/hooks/useHandleNewThread.test.ts | 25 +++--- apps/web/src/hooks/useHandleNewThread.ts | 29 +++---- apps/web/src/lib/t3ProjectFileDefaults.ts | 18 ++--- docs/user/project-settings.md | 4 +- packages/contracts/src/baseSchemas.ts | 25 ++++++ packages/contracts/src/settings.test.ts | 21 +++++ packages/contracts/src/settings.ts | 34 ++++++++- packages/contracts/src/t3ProjectFile.ts | 35 +++++++++ packages/shared/package.json | 4 - packages/shared/src/projectSettings.test.ts | 76 +++++++++++++++++++ packages/shared/src/projectSettings.ts | 72 +++++++++++++++++- packages/shared/src/threadEnvMode.test.ts | 65 ---------------- packages/shared/src/threadEnvMode.ts | 36 --------- 25 files changed, 615 insertions(+), 236 deletions(-) delete mode 100644 packages/shared/src/threadEnvMode.test.ts delete mode 100644 packages/shared/src/threadEnvMode.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 090467a8cd90..94312207714a 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -50,10 +50,16 @@ const PAGE_PROJECT_KEYS: Record = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const value = reference.settings[key]; return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; }; + // `uniform` folds a real null into "mixed"; nullable keys need the distinction. + const isMixed = (key: keyof ServerSettings) => + reference === null || + displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index dd589cd2038c..185fb07e602f 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => { expect(secondSettings.responseStreamingMode).toBe("token"); }); + it("removes a project override when a picker sends null for a key that cannot store it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }, + }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c..01020a3eb221 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -1,4 +1,5 @@ import { + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type EnvironmentId, type ProjectId, @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( continue; const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; - const next = { ...current, ...patch }; + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + // A picker's "Inherit" sends null; for keys whose override cannot + // store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + } else { + next[key] = value; + } + } const overrides = writes.get(target.environment.environmentId) ?? {}; overrides[target.projectId] = next; writes.set(target.environment.environmentId, overrides); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 4a912f171a55..f2801255b3bc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -20,10 +20,6 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; -import { - isDefaultThreadEnvModeSettled, - resolveDefaultThreadEnvMode, -} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; - const t3ProjectFileDefaultMode = useMemo(() => { - if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; - return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; - }, [t3ProjectFileData]); - // Environment settings with the project's overrides applied; the - // aggregate's own legacy fields still count until the server folds them. + const t3ProjectFile = useMemo( + () => + t3ProjectFileData === null || t3ProjectFileData.truncated + ? null + : parseT3ProjectFile(t3ProjectFileData.contents), + [t3ProjectFileData], + ); + // Environment settings with the project's overrides and its t3.json + // applied; the aggregate's own legacy fields still count until the server + // folds them. const projectSettings = useMemo( () => resolveProjectSettings( selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, selectedProject?.id ?? null, selectedProject, + t3ProjectFile, ), - [selectedEnvironmentServerConfig?.settings, selectedProject], + [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: projectThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); + const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // While the file read is pending and nothing above it decided, the + // resolved default is provisional. Nothing may write it into the draft + // during that window (the auto-branch effect does), or the frozen interim + // value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + projectSettings.sources.defaultThreadEnvMode !== "environment" || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index b9a880301831..a6a556231e18 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = ""; const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( AsyncResult.initial(false), ).pipe(Atom.withLabel("project-file-query:empty")); -function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { +/** A pending in-app write to the file, overlaying the query until confirmed. */ +export function optimisticFileAtom( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5..d49eaa5cbf85 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -1,13 +1,12 @@ import { DEFAULT_SERVER_SETTINGS, - EnvironmentId, type ModelSelection, type ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; -import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); - const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; - - // A checkout's t3.json wins over the environment default when the project - // has no override of its own; show which one "inherit" resolves to. - const checkout = scope.kind === "checkout" ? scope.checkout : null; - // The query is disabled without a checkout, so any id satisfies the hook. - const t3File = useT3ProjectFileState( - checkout?.environmentId ?? EnvironmentId.make("none"), - category === "general" && checkout ? checkout.workspaceRoot : null, - ); - const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null; - const inheritedEnvModeLabel = - workspaceSource === "project" - ? null - : repositoryEnvMode - ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)` - : null; + // File-backed keys show their effective value; the target already carries + // the checkout's t3.json, and a null file here only fills the built-in. + // The reset arrow beside the title clears the tier (SettingsRow handles a + // project override, the environment value is cleared here), so the picker + // has no "inherit" item. + const effective = target + ? resolveProjectSettings(target.settings, null, null, null).settings + : null; function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); @@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting title="Workspace" description={ isProjectScope - ? "Where new threads in this project start. A t3.json preference applies when the project has no override." - : "Where new threads start, unless overridden by the project or t3.json." - } - status={ - inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined + ? "Where new threads in this project start." + : "Where new threads start. Projects and their t3.json can override it." } resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( + !isProjectScope && settings.defaultThreadEnvMode !== null ? ( - updateSettings({ - defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, - }) - } + onClick={() => updateSettings({ defaultThreadEnvMode: null })} /> ) : null } control={ } /> + updateSettings({ worktreeSubmodules: null })} + /> + ) : null + } + control={ + + } + /> ) : category === "source-control" ? ( <> diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 623e83056155..a351fa5fa25b 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -4,6 +4,7 @@ import { type ProjectFileBackedSettingKey, resolveEnvironmentMachineKind, type ServerSettings, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { CheckIcon, LayersIcon } from "lucide-react"; import * as Equal from "effect/Equal"; @@ -11,7 +12,7 @@ import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import type { EnvironmentPresentation } from "../../state/environments"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { Button, InlineButton } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -44,7 +45,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { ? "Automatic" : key === "sourceControlWriterModelSelection" ? "Text generation model" - : key === "defaultThreadEnvMode" + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" ? "Inherit" : "Not set"; } @@ -58,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) { return resolveEnvModeLabel(value); } + if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) { + return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules]; + } if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { return PULL_REQUEST_MERGE_METHOD_LABELS[ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 88f0326ea9e0..acc9b7eb7dbe 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -401,6 +401,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["default workspace mode draft local worktree"], }, + { + id: "worktree-submodules", + title: "Submodules", + to: "/settings/general", + scope: "project-defaults", + searchTerms: ["git submodule init recursive top-level none worktree t3.json"], + }, { id: "start-from-origin", title: "Start from origin", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 363b2a4583a4..b5ad7c6514d1 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -55,8 +55,11 @@ built-in default. Leave a setting on **Inherit** to let the next tier decide. Browser access changes apply when an agent session next starts. New worktrees initialize git submodules recursively. If that step is slow because the repository -declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop -at the ones the repository declares itself, or `"none"` to leave them for a setup script. +declares many nested submodules, set **Submodules** in **Settings → General** (with the project +selected to override it there) to **Top level only** to stop at the ones the repository declares +itself, or **Skip** to leave them for a setup script. It resolves in the same order as the +workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out +applies when the project and environment are both on **Inherit**. ## Storage cleanup diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 007d04505c86..692edfb18a65 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -835,6 +835,15 @@ describe("ServerSettings worktree defaults", () => { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults worktree submodules to inherit and tolerates unknown modes", () => { + expect(decodeServerSettings({}).worktreeSubmodules).toBeNull(); + expect(decodeServerSettings({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe( + "top-level", + ); + expect(decodeServerSettings({ worktreeSubmodules: "shallow" }).worktreeSubmodules).toBeNull(); + expect(decodeServerSettingsPatch({ worktreeSubmodules: null }).worktreeSubmodules).toBeNull(); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4a57f6680fb1..504a9423f2a3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,13 +5,14 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ForwardCompatibleNullable, + ForwardCompatibleOptional, OmittedWhenNull, ProjectId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; -import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; +import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1001,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultRuntimeMode", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", + "worktreeSubmodules", "defaultAutoPull", "defaultProjectScripts", "enableAgentBrowserAccess", @@ -1027,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), @@ -1208,6 +1211,14 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** + * Null defers to the repository's t3.json, then to recursive. A value + * picked on a newer server decodes as null here rather than failing the + * whole settings snapshot for an older client. + */ + worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -1499,6 +1510,7 @@ export const ServerSettingsPatch = Schema.Struct({ environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 53294a9759b3..536ac77880aa 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -90,7 +90,7 @@ export const T3ProjectFile = Schema.Struct({ worktreeSubmodules: Schema.optionalKey( WorktreeSubmodules.annotate({ description: - 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle.', + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle. A project or environment setting in T3 Code overrides this.', }), ), scripts: Schema.optionalKey( @@ -118,6 +118,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type; */ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, + worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, } as const satisfies { readonly [K in ProjectScopedServerSettingKey]?: { readonly field: { diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index c4c02660a311..f59156530aa5 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts"; import { clearProjectSettingsOverrides, hasProjectSettingsOverrides, + resolveProjectFileBackedSetting, resolveProjectSettings, resolveWorktreeCleanup, withProjectSettingsOverrides, @@ -187,6 +188,23 @@ describe("resolveProjectSettings with a t3.json", () => { ).toBe("environment"); }); + it("resolves one key from the settings tier, then the file, then the built-in", () => { + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", "none", { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "none", source: "environment" }); + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", null, { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "top-level", source: "t3.json" }); + expect(resolveProjectFileBackedSetting("worktreeSubmodules", null, null)).toEqual({ + value: "recursive", + source: "environment", + }); + }); + it("leaves settings untouched when no file is passed", () => { expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe( DEFAULT_SERVER_SETTINGS, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 967f3bc6b0c5..f99dacbbb69d 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -2,6 +2,7 @@ import { type ModelSelection, PROJECT_FILE_BACKED_SETTINGS, PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectFileBackedSettingKey, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, @@ -104,22 +105,42 @@ function applyProjectFile( ): ResolvedProjectSettings { let effective: Record | null = null; let sources: Record | null = null; - for (const [key, { field, builtIn }] of Object.entries(PROJECT_FILE_BACKED_SETTINGS)) { - const settingKey = key as ProjectScopedServerSettingKey; - if (resolved.settings[settingKey] !== null) continue; - const fromFile = projectFile?.[field]; + for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) { + if (resolved.settings[key] !== null) continue; + const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile); effective ??= { ...resolved.settings }; sources ??= { ...resolved.sources }; - effective[settingKey] = fromFile ?? builtIn; + effective[key] = value; // A project override of null defers like an unset one, so the value did // not come from the project either way. - sources[settingKey] = fromFile === undefined ? "environment" : "t3.json"; + sources[key] = source; } return effective === null || sources === null ? resolved : { ...resolved, settings: effective as ServerSettings, sources }; } +/** + * The file and built-in tiers for one key, given the project-over-environment + * value (`null` when neither is set). For callers that hold the settings tier + * but only see the file later, such as the git driver reading the t3.json of + * the checkout it just created. Same chain as `resolveProjectSettings`. + */ +export function resolveProjectFileBackedSetting( + key: K, + setting: ServerSettings[K], + projectFile: T3ProjectFile | null, +): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } { + if (setting !== null) { + return { value: setting as ResolvedServerSettings[K], source: "environment" }; + } + const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key]; + const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined; + return fromFile === undefined + ? { value: builtIn as ResolvedServerSettings[K], source: "environment" } + : { value: fromFile, source: "t3.json" }; +} + function resolveProjectOverrides( settings: ServerSettings, projectId: ProjectId | null, From 9a609a4e444ba739d6fcd607769d68b679b0bcc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:52:08 -0700 Subject: [PATCH 066/164] fix(web): show thread undo notice in the sidebar (#12972) --- apps/web/src/components/ChatView.tsx | 12 -- apps/web/src/components/Sidebar.tsx | 40 +---- .../src/components/sidebar/SidebarChrome.tsx | 2 + .../sidebar/SidebarThreadUndoNotice.tsx | 30 ++++ apps/web/src/components/ui/alert.tsx | 2 + .../src/hooks/showThreadUndoNotice.test.ts | 152 ++++++++++++++++++ apps/web/src/hooks/showThreadUndoNotice.ts | 106 ++++++++++++ apps/web/src/hooks/showUndoToast.test.ts | 127 --------------- apps/web/src/hooks/showUndoToast.ts | 87 ---------- apps/web/src/hooks/threadUndo.ts | 19 ++- apps/web/src/hooks/useThreadActions.ts | 47 ++---- .../src/hooks/useThreadActions.undo.test.ts | 71 ++++---- apps/web/src/routes/_chat.tsx | 14 ++ docs/user/keybindings.md | 9 +- 14 files changed, 378 insertions(+), 340 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx create mode 100644 apps/web/src/hooks/showThreadUndoNotice.test.ts create mode 100644 apps/web/src/hooks/showThreadUndoNotice.ts delete mode 100644 apps/web/src/hooks/showUndoToast.test.ts delete mode 100644 apps/web/src/hooks/showUndoToast.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c9540e4a0a84..981f6f2c04af 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -230,7 +230,6 @@ import { import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { isEditableFocused } from "../lib/editableFocus"; -import { undoLatestThreadAction } from "../hooks/showUndoToast"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -6731,17 +6730,6 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "thread.undo") { - // Only claim the chord when there is an Undo to run; otherwise the - // page keeps its native behavior for the key. - if (event.repeat) return; - if (undoLatestThreadAction()) { - event.preventDefault(); - event.stopPropagation(); - } - return; - } - if (command === "thread.pin") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 23c55fe8ecce..6341b34b3768 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3077,9 +3077,7 @@ export default function Sidebar() { settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef, { - undoToast: opts.coSettlingKeys === undefined, - }); + const result = await settleThread(threadRef); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { @@ -3731,9 +3729,7 @@ export default function Sidebar() { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil, { - undoToast: opts.coSnoozingKeys === undefined, - }); + const result = await snoozeThread(threadRef, preset.snoozedUntil); if (result._tag === "Failure") { // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) @@ -3887,35 +3883,15 @@ export default function Sidebar() { outcome.status === "failure" ? [outcome.error] : [], ); - if (snoozedThreadRefs.length > 0) { - const snoozedCount = snoozedThreadRefs.length; - const failedCount = failures.length; - toastManager.add( - stackedThreadToast({ - type: failedCount > 0 ? "warning" : "success", - title: - failedCount > 0 - ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` - : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, - description: - failedCount > 0 - ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` - : undefined, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => { - for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); - }, - }, - }), - ); - } else if (failures.length > 0) { + if (failures.length > 0) { const firstError = failures[0]; toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to snooze threads", + title: + snoozedThreadRefs.length > 0 + ? `Failed to snooze ${failures.length} thread${failures.length === 1 ? "" : "s"}` + : "Failed to snooze threads", description: firstError instanceof Error ? firstError.message : "An error occurred.", }), @@ -4019,7 +3995,6 @@ export default function Sidebar() { }, [ attemptSettle, - attemptSnooze, attemptUnpin, clearSelection, confirmThreadDelete, @@ -4028,7 +4003,6 @@ export default function Sidebar() { performSnooze, removeFromSelection, serverConfigs, - attemptUnsnooze, updateThreadMetadata, timestampFormat, ], diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 22ebf1831e04..6433a1cba81a 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -26,6 +26,7 @@ import { } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; +import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -221,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( + diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx new file mode 100644 index 000000000000..74f767f05d9a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; + +import { undoLatestThreadAction, useThreadUndoNotice } from "../../hooks/showThreadUndoNotice"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { primaryServerKeybindingsAtom } from "../../state/server"; +import { Alert, AlertDescription } from "../ui/alert"; +import { InlineButton } from "../ui/button"; + +export function SidebarThreadUndoNotice() { + const notice = useThreadUndoNotice((state) => state.notice); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + + if (!notice) return null; + const shortcut = shortcutLabelForCommand(keybindings, "thread.undo"); + + return ( + + + {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "} + + {shortcut ? `${shortcut} to undo` : "Undo"} + + + + ); +} diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index d071fd2dad53..e2a08066e6c0 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -11,6 +11,8 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore variants: { variant: { default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground", + sidebar: + "rounded-lg border-sidebar-border bg-sidebar-control-surface px-2 py-1.5 text-[11px] leading-4 [&_[data-slot=alert-description]]:block [&_[data-slot=alert-description]]:text-sidebar-muted-foreground", error: "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error", info: "border-info/32 bg-info/4 [&_svg]:text-info", diff --git a/apps/web/src/hooks/showThreadUndoNotice.test.ts b/apps/web/src/hooks/showThreadUndoNotice.test.ts new file mode 100644 index 000000000000..aceb70c21d9c --- /dev/null +++ b/apps/web/src/hooks/showThreadUndoNotice.test.ts @@ -0,0 +1,152 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import * as Cause from "effect/Cause"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../components/ui/toast"; +import { + showThreadUndoNotice, + undoLatestThreadAction, + useThreadUndoNotice, +} from "./showThreadUndoNotice"; +import * as ThreadUndo from "./threadUndo"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function setup() { + const add = vi.spyOn(toastManager, "add").mockReturnValue("error-toast"); + const undo = vi.fn(async () => AsyncResult.success(undefined)); + const claim = ThreadUndo.begin("pin", "env/thread"); + const options = { action: "Unpinned" as const, failureTitle: "Restore failed", undo, claim }; + return { add, undo, claim, options }; +} + +function notice() { + const value = useThreadUndoNotice.getState().notice; + if (!value) throw new Error("Undo notice is missing"); + return value; +} + +describe("thread undo notice", () => { + it("aggregates consecutive actions without success toasts and restores the group once", async () => { + const { add, undo, options } = setup(); + showThreadUndoNotice({ + ...options, + action: "Settled", + claim: ThreadUndo.begin("settle", "env/a"), + }); + showThreadUndoNotice({ + ...options, + action: "Settled", + claim: ThreadUndo.begin("settle", "env/b"), + }); + expect(notice()).toMatchObject({ action: "Settled", count: 2 }); + expect(add).not.toHaveBeenCalled(); + const group = notice(); + await group.undo(); + await group.undo(); + expect(undo).toHaveBeenCalledTimes(2); + expect(undoLatestThreadAction()).toBe(false); + }); + + it("drops invalidated claims immediately and rejects a captured stale undo", async () => { + const { undo, options } = setup(); + showThreadUndoNotice(options); + const stale = notice(); + ThreadUndo.invalidate("pin", "env/thread"); + expect(useThreadUndoNotice.getState().notice).toBeNull(); + showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/thread") }); + await stale.undo(); + expect(undo).not.toHaveBeenCalled(); + await notice().undo(); + expect(undo).toHaveBeenCalledOnce(); + }); + + it("does not show a notice for a late completion after a newer action", () => { + const { add, options } = setup(); + ThreadUndo.invalidate("pin", "env/thread"); + showThreadUndoNotice(options); + expect(useThreadUndoNotice.getState().notice).toBeNull(); + expect(add).not.toHaveBeenCalled(); + }); + + it("keeps the group available until five seconds after the latest action", async () => { + const { undo, claim, options } = setup(); + showThreadUndoNotice(options); + vi.advanceTimersByTime(4_000); + const second = ThreadUndo.begin("pin", "env/second"); + showThreadUndoNotice({ ...options, claim: second }); + const group = notice(); + vi.advanceTimersByTime(4_999); + expect(notice().count).toBe(2); + vi.advanceTimersByTime(1); + expect(useThreadUndoNotice.getState().notice).toBeNull(); + expect(claim.isCurrent()).toBe(false); + expect(second.isCurrent()).toBe(false); + await group.undo(); + expect(undo).not.toHaveBeenCalled(); + }); + + it("reports a failed restore and releases its claim", async () => { + const { add, claim, options } = setup(); + showThreadUndoNotice({ + ...options, + undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), + }); + await notice().undo(); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }), + ); + expect(claim.isCurrent()).toBe(false); + }); + + it("reports a rejected restore promise", async () => { + const { add, options } = setup(); + showThreadUndoNotice({ + ...options, + undo: async () => { + throw new Error("disconnected"); + }, + }); + await notice().undo(); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", description: "disconnected" }), + ); + }); + + it("does not report interrupted restores as errors", async () => { + const { add, options } = setup(); + showThreadUndoNotice({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) }); + await notice().undo(); + expect(add).not.toHaveBeenCalled(); + }); + + it("undoes the latest kind first, then reveals the preceding group", async () => { + const { options } = setup(); + const older = vi.fn(async () => AsyncResult.success(undefined)); + const newer = vi.fn(async () => AsyncResult.success(undefined)); + showThreadUndoNotice({ + ...options, + action: "Settled", + undo: older, + claim: ThreadUndo.begin("settle", "env/a"), + }); + showThreadUndoNotice({ + ...options, + action: "Snoozed", + undo: newer, + claim: ThreadUndo.begin("snooze", "env/b"), + }); + expect(undoLatestThreadAction()).toBe(true); + expect(newer).toHaveBeenCalledOnce(); + expect(older).not.toHaveBeenCalled(); + expect(notice().action).toBe("Settled"); + await notice().undo(); + expect(older).toHaveBeenCalledOnce(); + expect(undoLatestThreadAction()).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/showThreadUndoNotice.ts b/apps/web/src/hooks/showThreadUndoNotice.ts new file mode 100644 index 000000000000..ea296219487a --- /dev/null +++ b/apps/web/src/hooks/showThreadUndoNotice.ts @@ -0,0 +1,106 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { create } from "zustand"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import * as ThreadUndo from "./threadUndo"; + +type UndoOptions = { + action: "Settled" | "Snoozed" | "Unpinned" | "Archived"; + undo: () => Promise>; + failureTitle: string; + claim: ReturnType; +}; + +type UndoNotice = { + action: UndoOptions["action"]; + count: number; + undo: () => Promise; +}; + +export const useThreadUndoNotice = create<{ notice: UndoNotice | null }>(() => ({ notice: null })); + +// Shared across sidebar, header and menu actions. Consecutive actions of the +// same kind share one notice and can be restored together. +let liveUndos: UndoOptions[] = []; +let expiry: ReturnType | undefined; + +function refreshNotice() { + liveUndos = liveUndos.filter(({ claim }) => claim.isCurrent()); + const latest = liveUndos.at(-1); + if (!latest) { + clearTimeout(expiry); + useThreadUndoNotice.setState({ notice: null }); + return; + } + const group: UndoOptions[] = []; + for (let index = liveUndos.length - 1; index >= 0; index--) { + const entry = liveUndos[index]!; + if (entry.action !== latest.action) break; + group.push(entry); + } + useThreadUndoNotice.setState({ + notice: { + action: latest.action, + count: group.length, + undo: async () => { + const current = group.filter( + (entry) => liveUndos.includes(entry) && entry.claim.isCurrent(), + ); + liveUndos = liveUndos.filter((entry) => !group.includes(entry)); + // Consume every claim before awaiting, so repeated clicks or shortcuts + // cannot restore the same group twice. + for (const entry of current) entry.claim.finish(); + refreshNotice(); + await Promise.all( + current.map(async ({ undo, failureTitle }) => { + const reportFailure = (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }; + try { + const result = await undo(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure(squashAtomCommandFailure(result)); + } + } catch (error) { + reportFailure(error); + } + }), + ); + }, + }, + }); +} + +ThreadUndo.subscribe(refreshNotice); + +/** Runs the group displayed in the sidebar; false when nothing is left to undo. */ +export function undoLatestThreadAction(): boolean { + const notice = useThreadUndoNotice.getState().notice; + if (!notice) return false; + void notice.undo(); + return true; +} + +/** Shows one compact confirmation for the currently undoable thread actions. */ +export function showThreadUndoNotice(options: UndoOptions) { + if (!options.claim.isCurrent()) return; + liveUndos.push(options); + refreshNotice(); + clearTimeout(expiry); + expiry = setTimeout(() => { + const expired = liveUndos; + liveUndos = []; + for (const { claim } of expired) claim.finish(); + refreshNotice(); + }, 5_000); +} diff --git a/apps/web/src/hooks/showUndoToast.test.ts b/apps/web/src/hooks/showUndoToast.test.ts deleted file mode 100644 index ced0b2bc5504..000000000000 --- a/apps/web/src/hooks/showUndoToast.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AsyncResult } from "effect/unstable/reactivity"; -import * as Cause from "effect/Cause"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { toastManager } from "../components/ui/toast"; -import { showUndoToast, undoLatestThreadAction } from "./showUndoToast"; -import * as ThreadUndo from "./threadUndo"; - -afterEach(() => vi.restoreAllMocks()); - -function setup() { - const add = vi.spyOn(toastManager, "add").mockReturnValue("undo-toast"); - const close = vi.spyOn(toastManager, "close").mockImplementation(() => {}); - const undo = vi.fn(async () => AsyncResult.success(undefined)); - const claim = ThreadUndo.begin("pin", "env/thread"); - const options = { - title: "Thread unpinned", - description: "Thread", - failureTitle: "Restore failed", - undo, - claim, - }; - return { add, close, undo, claim, options }; -} - -function click(add: ReturnType["add"], index = 0) { - const handler = add.mock.calls[index]?.[0].actionProps?.onClick; - if (!handler) throw new Error("Undo action is missing"); - return handler({} as Parameters[0]); -} - -describe("showUndoToast", () => { - it("ignores a stale toast and lets the latest action run only once", async () => { - const { add, close, undo, options } = setup(); - showUndoToast(options); - ThreadUndo.invalidate("pin", "env/thread"); - showUndoToast({ ...options, claim: ThreadUndo.begin("pin", "env/thread") }); - await click(add); - expect(undo).not.toHaveBeenCalled(); - await click(add, 1); - await click(add, 1); - expect(undo).toHaveBeenCalledOnce(); - expect(close).toHaveBeenCalledExactlyOnceWith("undo-toast"); - }); - - it("releases the claim on close and rejects a later click", async () => { - const { add, undo, claim, options } = setup(); - showUndoToast(options); - add.mock.calls[0]?.[0].onClose?.(); - expect(claim.isCurrent()).toBe(false); - await click(add); - expect(undo).not.toHaveBeenCalled(); - }); - - it("does not show a toast for a late completion after a newer action", () => { - const { add, options } = setup(); - ThreadUndo.invalidate("pin", "env/thread"); - showUndoToast(options); - expect(add).not.toHaveBeenCalled(); - }); - - it("reports a failed restore and releases its claim", async () => { - const { add, claim, options } = setup(); - showUndoToast({ - ...options, - undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), - }); - await click(add); - expect(add).toHaveBeenLastCalledWith( - expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }), - ); - expect(claim.isCurrent()).toBe(false); - }); - - it("reports a rejected restore promise", async () => { - const { add, options } = setup(); - showUndoToast({ - ...options, - undo: async () => { - throw new Error("disconnected"); - }, - }); - await click(add); - expect(add).toHaveBeenLastCalledWith( - expect.objectContaining({ type: "error", description: "disconnected" }), - ); - }); - - it("does not report interrupted restores as errors", async () => { - const { add, options } = setup(); - showUndoToast({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) }); - await click(add); - expect(add).toHaveBeenCalledOnce(); - }); -}); - -describe("undoLatestThreadAction", () => { - it("runs the newest live Undo once and then reports nothing to undo", () => { - const { options } = setup(); - const older = vi.fn(async () => AsyncResult.success(undefined)); - const newer = vi.fn(async () => AsyncResult.success(undefined)); - showUndoToast({ ...options, undo: older, claim: ThreadUndo.begin("settle", "env/a") }); - showUndoToast({ ...options, undo: newer, claim: ThreadUndo.begin("snooze", "env/b") }); - expect(undoLatestThreadAction()).toBe(true); - expect(newer).toHaveBeenCalledOnce(); - expect(older).not.toHaveBeenCalled(); - expect(undoLatestThreadAction()).toBe(true); - expect(older).toHaveBeenCalledOnce(); - expect(undoLatestThreadAction()).toBe(false); - }); - - it("skips a superseded toast and a closed toast", () => { - const { add, options } = setup(); - const superseded = vi.fn(async () => AsyncResult.success(undefined)); - const closed = vi.fn(async () => AsyncResult.success(undefined)); - const live = vi.fn(async () => AsyncResult.success(undefined)); - showUndoToast({ ...options, undo: live, claim: ThreadUndo.begin("archive", "env/live") }); - showUndoToast({ ...options, undo: closed, claim: ThreadUndo.begin("archive", "env/closed") }); - add.mock.calls[1]?.[0].onClose?.(); - showUndoToast({ ...options, undo: superseded, claim: ThreadUndo.begin("pin", "env/stale") }); - ThreadUndo.invalidate("pin", "env/stale"); - expect(undoLatestThreadAction()).toBe(true); - expect(superseded).not.toHaveBeenCalled(); - expect(closed).not.toHaveBeenCalled(); - expect(live).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/web/src/hooks/showUndoToast.ts b/apps/web/src/hooks/showUndoToast.ts deleted file mode 100644 index 14d532411e7f..000000000000 --- a/apps/web/src/hooks/showUndoToast.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - type AtomCommandResult, - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; - -import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import type * as ThreadUndo from "./threadUndo"; - -// Undo toasts still on screen, oldest first, so the `thread.undo` shortcut -// mirrors the newest toast's button without knowing which action it was. -const liveUndos: Array<() => Promise | null> = []; - -/** Runs the newest Undo whose claim still holds; false when nothing is left to undo. */ -export function undoLatestThreadAction(): boolean { - // A superseded entry drops itself when tried, so keep going until one - // runs or the list is empty. - while (liveUndos.length > 0) { - if (liveUndos[liveUndos.length - 1]?.() !== null) return true; - } - return false; -} - -/** Shows a single-use Undo while its thread action still owns the claim. */ -export function showUndoToast({ - title, - description, - undo, - failureTitle, - claim, -}: { - title: string; - description: string | undefined; - undo: () => Promise>; - failureTitle: string; - claim: ReturnType; -}) { - if (!claim.isCurrent()) return; - let undoStarted = false; - let toastId: string | undefined; - const reportFailure = (error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: failureTitle, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }; - const forget = () => { - const index = liveUndos.indexOf(run); - if (index !== -1) liveUndos.splice(index, 1); - }; - const run = () => { - forget(); - if (undoStarted || !claim.isCurrent()) return null; - undoStarted = true; - claim.finish(); - if (toastId !== undefined) toastManager.close(toastId); - return undo() - .then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportFailure(squashAtomCommandFailure(result)); - } - }) - .catch(reportFailure); - }; - liveUndos.push(run); - toastId = toastManager.add({ - ...stackedThreadToast({ - type: "success", - title, - description, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: async () => { - await run(); - }, - }, - }), - onClose: () => { - claim.finish(); - forget(); - }, - }); -} diff --git a/apps/web/src/hooks/threadUndo.ts b/apps/web/src/hooks/threadUndo.ts index a9e5c179fc87..29d05b51efd0 100644 --- a/apps/web/src/hooks/threadUndo.ts +++ b/apps/web/src/hooks/threadUndo.ts @@ -1,16 +1,32 @@ // Shared across hook instances so sidebar, header and menu actions invalidate each other. const currentActions = new Map(); +const listeners = new Set<() => void>(); + +export function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notify() { + for (const listener of listeners) listener(); +} /** Claims one kind of thread action; a later claim of that kind expires its Undo. */ export function begin(kind: string, threadKey: string) { const key = JSON.stringify([kind, threadKey]); const token = Symbol(); currentActions.set(key, token); + notify(); const isCurrent = () => currentActions.get(key) === token; return { isCurrent, finish: () => { - if (isCurrent()) currentActions.delete(key); + if (isCurrent()) { + currentActions.delete(key); + notify(); + } }, }; } @@ -18,4 +34,5 @@ export function begin(kind: string, threadKey: string) { /** Expires only this action kind, leaving unrelated thread actions intact. */ export function invalidate(kind: string, threadKey: string) { currentActions.delete(JSON.stringify([kind, threadKey])); + notify(); } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 40a06ba9676a..0e5491099223 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -15,7 +15,6 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; -import { snoozeWakeDescription } from "../components/Sidebar.snooze"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import * as ThreadUndo from "./threadUndo"; -import { showUndoToast } from "./showUndoToast"; +import { showThreadUndoNotice } from "./showThreadUndoNotice"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Schema.TaggedError()( @@ -223,7 +222,6 @@ export function useThreadActions() { const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); - const timestampFormat = useClientSettings((settings) => settings.timestampFormat); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -313,9 +311,8 @@ export function useThreadActions() { } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); - showUndoToast({ - title: "Thread archived", - description: thread.title, + showThreadUndoNotice({ + action: "Archived", claim: action, // Undo also brings the reader back when archiving moved them to a draft. undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), @@ -613,9 +610,8 @@ export function useThreadActions() { input: { threadId: target.threadId }, }); if (result._tag === "Success" && action.isCurrent()) { - showUndoToast({ - title: "Thread unpinned", - description: thread?.title, + showThreadUndoNotice({ + action: "Unpinned", claim: action, undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), failureTitle: "Failed to undo unpin", @@ -629,11 +625,7 @@ export function useThreadActions() { ); const settleThread = useCallback( - async ( - target: ScopedThreadRef, - // Batch callers settle a selection at once and stay silent as before. - opts: { undoToast?: boolean } = {}, - ) => { + async (target: ScopedThreadRef) => { // Version skew: never send the command to a server that predates it — // the raw protocol rejection would read as a random failure. if (!readEnvironmentSupportsSettlement(target.environmentId)) { @@ -671,13 +663,8 @@ export function useThreadActions() { if (wokeAt !== null) { markThreadVisited(scopedThreadKey(target), wokeAt); } - if (opts.undoToast === false) { - action.finish(); - return result; - } - showUndoToast({ - title: "Thread settled", - description: resolved?.thread.title, + showThreadUndoNotice({ + action: "Settled", claim: action, undo: async () => { const unsettled = await unsettleThread(target); @@ -797,12 +784,7 @@ export function useThreadActions() { ); const snoozeThread = useCallback( - async ( - target: ScopedThreadRef, - snoozedUntil: string, - // Batch callers report one toast for the whole selection instead. - opts: { undoToast?: boolean } = {}, - ) => { + async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( @@ -833,21 +815,20 @@ export function useThreadActions() { environmentId: target.environmentId, input: { threadId: target.threadId, snoozedUntil }, }); - if (result._tag !== "Success" || opts.undoToast === false) { + if (result._tag !== "Success") { action.finish(); return result; } - // Snooze hides the row, so the toast is the only confirmation. - showUndoToast({ - title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, - description: resolved?.thread.title, + // Snooze hides the row, so keep its confirmation in the sidebar. + showThreadUndoNotice({ + action: "Snoozed", claim: action, undo: () => unsnoozeThread(target), failureTitle: "Failed to wake thread", }); return result; }, - [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread], + [resolveThreadTarget, snoozeThreadMutation, unsnoozeThread], ); const confirmAndDeleteThread = useCallback( diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index eacffd146e20..6e61b298be51 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { useThreadActions } from "./useThreadActions"; import { threadEnvironment } from "../state/threads"; import { toastManager } from "../components/ui/toast"; +import { useThreadUndoNotice } from "./showThreadUndoNotice"; const commands = vi.hoisted(() => ({ pin: vi.fn(), @@ -78,18 +79,14 @@ const target = { environmentId: EnvironmentId.make("undo-env"), threadId: ThreadId.make("thread"), }; -const event = {} as Parameters["onClick"]>>[0]; - -function undoOf( - add: { mock: { calls: Array<[Parameters[0]]> } }, - index: number, -) { - const onClick = add.mock.calls[index]?.[0].actionProps?.onClick; - expect(onClick).toBeTypeOf("function"); - return () => onClick?.(event); +function currentUndo() { + const notice = useThreadUndoNotice.getState().notice; + expect(notice).not.toBeNull(); + return notice!.undo; } beforeEach(() => { + vi.useFakeTimers(); for (const command of Object.values(commands)) { command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); } @@ -98,19 +95,21 @@ beforeEach(() => { threadShell.pinnedAt = null; threadShell.snoozedUntil = null; }); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); describe("unpin Undo", () => { - it("ignores an old toast across hook instances and still restores the latest unpin", async () => { - const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); + it("ignores an old notice across hook instances and still restores the latest unpin", async () => { const sidebar = useThreadActions(); const header = useThreadActions(); await sidebar.unpinThread(target); - const staleUndo = undoOf(add, 0); + const staleUndo = currentUndo(); await header.pinThread(target, { orderKey: "a1" }); await header.unpinThread(target); - const latestUndo = undoOf(add, 1); + const latestUndo = currentUndo(); await staleUndo(); expect(commands.pin).toHaveBeenCalledTimes(1); await latestUndo(); @@ -127,15 +126,15 @@ describe("unpin Undo", () => { describe("archive Undo", () => { it("unarchives and returns to the thread when archiving left it", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); router.state.matches[0]!.params = { environmentId: target.environmentId, threadId: target.threadId, }; const actions = useThreadActions(); await actions.archiveThread(target); - expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" })); - await undoOf(add, 0)(); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 }); + expect(add).not.toHaveBeenCalled(); + await currentUndo()(); expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({ environmentId: target.environmentId, input: { threadId: target.threadId }, @@ -149,11 +148,9 @@ describe("archive Undo", () => { }); it("stays put when the archived thread was not open", async () => { - const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); const actions = useThreadActions(); await actions.archiveThread(target); - await undoOf(add, 0)(); + await currentUndo()(); expect(commands.unarchive).toHaveBeenCalledOnce(); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -167,27 +164,25 @@ describe("archive Undo", () => { }); describe("settle and snooze Undo", () => { - it("un-settles from the toast and expires the Undo after a manual un-settle", async () => { + it("un-settles from the notice and expires the Undo after a manual un-settle", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); const actions = useThreadActions(); await actions.settleThread(target); - expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" })); - const undo = undoOf(add, 0); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 }); + expect(add).not.toHaveBeenCalled(); + const undo = currentUndo(); await actions.unsettleThread(target); await undo(); expect(commands.unsettle).toHaveBeenCalledOnce(); }); it("re-pins and re-snoozes a thread that settling had cleared", async () => { - const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); const snoozedUntil = "2030-01-01T09:00:00.000Z"; threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; threadShell.snoozedUntil = snoozedUntil; const actions = useThreadActions(); await actions.settleThread(target); - await undoOf(add, 0)(); + await currentUndo()(); expect(commands.unsettle).toHaveBeenCalledOnce(); expect(commands.pin).toHaveBeenCalledExactlyOnceWith({ environmentId: target.environmentId, @@ -200,31 +195,21 @@ describe("settle and snooze Undo", () => { }); it("expires an older unpin Undo when the thread is settled", async () => { - const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); const actions = useThreadActions(); await actions.unpinThread(target); - const staleUnpinUndo = undoOf(add, 0); + const staleUnpinUndo = currentUndo(); await actions.settleThread(target); await staleUnpinUndo(); expect(commands.pin).not.toHaveBeenCalled(); }); - it("stays silent for batch settles", async () => { + it("wakes the thread from the snooze notice", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - await useThreadActions().settleThread(target, { undoToast: false }); - expect(add).not.toHaveBeenCalled(); - }); - - it("wakes the thread from the snooze toast", async () => { - const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); - vi.spyOn(toastManager, "close").mockImplementation(() => {}); const actions = useThreadActions(); await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString()); - expect(add).toHaveBeenCalledWith( - expect.objectContaining({ title: expect.stringMatching(/^Snoozed until /) }), - ); - await undoOf(add, 0)(); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Snoozed", count: 1 }); + expect(add).not.toHaveBeenCalled(); + await currentUndo()(); expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({ environmentId: target.environmentId, input: { threadId: target.threadId, reason: "user" }, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 980177004daa..5b5b4888269b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -16,6 +16,9 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { isEditableFocused } from "../lib/editableFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; @@ -66,6 +69,8 @@ function ChatRouteGlobalShortcuts() { terminalOpen, previewFocus: isPreviewFocused(), previewOpen, + editableFocus: isEditableFocused(event.target), + modelPickerOpen: isModelPickerOpen(), }, }); @@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "thread.undo") { + if (event.repeat || isModelPickerOpen()) return; + if (undoLatestThreadAction()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + if (event.key === "Escape" && selectedThreadKeysSize > 0) { event.preventDefault(); clearSelection(); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 78c792d29851..548b03fd1a73 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -111,10 +111,11 @@ a shortcut. `thread.stop` interrupts the running turn in the focused thread. It has no default shortcut; assign one in **Settings → Keybindings**. -`thread.undo` (`mod+z` by default) reverses the most recent thread action that is -still offering **Undo** in a notification, such as an unpin, settle, snooze, or -archive. Its default rule skips text fields and terminals so native undo keeps -working there. +`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the +bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive +actions of the same kind undo together. The notice remains available for five +seconds after the latest action. The default shortcut skips text fields and +terminals so native undo keeps working there. `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your From 5423ba0fd08447240282c08362ee8e382bf16903 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:51:35 +0300 Subject: [PATCH 067/164] feat(web): merge the comment and review buttons into one composer (#12945) Co-authored-by: maria-rcks --- .../pullRequest/PullRequestCodeTab.tsx | 90 +------- .../PullRequestCommentComposer.tsx | 171 --------------- .../pullRequest/PullRequestCommentForm.tsx | 145 +++++++++++++ .../pullRequest/PullRequestComposer.tsx | 195 ++++++++++++++++++ .../pullRequest/PullRequestDetailPanel.tsx | 7 +- ...eviewBar.tsx => PullRequestReviewForm.tsx} | 95 +++++---- apps/web/src/components/ui/popover.tsx | 11 +- 7 files changed, 413 insertions(+), 301 deletions(-) delete mode 100644 apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestCommentForm.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestComposer.tsx rename apps/web/src/components/pullRequest/{PullRequestReviewBar.tsx => PullRequestReviewForm.tsx} (64%) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 08a46872b697..131593ab6d22 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -18,13 +18,11 @@ import { Columns2Icon, FolderTreeIcon, InfoIcon, - MessageSquareIcon, MessageSquareOffIcon, PilcrowIcon, Rows3Icon, TextWrapIcon, TriangleAlertIcon, - XIcon, } from "lucide-react"; import { useAtomRefresh } from "@effect/atom-react"; import * as Schema from "effect/Schema"; @@ -78,7 +76,6 @@ import { toastManager } from "../ui/toast"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation"; -import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, @@ -247,9 +244,6 @@ function PullRequestCodeTab({ const [draft, setDraft] = useState(null); const [threadPending, setThreadPending] = useState(false); const [orphansOpen, setOrphansOpen] = useState(false); - // Closed by default so the review form does not permanently eat vertical space below the - // diff; opened on demand as a floating overlay instead. - const [reviewOpen, setReviewOpen] = useState(false); // Which pull request the slices belong to travels with them, so a render taken before the // reset below cannot read the previous one's slices — or send its cursor to the host. const [sliceState, setSliceState] = useState<{ @@ -378,7 +372,6 @@ function PullRequestCodeTab({ inlineComment: hostReview.inlineComment && viewer.comment, reply: hostReview.reply && viewer.comment, resolve: hostReview.resolve && viewer.resolve, - verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), }; }, [detail.capabilities.review, detail.viewerPermissions]); // A comment is posted against the pull request's head diff, so a line number taken from one @@ -1054,70 +1047,6 @@ function PullRequestCodeTab({ ], ); - /** - * The review overlay belongs to the pull request, not to the patch: a change whose diff - * cannot be structured — or read at all — is still one a reviewer can approve or reject, so - * it survives every branch below. It floats over the scroll area rather than sitting in the - * layout flow, so the diff keeps the full height instead of permanently losing a strip to a - * footer most reviews never touch. Hidden entirely where the host offers no verdicts, same as - * the bar it wraps did. - */ - const reviewOverlay = - review.verdicts.length === 0 ? null : ( -
- {reviewOpen ? ( -
- - { - onRefresh(); - setReviewOpen(false); - }} - /> -
- ) : ( - - )} -
- ); // A rebase or a force-push can take the scoped commit out of the change. Its diff may still // be reachable on the host, but it is no longer part of what is being reviewed, so the scope // goes back to the whole change rather than sitting under a name nothing matches. @@ -1392,29 +1321,23 @@ function PullRequestCodeTab({ ); // The toolbar rides above every branch below, not just the one with a patch in it: a commit // whose diff is empty or unreadable still needs the scope dropdown that got the reader there. - const withReviewBar = (body: ReactNode) => ( + const withToolbar = (body: ReactNode) => (
{toolbar} - {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning - inside an overflowing element tracks the content's bottom edge, which would carry - the trigger away with the first scroll. */} -
-
{body}
- {reviewOverlay} -
+
{body}
); // Under the toolbar rather than in place of it, so choosing a commit does not take the // dropdown that was just used off the screen while its diff loads. if (diffQuery.isPending && loadedSlices.length === 0) { - return withReviewBar(); + return withToolbar(); } // A slice that fails once there are files on screen is reported at the end of them instead: // the diff already read is worth more than the error that stopped it growing. if (diffQuery.error && loadedSlices.length === 0) { - return withReviewBar( + return withToolbar(

{diffQuery.error}

, ); } @@ -1428,7 +1351,7 @@ function PullRequestCodeTab({ ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : [])) : []; if (files.length === 0 && rawSlices.length > 0) { - return withReviewBar( + return withToolbar(
{rawSlices.map((slice) => (
@@ -1441,7 +1364,7 @@ function PullRequestCodeTab({ } if (items.length === 0 && nextCursor === null) { - return withReviewBar( + return withToolbar(

{commit === null ? "This pull request has no file changes." @@ -1595,7 +1518,6 @@ function PullRequestCodeTab({ renderAnnotation={renderAnnotation} unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} /> - {reviewOverlay}

{fileTreeOpen ? (