authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 17:01:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 17:01:34-07:00
log94696b0f54f467062a14aa800ee257a4801c2022
tree737a943814e4f005f502565423c96b3d4c03a704
parentfff6a5b028f85122ffef8d3636f390f95ade5172
signature Signed by PGP key B5690EEEBB952194

fix(desktop,web): improve in-app browser shortcuts and URL behavior (#4703)


15 files changed, 347 insertions(+), 133 deletions(-)

apps/desktop/src/ipc/methods/preview.ts+2-2
......@@ -16,7 +16,7 @@ import {
1616 DesktopPreviewSetColorSchemeInputSchema,
1717 DesktopPreviewTabInputSchema,
1818 DesktopPreviewWebviewConfigSchema,
19 PreviewAnnotationPayloadSchema,
19 PreviewAnnotationSubmissionResultSchema,
2020 PreviewAutomationSnapshot,
2121 PreviewAutomationStatus,
2222} from "@t3tools/contracts";
......@@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
227227export const pickElement = DesktopIpc.makeIpcMethod({
228228 channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,
229229 payload: DesktopPreviewTabInputSchema,
230 result: Schema.NullOr(PreviewAnnotationPayloadSchema),
230 result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema),
231231 handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) {
232232 const manager = yield* PreviewManager.PreviewManager;
233233 return yield* manager.pickElement(tabId);
apps/desktop/src/preview/AnnotationKeyboard.test.ts created+28
......@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";
4
5const keyboardEvent = (
6 overrides: Partial<Parameters<typeof resolveAnnotationSubmission>[0]> = {},
7) => ({
8 key: "Enter",
9 metaKey: false,
10 ctrlKey: false,
11 shiftKey: false,
12 isComposing: false,
13 ...overrides,
14});
15
16describe("resolveAnnotationSubmission", () => {
17 it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => {
18 expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach");
19 expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send");
20 expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send");
21 });
22
23 it("leaves Shift+Enter and composition events available for editing", () => {
24 expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull();
25 expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull();
26 expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).toBeNull();
27 });
28});
apps/desktop/src/preview/AnnotationKeyboard.ts created+16
......@@ -0,0 +1,16 @@
1import type { PreviewAnnotationSubmission } from "@t3tools/contracts";
2
3interface AnnotationKeyboardEvent {
4 readonly key: string;
5 readonly metaKey: boolean;
6 readonly ctrlKey: boolean;
7 readonly shiftKey: boolean;
8 readonly isComposing: boolean;
9}
10
11export function resolveAnnotationSubmission(
12 event: AnnotationKeyboardEvent,
13): PreviewAnnotationSubmission | null {
14 if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null;
15 return event.metaKey || event.ctrlKey ? "send" : "attach";
16}
apps/desktop/src/preview/Manager.test.ts+22
......@@ -36,6 +36,28 @@ describe("fitPictureInPictureContentSize", () => {
3636 });
3737});
3838
39describe("isPreviewRefreshShortcut", () => {
40 const input = (overrides: Partial<Electron.Input> = {}) =>
41 ({
42 type: "keyDown",
43 key: "r",
44 meta: true,
45 control: false,
46 shift: false,
47 alt: false,
48 ...overrides,
49 }) as Electron.Input;
50
51 it("recognizes the platform refresh chord without matching modified variants", () => {
52 expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true);
53 expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe(
54 true,
55 );
56 expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false);
57 expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false);
58 });
59});
60
3961const {
4062 browserWindowConstructor,
4163 createFromPath,
apps/desktop/src/preview/Manager.ts+25-6
......@@ -11,6 +11,7 @@ import type {
1111 DesktopPreviewPointerEvent,
1212 PreviewAnnotationPayload,
1313 PreviewAnnotationRect,
14 PreviewAnnotationSubmissionResult,
1415 DesktopPreviewRecordingArtifact,
1516 DesktopPreviewRecordingFrame,
1617 DesktopPreviewScreenshotArtifact,
......@@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
406407 { key: "w", meta: true, shift: false, control: false },
407408]);
408409
410export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
411 input.type === "keyDown" &&
412 input.key.toLowerCase() === "r" &&
413 (input.meta || input.control) &&
414 !input.shift &&
415 !input.alt;
416
409417const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
410418 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
411419 if (value.kind === "pointer") {
......@@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13651373 });
13661374 });
13671375 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1376 if (isPreviewRefreshShortcut(input)) {
1377 event.preventDefault();
1378 runFork(
1379 attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () =>
1380 wc.reload(),
1381 ).pipe(Effect.ignore),
1382 );
1383 return;
1384 }
13681385 runFork(forwardShortcut(event, input));
13691386 };
13701387 yield* Scope.addFinalizer(
......@@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17921809 const wc = yield* requireWebContents(tabId);
17931810 yield* cancelPickElement(tabId);
17941811 const annotationTheme = yield* Ref.get(annotationThemeRef);
1795 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
1812 return yield* Effect.callback<PreviewAnnotationSubmissionResult | null, PreviewManagerError>(
17961813 (resume) => {
17971814 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
17981815 yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => {
......@@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18071824 );
18081825 });
18091826 const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* (
1810 payload: PreviewAnnotationPayload | null,
1827 payload: PreviewAnnotationSubmissionResult | null,
18111828 ) {
18121829 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);
18131830 if (!active || active.cancel !== cancel) return;
18141831 yield* cleanup();
18151832 resume(Effect.succeed(payload));
18161833 });
1817 const settle = (payload: PreviewAnnotationPayload | null) => {
1834 const settle = (payload: PreviewAnnotationSubmissionResult | null) => {
18181835 runFork(settlePick(payload));
18191836 };
18201837 const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () {
......@@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18441861 return;
18451862 }
18461863 const cropRect = normalizeCaptureRect(args[1]);
1864 const submission = args[2] === "send" ? "send" : "attach";
18471865 runFork(
18481866 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(
18491867 Effect.matchEffect({
1850 onFailure: () => Effect.sync(() => settle(payload)),
1851 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),
1868 onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })),
1869 onSuccess: (screenshot) =>
1870 Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })),
18521871 }),
18531872 Effect.ensuring(
18541873 attempt(
......@@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service<
35863605 ) => Effect.Effect<void, PreviewManagerError>;
35873606 readonly pickElement: (
35883607 tabId: string,
3589 ) => Effect.Effect<PreviewAnnotationPayload | null, PreviewManagerError>;
3608 ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>;
35903609 readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
35913610 readonly captureScreenshot: (
35923611 tabId: string,
apps/desktop/src/preview/PickPreload.ts+14-7
......@@ -11,8 +11,10 @@ import type {
1111 PreviewAnnotationRegionTarget,
1212 PreviewAnnotationStrokeTarget,
1313 PreviewAnnotationStyleChange,
14 PreviewAnnotationSubmission,
1415} from "@t3tools/contracts";
1516
17import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";
1618import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts";
1719import {
1820 ANNOTATION_CAPTURED_CHANNEL,
......@@ -426,7 +428,7 @@ function startAnnotation(): void {
426428 "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground";
427429 composerRow.appendChild(dragHandle);
428430
429 const submit = createButton("Attach", "Attach annotation and screenshot");
431 const submit = createButton("Attach", "Attach annotation and screenshot (Enter)");
430432 submit.className +=
431433 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
432434 composerRow.appendChild(submit);
......@@ -1182,7 +1184,7 @@ function startAnnotation(): void {
11821184 refreshToolButtons();
11831185 };
11841186
1185 submit.addEventListener("click", () => {
1187 const submitAnnotation = (submission: PreviewAnnotationSubmission): void => {
11861188 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
11871189 return;
11881190 pendingCapture = true;
......@@ -1223,13 +1225,18 @@ function startAnnotation(): void {
12231225 ...regions.map((region) => region.rect),
12241226 ...strokes.map((stroke) => stroke.bounds),
12251227 ]);
1226 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect);
1228 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission);
12271229 });
1228 });
1229 comment.addEventListener("keydown", (event) => {
1230 if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
1230 };
1231 submit.addEventListener("click", () => submitAnnotation("attach"));
1232 root.addEventListener("keydown", (event) => {
1233 const submission = event.target === comment ? resolveAnnotationSubmission(event) : null;
1234 // Keep this in the bubble phase so editor inputs receive the event before
1235 // it is isolated from listeners installed by the inspected page.
1236 event.stopImmediatePropagation();
1237 if (!submission) return;
12311238 event.preventDefault();
1232 submit.click();
1239 submitAnnotation(submission);
12331240 });
12341241
12351242 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
apps/web/src/components/ChatView.tsx+55-6
......@@ -8,6 +8,7 @@ import {
88 type ProjectScript,
99 type ProjectId,
1010 type ProviderApprovalDecision,
11 type PreviewAnnotationPayload,
1112 ProviderInstanceId,
1213 type ServerProvider,
1314 type ResolvedKeybindingsConfig,
......@@ -4561,8 +4562,24 @@ function ChatViewContent(props: ChatViewProps) {
45614562 ],
45624563 );
45634564
4564 const onSend = async (e?: { preventDefault: () => void }) => {
4565 const onSend = async (
4566 e?: { preventDefault: () => void },
4567 directAnnotation?: {
4568 annotation: PreviewAnnotationPayload;
4569 image: ComposerImageAttachment | null;
4570 },
4571 ) => {
45654572 e?.preventDefault();
4573 const notifyDirectAnnotationAttached = () => {
4574 if (!directAnnotation) return;
4575 toastManager.add(
4576 stackedThreadToast({
4577 type: "info",
4578 title: "Annotation attached to draft",
4579 description: "Sending is unavailable right now. Finish the current action, then send.",
4580 }),
4581 );
4582 };
45664583 if (
45674584 !activeThread ||
45684585 isSendBusy ||
......@@ -4570,19 +4587,28 @@ function ChatViewContent(props: ChatViewProps) {
45704587 threadDetailLoading ||
45714588 activeEnvironmentUnavailable ||
45724589 sendInFlightRef.current
4573 )
4590 ) {
4591 notifyDirectAnnotationAttached();
45744592 return;
4593 }
45754594 if (activePendingProgress) {
4595 if (directAnnotation) {
4596 notifyDirectAnnotationAttached();
4597 return;
4598 }
45764599 onAdvanceActivePendingUserInput();
45774600 return;
45784601 }
45794602 const sendCtx = composerRef.current?.getSendContext();
4580 if (!sendCtx?.providerAvailable) return;
4603 if (!sendCtx?.providerAvailable) {
4604 notifyDirectAnnotationAttached();
4605 return;
4606 }
45814607 const {
4582 images: composerImages,
4608 images: sendContextImages,
45834609 terminalContexts: composerTerminalContexts,
45844610 elementContexts: composerElementContexts,
4585 previewAnnotations: composerPreviewAnnotations,
4611 previewAnnotations: sendContextPreviewAnnotations,
45864612 reviewComments: composerReviewComments,
45874613 selectedProvider: ctxSelectedProvider,
45884614 selectedModel: ctxSelectedModel,
......@@ -4590,6 +4616,26 @@ function ChatViewContent(props: ChatViewProps) {
45904616 selectedPromptEffort: ctxSelectedPromptEffort,
45914617 selectedModelSelection: ctxSelectedModelSelection,
45924618 } = sendCtx;
4619 const composerImages =
4620 directAnnotation?.image &&
4621 !sendContextImages.some((image) => image.id === directAnnotation.image?.id)
4622 ? [...sendContextImages, directAnnotation.image]
4623 : sendContextImages;
4624 const composerPreviewAnnotations =
4625 directAnnotation &&
4626 !sendContextPreviewAnnotations.some(
4627 (annotation) => annotation.id === directAnnotation.annotation.id,
4628 )
4629 ? [
4630 ...sendContextPreviewAnnotations,
4631 {
4632 ...directAnnotation.annotation,
4633 screenshot: directAnnotation.annotation.screenshot
4634 ? { ...directAnnotation.annotation.screenshot, dataUrl: "" }
4635 : null,
4636 },
4637 ]
4638 : sendContextPreviewAnnotations;
45934639 const promptForSend = promptRef.current;
45944640 const {
45954641 trimmedPrompt: trimmed,
......@@ -4605,7 +4651,7 @@ function ChatViewContent(props: ChatViewProps) {
46054651 composerPreviewAnnotations.length +
46064652 composerReviewComments.length,
46074653 });
4608 if (showPlanFollowUpPrompt && activeProposedPlan) {
4654 if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
46094655 const followUp = resolvePlanFollowUpSubmission({
46104656 draftText: trimmed,
46114657 planMarkdown: activeProposedPlan.planMarkdown,
......@@ -5674,6 +5720,9 @@ function ChatViewContent(props: ChatViewProps) {
56745720 tabId={activeRightPanelSurface.resourceId}
56755721 configuredUrls={configuredPreviewUrls}
56765722 visible
5723 onSendAnnotation={(annotation, image) => {
5724 void onSend(undefined, { annotation, image });
5725 }}
56775726 />
56785727 </Suspense>
56795728 ) : activeRightPanelSurface?.kind === "terminal" ? (
apps/web/src/components/preview/PreviewChromeRow.test.tsx created+25
......@@ -0,0 +1,25 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { PreviewChromeRow } from "./PreviewChromeRow";
5
6describe("PreviewChromeRow", () => {
7 it("shows the complete URL while the address bar is not focused", () => {
8 const markup = renderToStaticMarkup(
9 <PreviewChromeRow
10 url="https://example.com/dashboard?mode=edit&tab=1#notes"
11 loading={false}
12 loadProgress={0}
13 canGoBack={false}
14 canGoForward={false}
15 refreshDisabled={false}
16 onBack={vi.fn()}
17 onForward={vi.fn()}
18 onRefresh={vi.fn()}
19 onSubmit={vi.fn()}
20 />,
21 );
22
23 expect(markup).toContain('value="https://example.com/dashboard?mode=edit&amp;tab=1#notes"');
24 });
25});
apps/web/src/components/preview/PreviewChromeRow.tsx+1-4
......@@ -23,7 +23,6 @@ import { cn } from "~/lib/utils";
2323
2424interface Props {
2525 url: string;
26 displayUrl?: string | undefined;
2726 loading: boolean;
2827 loadProgress: number;
2928 canGoBack: boolean;
......@@ -65,7 +64,6 @@ const NOOP = () => {};
6564
6665export function PreviewChromeRow({
6766 url,
68 displayUrl,
6967 loading,
7068 loadProgress,
7169 canGoBack,
......@@ -172,7 +170,7 @@ export function PreviewChromeRow({
172170 render={
173171 <InputGroupInput
174172 ref={inputRef}
175 value={inputFocused ? draft : (displayUrl ?? url)}
173 value={inputFocused ? draft : url}
176174 className={cn(
177175 onOpenInBrowser &&
178176 !inputFocused &&
......@@ -203,7 +201,6 @@ export function PreviewChromeRow({
203201 />
204202 }
205203 />
206 {!inputFocused && displayUrl ? <TooltipPopup>{url}</TooltipPopup> : null}
207204 </Tooltip>
208205 {onOpenInBrowser && !inputFocused ? (
209206 <InputGroupAddon
apps/web/src/components/preview/PreviewPanel.tsx+15-2
......@@ -1,7 +1,8 @@
11"use client";
22
3import type { ScopedThreadRef } from "@t3tools/contracts";
3import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";
44
5import type { ComposerImageAttachment } from "~/composerDraftStore";
56import { isPreviewSupportedInRuntime } from "~/previewStateStore";
67
78import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
......@@ -13,9 +14,20 @@ interface Props {
1314 tabId?: string | null;
1415 configuredUrls?: ReadonlyArray<string> | undefined;
1516 visible: boolean;
17 onSendAnnotation?: (
18 annotation: PreviewAnnotationPayload,
19 image: ComposerImageAttachment | null,
20 ) => void;
1621}
1722
18export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) {
23export function PreviewPanel({
24 mode,
25 threadRef,
26 tabId,
27 configuredUrls,
28 visible,
29 onSendAnnotation,
30}: Props) {
1931 if (!isPreviewSupportedInRuntime()) {
2032 return (
2133 <PreviewPanelShell mode={mode}>
......@@ -35,6 +47,7 @@ export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }
3547 {...(tabId !== undefined ? { tabId } : {})}
3648 configuredUrls={configuredUrls}
3749 visible={visible}
50 {...(onSendAnnotation ? { onSendAnnotation } : {})}
3851 />
3952 </PreviewPanelShell>
4053 );
apps/web/src/components/preview/PreviewView.test.tsx+85-2
......@@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({
1717 closeRightPanel: vi.fn(),
1818 openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
1919 closePictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
20 pickElement: vi.fn(),
21 previewAnnotationScreenshotFile: vi.fn(),
22 addPreviewAnnotation: vi.fn(),
23 addImage: vi.fn(),
24 toggleAnnotation: null as (() => void) | null,
2025 pictureInPicture: false,
2126 showEmptyState: false,
2227}));
......@@ -28,11 +33,15 @@ vi.mock("~/state/session", () => ({
2833vi.mock("~/composerDraftStore", () => ({
2934 useComposerDraftStore: (
3035 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,
31 ) => select({ addPreviewAnnotation: vi.fn(), addImage: vi.fn() }),
36 ) =>
37 select({
38 addPreviewAnnotation: mocks.addPreviewAnnotation,
39 addImage: mocks.addImage,
40 }),
3241}));
3342
3443vi.mock("~/lib/previewAnnotation", () => ({
35 previewAnnotationScreenshotFile: vi.fn(),
44 previewAnnotationScreenshotFile: mocks.previewAnnotationScreenshotFile,
3645}));
3746
3847vi.mock("~/localApi", () => ({
......@@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({
144153vi.mock("./previewBridge", () => ({
145154 previewBridge: {
146155 navigate: mocks.navigate,
156 pickElement: mocks.pickElement,
147157 pictureInPicture: {
148158 open: mocks.openPictureInPicture,
149159 close: mocks.closePictureInPicture,
......@@ -154,6 +164,7 @@ vi.mock("./previewBridge", () => ({
154164vi.mock("./PreviewChromeRow", () => ({
155165 PreviewChromeRow: (props: {
156166 onSubmit: (url: string) => void;
167 onPickElement?: () => void;
157168 onPictureInPicture?: () => void;
158169 pictureInPicture?: boolean;
159170 trailingActions?: {
......@@ -161,6 +172,7 @@ vi.mock("./PreviewChromeRow", () => ({
161172 };
162173 }) => {
163174 mocks.submittedUrl = props.onSubmit;
175 mocks.toggleAnnotation = props.onPickElement ?? null;
164176 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;
165177 mocks.toggleNativePictureInPicture =
166178 props.trailingActions?.props.onNativePictureInPicture ?? null;
......@@ -213,6 +225,11 @@ describe("PreviewView navigation", () => {
213225 mocks.closeRightPanel.mockClear();
214226 mocks.openPictureInPicture.mockClear();
215227 mocks.closePictureInPicture.mockClear();
228 mocks.pickElement.mockReset();
229 mocks.previewAnnotationScreenshotFile.mockReset();
230 mocks.addPreviewAnnotation.mockClear();
231 mocks.addImage.mockClear();
232 mocks.toggleAnnotation = null;
216233 mocks.pictureInPicture = false;
217234 mocks.showEmptyState = false;
218235 });
......@@ -327,4 +344,70 @@ describe("PreviewView navigation", () => {
327344 expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),
328345 );
329346 });
347
348 it("forwards Cmd/Ctrl+Enter annotations to the composer send path", async () => {
349 const annotation = {
350 id: "annotation-1",
351 pageUrl: "https://example.com/dashboard",
352 pageTitle: "Dashboard",
353 comment: "Tighten this spacing",
354 elements: [],
355 regions: [],
356 strokes: [],
357 styleChanges: [],
358 screenshot: null,
359 createdAt: "2026-07-27T00:00:00.000Z",
360 };
361 const onSendAnnotation = vi.fn();
362 mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
363
364 renderToStaticMarkup(
365 <PreviewView
366 threadRef={TEST_THREAD_REF}
367 tabId="tab-1"
368 visible
369 onSendAnnotation={onSendAnnotation}
370 />,
371 );
372 mocks.toggleAnnotation?.();
373
374 await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null));
375 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);
376 });
377
378 it("still sends when screenshot attachment conversion fails", async () => {
379 const annotation = {
380 id: "annotation-2",
381 pageUrl: "https://example.com/dashboard",
382 pageTitle: "Dashboard",
383 comment: "Tighten this spacing",
384 elements: [],
385 regions: [],
386 strokes: [],
387 styleChanges: [],
388 screenshot: {
389 dataUrl: "data:image/png;base64,c2NyZWVuc2hvdA==",
390 width: 10,
391 height: 10,
392 cropRect: { x: 0, y: 0, width: 10, height: 10 },
393 },
394 createdAt: "2026-07-27T00:00:00.000Z",
395 };
396 const onSendAnnotation = vi.fn();
397 mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
398 mocks.previewAnnotationScreenshotFile.mockRejectedValue(new Error("conversion failed"));
399
400 renderToStaticMarkup(
401 <PreviewView
402 threadRef={TEST_THREAD_REF}
403 tabId="tab-1"
404 visible
405 onSendAnnotation={onSendAnnotation}
406 />,
407 );
408 mocks.toggleAnnotation?.();
409
410 await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null));
411 expect(mocks.addImage).not.toHaveBeenCalled();
412 });
330413});
apps/web/src/components/preview/PreviewView.tsx+41-29
......@@ -4,13 +4,14 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment";
44import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
55import {
66 FILL_PREVIEW_VIEWPORT,
7 type PreviewAnnotationPayload,
78 type PreviewViewportSetting,
89 type ScopedThreadRef,
910} from "@t3tools/contracts";
1011import { normalizePreviewUrl } from "@t3tools/shared/preview";
1112import { useCallback, useEffect, useRef, useState } from "react";
1213
13import { useComposerDraftStore } from "~/composerDraftStore";
14import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
1415import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
1516import { ensureLocalApi } from "~/localApi";
1617import {
......@@ -19,7 +20,6 @@ import {
1920 useThreadPreviewState,
2021} from "~/previewStateStore";
2122import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
22import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";
2323import { previewEnvironment } from "~/state/preview";
2424import { useAtomCommand } from "~/state/use-atom-command";
2525import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
......@@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge";
2929import { subscribePreviewAction } from "./previewActionBus";
3030import { openPreviewSession } from "./openPreviewSession";
3131import { PreviewChromeRow } from "./PreviewChromeRow";
32import { formatPreviewUrl } from "./previewUrlPresentation";
3332import { PreviewEmptyState } from "./PreviewEmptyState";
3433import { PreviewMoreMenu } from "./PreviewMoreMenu";
3534import {
......@@ -60,6 +59,10 @@ interface Props {
6059 tabId?: string | null;
6160 configuredUrls?: ReadonlyArray<string> | undefined;
6261 visible: boolean;
62 onSendAnnotation?: (
63 annotation: PreviewAnnotationPayload,
64 image: ComposerImageAttachment | null,
65 ) => void;
6366}
6467
6568const localApi = typeof window === "undefined" ? null : ensureLocalApi();
......@@ -68,7 +71,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
6871 * Single-tab preview surface: chrome row on top, one webview below, empty
6972 * state when no session exists for the thread.
7073 */
71export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {
74export function PreviewView({
75 threadRef,
76 tabId: requestedTabId,
77 configuredUrls,
78 visible,
79 onSendAnnotation,
80}: Props) {
7281 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
7382 const [pickActive, setPickActive] = useState(false);
7483 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
......@@ -80,8 +89,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
8089 );
8190 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
8291 const addImage = useComposerDraftStore((store) => store.addImage);
83 const environment = useEnvironment(threadRef.environmentId);
84 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
8592 const open = useAtomCommand(previewEnvironment.open);
8693 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
8794
......@@ -116,14 +123,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
116123 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
117124 const controller = desktopOverlay?.controller ?? "none";
118125 const loadProgress = useLoadingProgress(loading);
119 const displayUrl =
120 url && environment && environmentHttpBaseUrl
121 ? (formatPreviewUrl({
122 url,
123 environmentLabel: environment.label,
124 environmentHttpBaseUrl,
125 }) ?? undefined)
126 : undefined;
127126 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
128127 const panelRect = useBrowserSurfaceStore((state) =>
129128 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
......@@ -523,20 +522,34 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
523522 setPickActive(true);
524523 void (async () => {
525524 try {
526 const annotation = await previewBridge.pickElement(runtimeTabId);
527 if (!annotation) return;
525 const result = await previewBridge.pickElement(runtimeTabId);
526 if (!result) return;
527 const { annotation, submission } = result;
528528 addPreviewAnnotation(threadRef, annotation);
529 const screenshotFile = await previewAnnotationScreenshotFile(annotation);
530 if (screenshotFile && annotation.screenshot) {
531 addImage(threadRef, {
532 type: "image",
533 id: annotation.id,
534 name: screenshotFile.name,
535 mimeType: screenshotFile.type,
536 sizeBytes: screenshotFile.size,
537 previewUrl: annotation.screenshot.dataUrl,
538 file: screenshotFile,
539 });
529 let screenshotFile: File | null = null;
530 try {
531 screenshotFile = await previewAnnotationScreenshotFile(annotation);
532 } catch {
533 // The structured annotation is still sendable when converting its
534 // optional screenshot into a composer attachment fails.
535 }
536 const image =
537 screenshotFile && annotation.screenshot
538 ? ({
539 type: "image",
540 id: annotation.id,
541 name: screenshotFile.name,
542 mimeType: screenshotFile.type,
543 sizeBytes: screenshotFile.size,
544 previewUrl: annotation.screenshot.dataUrl,
545 file: screenshotFile,
546 } satisfies ComposerImageAttachment)
547 : null;
548 if (image) {
549 addImage(threadRef, image);
550 }
551 if (submission === "send") {
552 onSendAnnotation?.(annotation, image);
540553 }
541554 } catch {
542555 // Picker failed (e.g. webview navigated). Treat as silent cancel.
......@@ -561,7 +574,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
561574 }
562575 }
563576 })();
564 }, [addImage, addPreviewAnnotation, runtimeTabId, threadRef]);
577 }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]);
565578
566579 // If the active tab changes mid-pick (close, thread switch, hot restart),
567580 // tell main to tear down the in-flight session AND reset our local toggle
......@@ -611,7 +624,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
611624 >
612625 <PreviewChromeRow
613626 url={url}
614 displayUrl={displayUrl}
615627 loading={loading}
616628 loadProgress={loadProgress}
617629 canGoBack={canGoBack}
apps/web/src/components/preview/previewUrlPresentation.test.ts deleted-45
......@@ -1,45 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatPreviewUrl } from "./previewUrlPresentation";
4
5describe("formatPreviewUrl", () => {
6 it("formats signed asset URLs with the environment label and decoded filename", () => {
7 expect(
8 formatPreviewUrl({
9 url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf",
10 environmentLabel: "Local environment",
11 environmentHttpBaseUrl: "http://127.0.0.1:3773",
12 }),
13 ).toBe("Local environment · architecture brief.pdf");
14 });
15
16 it("does not alias assets from another origin", () => {
17 expect(
18 formatPreviewUrl({
19 url: "https://example.com/api/assets/token/report.pdf",
20 environmentLabel: "Local environment",
21 environmentHttpBaseUrl: "http://127.0.0.1:3773",
22 }),
23 ).toBe("example.com");
24 });
25
26 it("formats regular preview URLs as their exact host", () => {
27 expect(
28 formatPreviewUrl({
29 url: "http://127.0.0.1:5173/dashboard",
30 environmentLabel: "Local environment",
31 environmentHttpBaseUrl: "http://127.0.0.1:3773",
32 }),
33 ).toBe("127.0.0.1:5173");
34 });
35
36 it("does not compact non-http URLs", () => {
37 expect(
38 formatPreviewUrl({
39 url: "file:///tmp/report.pdf",
40 environmentLabel: "Local environment",
41 environmentHttpBaseUrl: "http://127.0.0.1:3773",
42 }),
43 ).toBeNull();
44 });
45});
apps/web/src/components/preview/previewUrlPresentation.ts deleted-27
......@@ -1,27 +0,0 @@
1interface PreviewUrlPresentationInput {
2 readonly url: string;
3 readonly environmentLabel: string;
4 readonly environmentHttpBaseUrl: string;
5}
6
7export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null {
8 try {
9 const url = new URL(input.url);
10 const environmentUrl = new URL(input.environmentHttpBaseUrl);
11 if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) {
12 const encodedFileName = url.pathname.split("/").at(-1);
13 if (!encodedFileName) {
14 return null;
15 }
16 const fileName = decodeURIComponent(encodedFileName);
17 if (!fileName || fileName === "." || fileName === "..") {
18 return null;
19 }
20 return `${input.environmentLabel} · ${fileName}`;
21 }
22
23 return url.protocol === "http:" || url.protocol === "https:" ? url.host : null;
24 } catch {
25 return null;
26 }
27}
packages/contracts/src/ipc.ts+18-3
......@@ -896,6 +896,20 @@ export const PreviewAnnotationPayloadSchema: Schema.Codec<PreviewAnnotationPaylo
896896 },
897897);
898898
899export type PreviewAnnotationSubmission = "attach" | "send";
900export const PreviewAnnotationSubmissionSchema: Schema.Codec<PreviewAnnotationSubmission> =
901 Schema.Literals(["attach", "send"]);
902
903export interface PreviewAnnotationSubmissionResult {
904 annotation: PreviewAnnotationPayload;
905 submission: PreviewAnnotationSubmission;
906}
907export const PreviewAnnotationSubmissionResultSchema: Schema.Codec<PreviewAnnotationSubmissionResult> =
908 Schema.Struct({
909 annotation: PreviewAnnotationPayloadSchema,
910 submission: PreviewAnnotationSubmissionSchema,
911 });
912
899913export const DesktopPreviewTabInputSchema = Schema.Struct({
900914 tabId: DesktopPreviewTabIdSchema,
901915});
......@@ -1062,10 +1076,11 @@ export interface DesktopPreviewBridge {
10621076 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
10631077 /**
10641078 * Activate the in-page element picker for the given tab. Resolves with
1065 * the picked payload, or `null` when the user cancels (Escape / nav). The
1066 * promise rejects if the picker can't be activated (no webview, etc.).
1079 * the picked annotation and its attach/send intent, or `null` when the
1080 * user cancels (Escape / nav). The promise rejects if the picker can't be
1081 * activated (no webview, etc.).
10671082 */
1068 pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>;
1083 pickElement: (tabId: string) => Promise<PreviewAnnotationSubmissionResult | null>;
10691084 /** Cancel an in-flight preview annotation session. */
10701085 cancelPickElement: (tabId: string) => Promise<void>;
10711086 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;