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 {...@@ -16,7 +16,7 @@ import {
16 DesktopPreviewSetColorSchemeInputSchema,16 DesktopPreviewSetColorSchemeInputSchema,
17 DesktopPreviewTabInputSchema,17 DesktopPreviewTabInputSchema,
18 DesktopPreviewWebviewConfigSchema,18 DesktopPreviewWebviewConfigSchema,
19 PreviewAnnotationPayloadSchema,19 PreviewAnnotationSubmissionResultSchema,
20 PreviewAutomationSnapshot,20 PreviewAutomationSnapshot,
21 PreviewAutomationStatus,21 PreviewAutomationStatus,
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
...@@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({...@@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
227export const pickElement = DesktopIpc.makeIpcMethod({227export const pickElement = DesktopIpc.makeIpcMethod({
228 channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,228 channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,
229 payload: DesktopPreviewTabInputSchema,229 payload: DesktopPreviewTabInputSchema,
230 result: Schema.NullOr(PreviewAnnotationPayloadSchema),230 result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema),
231 handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) {231 handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) {
232 const manager = yield* PreviewManager.PreviewManager;232 const manager = yield* PreviewManager.PreviewManager;
233 return yield* manager.pickElement(tabId);233 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", () => {...@@ -36,6 +36,28 @@ describe("fitPictureInPictureContentSize", () => {
36 });36 });
37});37});
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
39const {61const {
40 browserWindowConstructor,62 browserWindowConstructor,
41 createFromPath,63 createFromPath,
apps/desktop/src/preview/Manager.ts+25-6
...@@ -11,6 +11,7 @@ import type {...@@ -11,6 +11,7 @@ import type {
11 DesktopPreviewPointerEvent,11 DesktopPreviewPointerEvent,
12 PreviewAnnotationPayload,12 PreviewAnnotationPayload,
13 PreviewAnnotationRect,13 PreviewAnnotationRect,
14 PreviewAnnotationSubmissionResult,
14 DesktopPreviewRecordingArtifact,15 DesktopPreviewRecordingArtifact,
15 DesktopPreviewRecordingFrame,16 DesktopPreviewRecordingFrame,
16 DesktopPreviewScreenshotArtifact,17 DesktopPreviewScreenshotArtifact,
...@@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{...@@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
406 { key: "w", meta: true, shift: false, control: false },407 { key: "w", meta: true, shift: false, control: false },
407]);408]);
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
409const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {417const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
410 if (typeof value !== "object" || value === null || !("kind" in value)) return false;418 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
411 if (value.kind === "pointer") {419 if (value.kind === "pointer") {
...@@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1365 });1373 });
1366 });1374 });
1367 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {1375 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 }
1368 runFork(forwardShortcut(event, input));1385 runFork(forwardShortcut(event, input));
1369 };1386 };
1370 yield* Scope.addFinalizer(1387 yield* Scope.addFinalizer(
...@@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1792 const wc = yield* requireWebContents(tabId);1809 const wc = yield* requireWebContents(tabId);
1793 yield* cancelPickElement(tabId);1810 yield* cancelPickElement(tabId);
1794 const annotationTheme = yield* Ref.get(annotationThemeRef);1811 const annotationTheme = yield* Ref.get(annotationThemeRef);
1795 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(1812 return yield* Effect.callback<PreviewAnnotationSubmissionResult | null, PreviewManagerError>(
1796 (resume) => {1813 (resume) => {
1797 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {1814 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
1798 yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => {1815 yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => {
...@@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1807 );1824 );
1808 });1825 });
1809 const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* (1826 const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* (
1810 payload: PreviewAnnotationPayload | null,1827 payload: PreviewAnnotationSubmissionResult | null,
1811 ) {1828 ) {
1812 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);1829 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);
1813 if (!active || active.cancel !== cancel) return;1830 if (!active || active.cancel !== cancel) return;
1814 yield* cleanup();1831 yield* cleanup();
1815 resume(Effect.succeed(payload));1832 resume(Effect.succeed(payload));
1816 });1833 });
1817 const settle = (payload: PreviewAnnotationPayload | null) => {1834 const settle = (payload: PreviewAnnotationSubmissionResult | null) => {
1818 runFork(settlePick(payload));1835 runFork(settlePick(payload));
1819 };1836 };
1820 const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () {1837 const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () {
...@@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1844 return;1861 return;
1845 }1862 }
1846 const cropRect = normalizeCaptureRect(args[1]);1863 const cropRect = normalizeCaptureRect(args[1]);
1864 const submission = args[2] === "send" ? "send" : "attach";
1847 runFork(1865 runFork(
1848 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(1866 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(
1849 Effect.matchEffect({1867 Effect.matchEffect({
1850 onFailure: () => Effect.sync(() => settle(payload)),1868 onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })),
1851 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),1869 onSuccess: (screenshot) =>
1870 Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })),
1852 }),1871 }),
1853 Effect.ensuring(1872 Effect.ensuring(
1854 attempt(1873 attempt(
...@@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service<...@@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service<
3586 ) => Effect.Effect<void, PreviewManagerError>;3605 ) => Effect.Effect<void, PreviewManagerError>;
3587 readonly pickElement: (3606 readonly pickElement: (
3588 tabId: string,3607 tabId: string,
3589 ) => Effect.Effect<PreviewAnnotationPayload | null, PreviewManagerError>;3608 ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>;
3590 readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>;3609 readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3591 readonly captureScreenshot: (3610 readonly captureScreenshot: (
3592 tabId: string,3611 tabId: string,
apps/desktop/src/preview/PickPreload.ts+14-7
...@@ -11,8 +11,10 @@ import type {...@@ -11,8 +11,10 @@ import type {
11 PreviewAnnotationRegionTarget,11 PreviewAnnotationRegionTarget,
12 PreviewAnnotationStrokeTarget,12 PreviewAnnotationStrokeTarget,
13 PreviewAnnotationStyleChange,13 PreviewAnnotationStyleChange,
14 PreviewAnnotationSubmission,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
1516
17import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";
16import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts";18import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts";
17import {19import {
18 ANNOTATION_CAPTURED_CHANNEL,20 ANNOTATION_CAPTURED_CHANNEL,
...@@ -426,7 +428,7 @@ function startAnnotation(): void {...@@ -426,7 +428,7 @@ function startAnnotation(): void {
426 "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";428 "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";
427 composerRow.appendChild(dragHandle);429 composerRow.appendChild(dragHandle);
428430
429 const submit = createButton("Attach", "Attach annotation and screenshot");431 const submit = createButton("Attach", "Attach annotation and screenshot (Enter)");
430 submit.className +=432 submit.className +=
431 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";433 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
432 composerRow.appendChild(submit);434 composerRow.appendChild(submit);
...@@ -1182,7 +1184,7 @@ function startAnnotation(): void {...@@ -1182,7 +1184,7 @@ function startAnnotation(): void {
1182 refreshToolButtons();1184 refreshToolButtons();
1183 };1185 };
11841186
1185 submit.addEventListener("click", () => {1187 const submitAnnotation = (submission: PreviewAnnotationSubmission): void => {
1186 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))1188 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
1187 return;1189 return;
1188 pendingCapture = true;1190 pendingCapture = true;
...@@ -1223,13 +1225,18 @@ function startAnnotation(): void {...@@ -1223,13 +1225,18 @@ function startAnnotation(): void {
1223 ...regions.map((region) => region.rect),1225 ...regions.map((region) => region.rect),
1224 ...strokes.map((stroke) => stroke.bounds),1226 ...strokes.map((stroke) => stroke.bounds),
1225 ]);1227 ]);
1226 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect);1228 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission);
1227 });1229 });
1228 });1230 };
1229 comment.addEventListener("keydown", (event) => {1231 submit.addEventListener("click", () => submitAnnotation("attach"));
1230 if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;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;
1231 event.preventDefault();1238 event.preventDefault();
1232 submit.click();1239 submitAnnotation(submission);
1233 });1240 });
12341241
1235 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });1242 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
apps/web/src/components/ChatView.tsx+55-6
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 type ProjectScript,8 type ProjectScript,
9 type ProjectId,9 type ProjectId,
10 type ProviderApprovalDecision,10 type ProviderApprovalDecision,
11 type PreviewAnnotationPayload,
11 ProviderInstanceId,12 ProviderInstanceId,
12 type ServerProvider,13 type ServerProvider,
13 type ResolvedKeybindingsConfig,14 type ResolvedKeybindingsConfig,
...@@ -4561,8 +4562,24 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4561,8 +4562,24 @@ function ChatViewContent(props: ChatViewProps) {
4561 ],4562 ],
4562 );4563 );
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 ) => {
4565 e?.preventDefault();4572 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 };
4566 if (4583 if (
4567 !activeThread ||4584 !activeThread ||
4568 isSendBusy ||4585 isSendBusy ||
...@@ -4570,19 +4587,28 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4570,19 +4587,28 @@ function ChatViewContent(props: ChatViewProps) {
4570 threadDetailLoading ||4587 threadDetailLoading ||
4571 activeEnvironmentUnavailable ||4588 activeEnvironmentUnavailable ||
4572 sendInFlightRef.current4589 sendInFlightRef.current
4573 )4590 ) {
4591 notifyDirectAnnotationAttached();
4574 return;4592 return;
4593 }
4575 if (activePendingProgress) {4594 if (activePendingProgress) {
4595 if (directAnnotation) {
4596 notifyDirectAnnotationAttached();
4597 return;
4598 }
4576 onAdvanceActivePendingUserInput();4599 onAdvanceActivePendingUserInput();
4577 return;4600 return;
4578 }4601 }
4579 const sendCtx = composerRef.current?.getSendContext();4602 const sendCtx = composerRef.current?.getSendContext();
4580 if (!sendCtx?.providerAvailable) return;4603 if (!sendCtx?.providerAvailable) {
4604 notifyDirectAnnotationAttached();
4605 return;
4606 }
4581 const {4607 const {
4582 images: composerImages,4608 images: sendContextImages,
4583 terminalContexts: composerTerminalContexts,4609 terminalContexts: composerTerminalContexts,
4584 elementContexts: composerElementContexts,4610 elementContexts: composerElementContexts,
4585 previewAnnotations: composerPreviewAnnotations,4611 previewAnnotations: sendContextPreviewAnnotations,
4586 reviewComments: composerReviewComments,4612 reviewComments: composerReviewComments,
4587 selectedProvider: ctxSelectedProvider,4613 selectedProvider: ctxSelectedProvider,
4588 selectedModel: ctxSelectedModel,4614 selectedModel: ctxSelectedModel,
...@@ -4590,6 +4616,26 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4590,6 +4616,26 @@ function ChatViewContent(props: ChatViewProps) {
4590 selectedPromptEffort: ctxSelectedPromptEffort,4616 selectedPromptEffort: ctxSelectedPromptEffort,
4591 selectedModelSelection: ctxSelectedModelSelection,4617 selectedModelSelection: ctxSelectedModelSelection,
4592 } = sendCtx;4618 } = 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;
4593 const promptForSend = promptRef.current;4639 const promptForSend = promptRef.current;
4594 const {4640 const {
4595 trimmedPrompt: trimmed,4641 trimmedPrompt: trimmed,
...@@ -4605,7 +4651,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4605,7 +4651,7 @@ function ChatViewContent(props: ChatViewProps) {
4605 composerPreviewAnnotations.length +4651 composerPreviewAnnotations.length +
4606 composerReviewComments.length,4652 composerReviewComments.length,
4607 });4653 });
4608 if (showPlanFollowUpPrompt && activeProposedPlan) {4654 if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
4609 const followUp = resolvePlanFollowUpSubmission({4655 const followUp = resolvePlanFollowUpSubmission({
4610 draftText: trimmed,4656 draftText: trimmed,
4611 planMarkdown: activeProposedPlan.planMarkdown,4657 planMarkdown: activeProposedPlan.planMarkdown,
...@@ -5674,6 +5720,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5674,6 +5720,9 @@ function ChatViewContent(props: ChatViewProps) {
5674 tabId={activeRightPanelSurface.resourceId}5720 tabId={activeRightPanelSurface.resourceId}
5675 configuredUrls={configuredPreviewUrls}5721 configuredUrls={configuredPreviewUrls}
5676 visible5722 visible
5723 onSendAnnotation={(annotation, image) => {
5724 void onSend(undefined, { annotation, image });
5725 }}
5677 />5726 />
5678 </Suspense>5727 </Suspense>
5679 ) : activeRightPanelSurface?.kind === "terminal" ? (5728 ) : 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";...@@ -23,7 +23,6 @@ import { cn } from "~/lib/utils";
2323
24interface Props {24interface Props {
25 url: string;25 url: string;
26 displayUrl?: string | undefined;
27 loading: boolean;26 loading: boolean;
28 loadProgress: number;27 loadProgress: number;
29 canGoBack: boolean;28 canGoBack: boolean;
...@@ -65,7 +64,6 @@ const NOOP = () => {};...@@ -65,7 +64,6 @@ const NOOP = () => {};
6564
66export function PreviewChromeRow({65export function PreviewChromeRow({
67 url,66 url,
68 displayUrl,
69 loading,67 loading,
70 loadProgress,68 loadProgress,
71 canGoBack,69 canGoBack,
...@@ -172,7 +170,7 @@ export function PreviewChromeRow({...@@ -172,7 +170,7 @@ export function PreviewChromeRow({
172 render={170 render={
173 <InputGroupInput171 <InputGroupInput
174 ref={inputRef}172 ref={inputRef}
175 value={inputFocused ? draft : (displayUrl ?? url)}173 value={inputFocused ? draft : url}
176 className={cn(174 className={cn(
177 onOpenInBrowser &&175 onOpenInBrowser &&
178 !inputFocused &&176 !inputFocused &&
...@@ -203,7 +201,6 @@ export function PreviewChromeRow({...@@ -203,7 +201,6 @@ export function PreviewChromeRow({
203 />201 />
204 }202 }
205 />203 />
206 {!inputFocused && displayUrl ? <TooltipPopup>{url}</TooltipPopup> : null}
207 </Tooltip>204 </Tooltip>
208 {onOpenInBrowser && !inputFocused ? (205 {onOpenInBrowser && !inputFocused ? (
209 <InputGroupAddon206 <InputGroupAddon
apps/web/src/components/preview/PreviewPanel.tsx+15-2
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1"use client";1"use client";
22
3import type { ScopedThreadRef } from "@t3tools/contracts";3import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";
44
5import type { ComposerImageAttachment } from "~/composerDraftStore";
5import { isPreviewSupportedInRuntime } from "~/previewStateStore";6import { isPreviewSupportedInRuntime } from "~/previewStateStore";
67
7import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";8import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
...@@ -13,9 +14,20 @@ interface Props {...@@ -13,9 +14,20 @@ interface Props {
13 tabId?: string | null;14 tabId?: string | null;
14 configuredUrls?: ReadonlyArray<string> | undefined;15 configuredUrls?: ReadonlyArray<string> | undefined;
15 visible: boolean;16 visible: boolean;
17 onSendAnnotation?: (
18 annotation: PreviewAnnotationPayload,
19 image: ComposerImageAttachment | null,
20 ) => void;
16}21}
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) {
19 if (!isPreviewSupportedInRuntime()) {31 if (!isPreviewSupportedInRuntime()) {
20 return (32 return (
21 <PreviewPanelShell mode={mode}>33 <PreviewPanelShell mode={mode}>
...@@ -35,6 +47,7 @@ export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }...@@ -35,6 +47,7 @@ export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }
35 {...(tabId !== undefined ? { tabId } : {})}47 {...(tabId !== undefined ? { tabId } : {})}
36 configuredUrls={configuredUrls}48 configuredUrls={configuredUrls}
37 visible={visible}49 visible={visible}
50 {...(onSendAnnotation ? { onSendAnnotation } : {})}
38 />51 />
39 </PreviewPanelShell>52 </PreviewPanelShell>
40 );53 );
apps/web/src/components/preview/PreviewView.test.tsx+85-2
...@@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({...@@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({
17 closeRightPanel: vi.fn(),17 closeRightPanel: vi.fn(),
18 openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),18 openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
19 closePictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),19 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,
20 pictureInPicture: false,25 pictureInPicture: false,
21 showEmptyState: false,26 showEmptyState: false,
22}));27}));
...@@ -28,11 +33,15 @@ vi.mock("~/state/session", () => ({...@@ -28,11 +33,15 @@ vi.mock("~/state/session", () => ({
28vi.mock("~/composerDraftStore", () => ({33vi.mock("~/composerDraftStore", () => ({
29 useComposerDraftStore: (34 useComposerDraftStore: (
30 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,35 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 }),
32}));41}));
3342
34vi.mock("~/lib/previewAnnotation", () => ({43vi.mock("~/lib/previewAnnotation", () => ({
35 previewAnnotationScreenshotFile: vi.fn(),44 previewAnnotationScreenshotFile: mocks.previewAnnotationScreenshotFile,
36}));45}));
3746
38vi.mock("~/localApi", () => ({47vi.mock("~/localApi", () => ({
...@@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({...@@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({
144vi.mock("./previewBridge", () => ({153vi.mock("./previewBridge", () => ({
145 previewBridge: {154 previewBridge: {
146 navigate: mocks.navigate,155 navigate: mocks.navigate,
156 pickElement: mocks.pickElement,
147 pictureInPicture: {157 pictureInPicture: {
148 open: mocks.openPictureInPicture,158 open: mocks.openPictureInPicture,
149 close: mocks.closePictureInPicture,159 close: mocks.closePictureInPicture,
...@@ -154,6 +164,7 @@ vi.mock("./previewBridge", () => ({...@@ -154,6 +164,7 @@ vi.mock("./previewBridge", () => ({
154vi.mock("./PreviewChromeRow", () => ({164vi.mock("./PreviewChromeRow", () => ({
155 PreviewChromeRow: (props: {165 PreviewChromeRow: (props: {
156 onSubmit: (url: string) => void;166 onSubmit: (url: string) => void;
167 onPickElement?: () => void;
157 onPictureInPicture?: () => void;168 onPictureInPicture?: () => void;
158 pictureInPicture?: boolean;169 pictureInPicture?: boolean;
159 trailingActions?: {170 trailingActions?: {
...@@ -161,6 +172,7 @@ vi.mock("./PreviewChromeRow", () => ({...@@ -161,6 +172,7 @@ vi.mock("./PreviewChromeRow", () => ({
161 };172 };
162 }) => {173 }) => {
163 mocks.submittedUrl = props.onSubmit;174 mocks.submittedUrl = props.onSubmit;
175 mocks.toggleAnnotation = props.onPickElement ?? null;
164 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;176 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;
165 mocks.toggleNativePictureInPicture =177 mocks.toggleNativePictureInPicture =
166 props.trailingActions?.props.onNativePictureInPicture ?? null;178 props.trailingActions?.props.onNativePictureInPicture ?? null;
...@@ -213,6 +225,11 @@ describe("PreviewView navigation", () => {...@@ -213,6 +225,11 @@ describe("PreviewView navigation", () => {
213 mocks.closeRightPanel.mockClear();225 mocks.closeRightPanel.mockClear();
214 mocks.openPictureInPicture.mockClear();226 mocks.openPictureInPicture.mockClear();
215 mocks.closePictureInPicture.mockClear();227 mocks.closePictureInPicture.mockClear();
228 mocks.pickElement.mockReset();
229 mocks.previewAnnotationScreenshotFile.mockReset();
230 mocks.addPreviewAnnotation.mockClear();
231 mocks.addImage.mockClear();
232 mocks.toggleAnnotation = null;
216 mocks.pictureInPicture = false;233 mocks.pictureInPicture = false;
217 mocks.showEmptyState = false;234 mocks.showEmptyState = false;
218 });235 });
...@@ -327,4 +344,70 @@ describe("PreviewView navigation", () => {...@@ -327,4 +344,70 @@ describe("PreviewView navigation", () => {
327 expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),344 expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),
328 );345 );
329 });346 });
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 });
330});413});
apps/web/src/components/preview/PreviewView.tsx+41-29
...@@ -4,13 +4,14 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment";...@@ -4,13 +4,14 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {5import {
6 FILL_PREVIEW_VIEWPORT,6 FILL_PREVIEW_VIEWPORT,
7 type PreviewAnnotationPayload,
7 type PreviewViewportSetting,8 type PreviewViewportSetting,
8 type ScopedThreadRef,9 type ScopedThreadRef,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import { normalizePreviewUrl } from "@t3tools/shared/preview";11import { normalizePreviewUrl } from "@t3tools/shared/preview";
11import { useCallback, useEffect, useRef, useState } from "react";12import { useCallback, useEffect, useRef, useState } from "react";
1213
13import { useComposerDraftStore } from "~/composerDraftStore";14import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
14import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";15import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
15import { ensureLocalApi } from "~/localApi";16import { ensureLocalApi } from "~/localApi";
16import {17import {
...@@ -19,7 +20,6 @@ import {...@@ -19,7 +20,6 @@ import {
19 useThreadPreviewState,20 useThreadPreviewState,
20} from "~/previewStateStore";21} from "~/previewStateStore";
21import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";22import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
22import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";
23import { previewEnvironment } from "~/state/preview";23import { previewEnvironment } from "~/state/preview";
24import { useAtomCommand } from "~/state/use-atom-command";24import { useAtomCommand } from "~/state/use-atom-command";
25import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";25import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
...@@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge";...@@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge";
29import { subscribePreviewAction } from "./previewActionBus";29import { subscribePreviewAction } from "./previewActionBus";
30import { openPreviewSession } from "./openPreviewSession";30import { openPreviewSession } from "./openPreviewSession";
31import { PreviewChromeRow } from "./PreviewChromeRow";31import { PreviewChromeRow } from "./PreviewChromeRow";
32import { formatPreviewUrl } from "./previewUrlPresentation";
33import { PreviewEmptyState } from "./PreviewEmptyState";32import { PreviewEmptyState } from "./PreviewEmptyState";
34import { PreviewMoreMenu } from "./PreviewMoreMenu";33import { PreviewMoreMenu } from "./PreviewMoreMenu";
35import {34import {
...@@ -60,6 +59,10 @@ interface Props {...@@ -60,6 +59,10 @@ interface Props {
60 tabId?: string | null;59 tabId?: string | null;
61 configuredUrls?: ReadonlyArray<string> | undefined;60 configuredUrls?: ReadonlyArray<string> | undefined;
62 visible: boolean;61 visible: boolean;
62 onSendAnnotation?: (
63 annotation: PreviewAnnotationPayload,
64 image: ComposerImageAttachment | null,
65 ) => void;
63}66}
6467
65const localApi = typeof window === "undefined" ? null : ensureLocalApi();68const localApi = typeof window === "undefined" ? null : ensureLocalApi();
...@@ -68,7 +71,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();...@@ -68,7 +71,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
68 * Single-tab preview surface: chrome row on top, one webview below, empty71 * Single-tab preview surface: chrome row on top, one webview below, empty
69 * state when no session exists for the thread.72 * state when no session exists for the thread.
70 */73 */
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) {
72 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);81 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
73 const [pickActive, setPickActive] = useState(false);82 const [pickActive, setPickActive] = useState(false);
74 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();83 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
...@@ -80,8 +89,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -80,8 +89,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
80 );89 );
81 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);90 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
82 const addImage = useComposerDraftStore((store) => store.addImage);91 const addImage = useComposerDraftStore((store) => store.addImage);
83 const environment = useEnvironment(threadRef.environmentId);
84 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
85 const open = useAtomCommand(previewEnvironment.open);92 const open = useAtomCommand(previewEnvironment.open);
86 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");93 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
8794
...@@ -116,14 +123,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -116,14 +123,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
116 const showEmptyState = shouldShowPreviewEmptyState(snapshot);123 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
117 const controller = desktopOverlay?.controller ?? "none";124 const controller = desktopOverlay?.controller ?? "none";
118 const loadProgress = useLoadingProgress(loading);125 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;
127 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;126 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
128 const panelRect = useBrowserSurfaceStore((state) =>127 const panelRect = useBrowserSurfaceStore((state) =>
129 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,128 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
...@@ -523,20 +522,34 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -523,20 +522,34 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
523 setPickActive(true);522 setPickActive(true);
524 void (async () => {523 void (async () => {
525 try {524 try {
526 const annotation = await previewBridge.pickElement(runtimeTabId);525 const result = await previewBridge.pickElement(runtimeTabId);
527 if (!annotation) return;526 if (!result) return;
527 const { annotation, submission } = result;
528 addPreviewAnnotation(threadRef, annotation);528 addPreviewAnnotation(threadRef, annotation);
529 const screenshotFile = await previewAnnotationScreenshotFile(annotation);529 let screenshotFile: File | null = null;
530 if (screenshotFile && annotation.screenshot) {530 try {
531 addImage(threadRef, {531 screenshotFile = await previewAnnotationScreenshotFile(annotation);
532 type: "image",532 } catch {
533 id: annotation.id,533 // The structured annotation is still sendable when converting its
534 name: screenshotFile.name,534 // optional screenshot into a composer attachment fails.
535 mimeType: screenshotFile.type,535 }
536 sizeBytes: screenshotFile.size,536 const image =
537 previewUrl: annotation.screenshot.dataUrl,537 screenshotFile && annotation.screenshot
538 file: screenshotFile,538 ? ({
539 });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);
540 }553 }
541 } catch {554 } catch {
542 // Picker failed (e.g. webview navigated). Treat as silent cancel.555 // Picker failed (e.g. webview navigated). Treat as silent cancel.
...@@ -561,7 +574,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -561,7 +574,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
561 }574 }
562 }575 }
563 })();576 })();
564 }, [addImage, addPreviewAnnotation, runtimeTabId, threadRef]);577 }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]);
565578
566 // If the active tab changes mid-pick (close, thread switch, hot restart),579 // If the active tab changes mid-pick (close, thread switch, hot restart),
567 // tell main to tear down the in-flight session AND reset our local toggle580 // 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,...@@ -611,7 +624,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
611 >624 >
612 <PreviewChromeRow625 <PreviewChromeRow
613 url={url}626 url={url}
614 displayUrl={displayUrl}
615 loading={loading}627 loading={loading}
616 loadProgress={loadProgress}628 loadProgress={loadProgress}
617 canGoBack={canGoBack}629 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...@@ -896,6 +896,20 @@ export const PreviewAnnotationPayloadSchema: Schema.Codec<PreviewAnnotationPaylo
896 },896 },
897);897);
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
899export const DesktopPreviewTabInputSchema = Schema.Struct({913export const DesktopPreviewTabInputSchema = Schema.Struct({
900 tabId: DesktopPreviewTabIdSchema,914 tabId: DesktopPreviewTabIdSchema,
901});915});
...@@ -1062,10 +1076,11 @@ export interface DesktopPreviewBridge {...@@ -1062,10 +1076,11 @@ export interface DesktopPreviewBridge {
1062 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;1076 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
1063 /**1077 /**
1064 * Activate the in-page element picker for the given tab. Resolves with1078 * Activate the in-page element picker for the given tab. Resolves with
1065 * the picked payload, or `null` when the user cancels (Escape / nav). The1079 * the picked annotation and its attach/send intent, or `null` when the
1066 * promise rejects if the picker can't be activated (no webview, etc.).1080 * user cancels (Escape / nav). The promise rejects if the picker can't be
1081 * activated (no webview, etc.).
1067 */1082 */
1068 pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>;1083 pickElement: (tabId: string) => Promise<PreviewAnnotationSubmissionResult | null>;
1069 /** Cancel an in-flight preview annotation session. */1084 /** Cancel an in-flight preview annotation session. */
1070 cancelPickElement: (tabId: string) => Promise<void>;1085 cancelPickElement: (tabId: string) => Promise<void>;
1071 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;1086 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;