Date: Sun, 20 Sep 2026 15:51:32 -0700
Subject: [PATCH 16/64] 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 = (
+ props.onOpen(event, props.metadata.url)}
+ >
+
+ {props.label}
+
+ );
+ 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 17/64] 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 18/64] 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 19/64] 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 20/64] 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 21/64] 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(/