| author | |
| committer | |
| log | 94696b0f54f467062a14aa800ee257a4801c2022 |
| tree | 737a943814e4f005f502565423c96b3d4c03a704 |
| parent | fff6a5b028f85122ffef8d3636f390f95ade5172 |
| signature | Signed by PGP key B5690EEEBB952194 |
15 files changed, 347 insertions(+), 133 deletions(-)
apps/desktop/src/ipc/methods/preview.ts+2-2| ... | ... | @@ -16,7 +16,7 @@ import { |
| 16 | 16 | DesktopPreviewSetColorSchemeInputSchema, |
| 17 | 17 | DesktopPreviewTabInputSchema, |
| 18 | 18 | DesktopPreviewWebviewConfigSchema, |
| 19 | PreviewAnnotationPayloadSchema, | |
| 19 | PreviewAnnotationSubmissionResultSchema, | |
| 20 | 20 | PreviewAutomationSnapshot, |
| 21 | 21 | PreviewAutomationStatus, |
| 22 | 22 | } from "@t3tools/contracts"; |
| ... | ... | @@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ |
| 227 | 227 | export const pickElement = DesktopIpc.makeIpcMethod({ |
| 228 | 228 | channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, |
| 229 | 229 | payload: DesktopPreviewTabInputSchema, |
| 230 | result: Schema.NullOr(PreviewAnnotationPayloadSchema), | |
| 230 | result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), | |
| 231 | 231 | handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { |
| 232 | 232 | const manager = yield* PreviewManager.PreviewManager; |
| 233 | 233 | return yield* manager.pickElement(tabId); |
apps/desktop/src/preview/AnnotationKeyboard.test.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; | |
| 4 | ||
| 5 | const 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 | ||
| 16 | describe("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 @@ |
| 1 | import type { PreviewAnnotationSubmission } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface AnnotationKeyboardEvent { | |
| 4 | readonly key: string; | |
| 5 | readonly metaKey: boolean; | |
| 6 | readonly ctrlKey: boolean; | |
| 7 | readonly shiftKey: boolean; | |
| 8 | readonly isComposing: boolean; | |
| 9 | } | |
| 10 | ||
| 11 | export 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 | 36 | }); |
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | describe("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 | ||
| 39 | 61 | const { |
| 40 | 62 | browserWindowConstructor, |
| 41 | 63 | createFromPath, |
apps/desktop/src/preview/Manager.ts+25-6| ... | ... | @@ -11,6 +11,7 @@ import type { |
| 11 | 11 | DesktopPreviewPointerEvent, |
| 12 | 12 | PreviewAnnotationPayload, |
| 13 | 13 | PreviewAnnotationRect, |
| 14 | PreviewAnnotationSubmissionResult, | |
| 14 | 15 | DesktopPreviewRecordingArtifact, |
| 15 | 16 | DesktopPreviewRecordingFrame, |
| 16 | 17 | DesktopPreviewScreenshotArtifact, |
| ... | ... | @@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 406 | 407 | { key: "w", meta: true, shift: false, control: false }, |
| 407 | 408 | ]); |
| 408 | 409 | |
| 410 | export 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 | ||
| 409 | 417 | const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { |
| 410 | 418 | if (typeof value !== "object" || value === null || !("kind" in value)) return false; |
| 411 | 419 | if (value.kind === "pointer") { |
| ... | ... | @@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1365 | 1373 | }); |
| 1366 | 1374 | }); |
| 1367 | 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 | 1385 | runFork(forwardShortcut(event, input)); |
| 1369 | 1386 | }; |
| 1370 | 1387 | yield* Scope.addFinalizer( |
| ... | ... | @@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1792 | 1809 | const wc = yield* requireWebContents(tabId); |
| 1793 | 1810 | yield* cancelPickElement(tabId); |
| 1794 | 1811 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1795 | return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( | |
| 1812 | return yield* Effect.callback<PreviewAnnotationSubmissionResult | null, PreviewManagerError>( | |
| 1796 | 1813 | (resume) => { |
| 1797 | 1814 | const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { |
| 1798 | 1815 | yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { |
| ... | ... | @@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1807 | 1824 | ); |
| 1808 | 1825 | }); |
| 1809 | 1826 | const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( |
| 1810 | payload: PreviewAnnotationPayload | null, | |
| 1827 | payload: PreviewAnnotationSubmissionResult | null, | |
| 1811 | 1828 | ) { |
| 1812 | 1829 | const active = (yield* Ref.get(pickSessionsRef)).get(tabId); |
| 1813 | 1830 | if (!active || active.cancel !== cancel) return; |
| 1814 | 1831 | yield* cleanup(); |
| 1815 | 1832 | resume(Effect.succeed(payload)); |
| 1816 | 1833 | }); |
| 1817 | const settle = (payload: PreviewAnnotationPayload | null) => { | |
| 1834 | const settle = (payload: PreviewAnnotationSubmissionResult | null) => { | |
| 1818 | 1835 | runFork(settlePick(payload)); |
| 1819 | 1836 | }; |
| 1820 | 1837 | const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { |
| ... | ... | @@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1844 | 1861 | return; |
| 1845 | 1862 | } |
| 1846 | 1863 | const cropRect = normalizeCaptureRect(args[1]); |
| 1864 | const submission = args[2] === "send" ? "send" : "attach"; | |
| 1847 | 1865 | runFork( |
| 1848 | 1866 | captureAnnotationScreenshot(tabId, wc, cropRect).pipe( |
| 1849 | 1867 | 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 })), | |
| 1852 | 1871 | }), |
| 1853 | 1872 | Effect.ensuring( |
| 1854 | 1873 | attempt( |
| ... | ... | @@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service< |
| 3586 | 3605 | ) => Effect.Effect<void, PreviewManagerError>; |
| 3587 | 3606 | readonly pickElement: ( |
| 3588 | 3607 | tabId: string, |
| 3589 | ) => Effect.Effect<PreviewAnnotationPayload | null, PreviewManagerError>; | |
| 3608 | ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>; | |
| 3590 | 3609 | readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3591 | 3610 | readonly captureScreenshot: ( |
| 3592 | 3611 | tabId: string, |
apps/desktop/src/preview/PickPreload.ts+14-7| ... | ... | @@ -11,8 +11,10 @@ import type { |
| 11 | 11 | PreviewAnnotationRegionTarget, |
| 12 | 12 | PreviewAnnotationStrokeTarget, |
| 13 | 13 | PreviewAnnotationStyleChange, |
| 14 | PreviewAnnotationSubmission, | |
| 14 | 15 | } from "@t3tools/contracts"; |
| 15 | 16 | |
| 17 | import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; | |
| 16 | 18 | import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; |
| 17 | 19 | import { |
| 18 | 20 | ANNOTATION_CAPTURED_CHANNEL, |
| ... | ... | @@ -426,7 +428,7 @@ function startAnnotation(): void { |
| 426 | 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 | 429 | composerRow.appendChild(dragHandle); |
| 428 | 430 | |
| 429 | const submit = createButton("Attach", "Attach annotation and screenshot"); | |
| 431 | const submit = createButton("Attach", "Attach annotation and screenshot (Enter)"); | |
| 430 | 432 | submit.className += |
| 431 | 433 | " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; |
| 432 | 434 | composerRow.appendChild(submit); |
| ... | ... | @@ -1182,7 +1184,7 @@ function startAnnotation(): void { |
| 1182 | 1184 | refreshToolButtons(); |
| 1183 | 1185 | }; |
| 1184 | 1186 | |
| 1185 | submit.addEventListener("click", () => { | |
| 1187 | const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { | |
| 1186 | 1188 | if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) |
| 1187 | 1189 | return; |
| 1188 | 1190 | pendingCapture = true; |
| ... | ... | @@ -1223,13 +1225,18 @@ function startAnnotation(): void { |
| 1223 | 1225 | ...regions.map((region) => region.rect), |
| 1224 | 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 | }); | |
| 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; | |
| 1231 | 1238 | event.preventDefault(); |
| 1232 | submit.click(); | |
| 1239 | submitAnnotation(submission); | |
| 1233 | 1240 | }); |
| 1234 | 1241 | |
| 1235 | 1242 | window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); |
apps/web/src/components/ChatView.tsx+55-6| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | type ProjectScript, |
| 9 | 9 | type ProjectId, |
| 10 | 10 | type ProviderApprovalDecision, |
| 11 | type PreviewAnnotationPayload, | |
| 11 | 12 | ProviderInstanceId, |
| 12 | 13 | type ServerProvider, |
| 13 | 14 | type ResolvedKeybindingsConfig, |
| ... | ... | @@ -4561,8 +4562,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 4561 | 4562 | ], |
| 4562 | 4563 | ); |
| 4563 | 4564 | |
| 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 | 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 | 4583 | if ( |
| 4567 | 4584 | !activeThread || |
| 4568 | 4585 | isSendBusy || |
| ... | ... | @@ -4570,19 +4587,28 @@ function ChatViewContent(props: ChatViewProps) { |
| 4570 | 4587 | threadDetailLoading || |
| 4571 | 4588 | activeEnvironmentUnavailable || |
| 4572 | 4589 | sendInFlightRef.current |
| 4573 | ) | |
| 4590 | ) { | |
| 4591 | notifyDirectAnnotationAttached(); | |
| 4574 | 4592 | return; |
| 4593 | } | |
| 4575 | 4594 | if (activePendingProgress) { |
| 4595 | if (directAnnotation) { | |
| 4596 | notifyDirectAnnotationAttached(); | |
| 4597 | return; | |
| 4598 | } | |
| 4576 | 4599 | onAdvanceActivePendingUserInput(); |
| 4577 | 4600 | return; |
| 4578 | 4601 | } |
| 4579 | 4602 | const sendCtx = composerRef.current?.getSendContext(); |
| 4580 | if (!sendCtx?.providerAvailable) return; | |
| 4603 | if (!sendCtx?.providerAvailable) { | |
| 4604 | notifyDirectAnnotationAttached(); | |
| 4605 | return; | |
| 4606 | } | |
| 4581 | 4607 | const { |
| 4582 | images: composerImages, | |
| 4608 | images: sendContextImages, | |
| 4583 | 4609 | terminalContexts: composerTerminalContexts, |
| 4584 | 4610 | elementContexts: composerElementContexts, |
| 4585 | previewAnnotations: composerPreviewAnnotations, | |
| 4611 | previewAnnotations: sendContextPreviewAnnotations, | |
| 4586 | 4612 | reviewComments: composerReviewComments, |
| 4587 | 4613 | selectedProvider: ctxSelectedProvider, |
| 4588 | 4614 | selectedModel: ctxSelectedModel, |
| ... | ... | @@ -4590,6 +4616,26 @@ function ChatViewContent(props: ChatViewProps) { |
| 4590 | 4616 | selectedPromptEffort: ctxSelectedPromptEffort, |
| 4591 | 4617 | selectedModelSelection: ctxSelectedModelSelection, |
| 4592 | 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 | 4639 | const promptForSend = promptRef.current; |
| 4594 | 4640 | const { |
| 4595 | 4641 | trimmedPrompt: trimmed, |
| ... | ... | @@ -4605,7 +4651,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4605 | 4651 | composerPreviewAnnotations.length + |
| 4606 | 4652 | composerReviewComments.length, |
| 4607 | 4653 | }); |
| 4608 | if (showPlanFollowUpPrompt && activeProposedPlan) { | |
| 4654 | if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { | |
| 4609 | 4655 | const followUp = resolvePlanFollowUpSubmission({ |
| 4610 | 4656 | draftText: trimmed, |
| 4611 | 4657 | planMarkdown: activeProposedPlan.planMarkdown, |
| ... | ... | @@ -5674,6 +5720,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 5674 | 5720 | tabId={activeRightPanelSurface.resourceId} |
| 5675 | 5721 | configuredUrls={configuredPreviewUrls} |
| 5676 | 5722 | visible |
| 5723 | onSendAnnotation={(annotation, image) => { | |
| 5724 | void onSend(undefined, { annotation, image }); | |
| 5725 | }} | |
| 5677 | 5726 | /> |
| 5678 | 5727 | </Suspense> |
| 5679 | 5728 | ) : activeRightPanelSurface?.kind === "terminal" ? ( |
apps/web/src/components/preview/PreviewChromeRow.test.tsx created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { PreviewChromeRow } from "./PreviewChromeRow"; | |
| 5 | ||
| 6 | describe("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 | 23 | |
| 24 | 24 | interface Props { |
| 25 | 25 | url: string; |
| 26 | displayUrl?: string | undefined; | |
| 27 | 26 | loading: boolean; |
| 28 | 27 | loadProgress: number; |
| 29 | 28 | canGoBack: boolean; |
| ... | ... | @@ -65,7 +64,6 @@ const NOOP = () => {}; |
| 65 | 64 | |
| 66 | 65 | export function PreviewChromeRow({ |
| 67 | 66 | url, |
| 68 | displayUrl, | |
| 69 | 67 | loading, |
| 70 | 68 | loadProgress, |
| 71 | 69 | canGoBack, |
| ... | ... | @@ -172,7 +170,7 @@ export function PreviewChromeRow({ |
| 172 | 170 | render={ |
| 173 | 171 | <InputGroupInput |
| 174 | 172 | ref={inputRef} |
| 175 | value={inputFocused ? draft : (displayUrl ?? url)} | |
| 173 | value={inputFocused ? draft : url} | |
| 176 | 174 | className={cn( |
| 177 | 175 | onOpenInBrowser && |
| 178 | 176 | !inputFocused && |
| ... | ... | @@ -203,7 +201,6 @@ export function PreviewChromeRow({ |
| 203 | 201 | /> |
| 204 | 202 | } |
| 205 | 203 | /> |
| 206 | {!inputFocused && displayUrl ? <TooltipPopup>{url}</TooltipPopup> : null} | |
| 207 | 204 | </Tooltip> |
| 208 | 205 | {onOpenInBrowser && !inputFocused ? ( |
| 209 | 206 | <InputGroupAddon |
apps/web/src/components/preview/PreviewPanel.tsx+15-2| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | import type { ComposerImageAttachment } from "~/composerDraftStore"; | |
| 5 | 6 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 6 | 7 | |
| 7 | 8 | import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; |
| ... | ... | @@ -13,9 +14,20 @@ interface Props { |
| 13 | 14 | tabId?: string | null; |
| 14 | 15 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 15 | 16 | visible: boolean; |
| 17 | onSendAnnotation?: ( | |
| 18 | annotation: PreviewAnnotationPayload, | |
| 19 | image: ComposerImageAttachment | null, | |
| 20 | ) => void; | |
| 16 | 21 | } |
| 17 | 22 | |
| 18 | export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) { | |
| 23 | export function PreviewPanel({ | |
| 24 | mode, | |
| 25 | threadRef, | |
| 26 | tabId, | |
| 27 | configuredUrls, | |
| 28 | visible, | |
| 29 | onSendAnnotation, | |
| 30 | }: Props) { | |
| 19 | 31 | if (!isPreviewSupportedInRuntime()) { |
| 20 | 32 | return ( |
| 21 | 33 | <PreviewPanelShell mode={mode}> |
| ... | ... | @@ -35,6 +47,7 @@ export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible } |
| 35 | 47 | {...(tabId !== undefined ? { tabId } : {})} |
| 36 | 48 | configuredUrls={configuredUrls} |
| 37 | 49 | visible={visible} |
| 50 | {...(onSendAnnotation ? { onSendAnnotation } : {})} | |
| 38 | 51 | /> |
| 39 | 52 | </PreviewPanelShell> |
| 40 | 53 | ); |
apps/web/src/components/preview/PreviewView.test.tsx+85-2| ... | ... | @@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({ |
| 17 | 17 | closeRightPanel: vi.fn(), |
| 18 | 18 | openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined), |
| 19 | 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 | 25 | pictureInPicture: false, |
| 21 | 26 | showEmptyState: false, |
| 22 | 27 | })); |
| ... | ... | @@ -28,11 +33,15 @@ vi.mock("~/state/session", () => ({ |
| 28 | 33 | vi.mock("~/composerDraftStore", () => ({ |
| 29 | 34 | useComposerDraftStore: ( |
| 30 | 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 | })); |
| 33 | 42 | |
| 34 | 43 | vi.mock("~/lib/previewAnnotation", () => ({ |
| 35 | previewAnnotationScreenshotFile: vi.fn(), | |
| 44 | previewAnnotationScreenshotFile: mocks.previewAnnotationScreenshotFile, | |
| 36 | 45 | })); |
| 37 | 46 | |
| 38 | 47 | vi.mock("~/localApi", () => ({ |
| ... | ... | @@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({ |
| 144 | 153 | vi.mock("./previewBridge", () => ({ |
| 145 | 154 | previewBridge: { |
| 146 | 155 | navigate: mocks.navigate, |
| 156 | pickElement: mocks.pickElement, | |
| 147 | 157 | pictureInPicture: { |
| 148 | 158 | open: mocks.openPictureInPicture, |
| 149 | 159 | close: mocks.closePictureInPicture, |
| ... | ... | @@ -154,6 +164,7 @@ vi.mock("./previewBridge", () => ({ |
| 154 | 164 | vi.mock("./PreviewChromeRow", () => ({ |
| 155 | 165 | PreviewChromeRow: (props: { |
| 156 | 166 | onSubmit: (url: string) => void; |
| 167 | onPickElement?: () => void; | |
| 157 | 168 | onPictureInPicture?: () => void; |
| 158 | 169 | pictureInPicture?: boolean; |
| 159 | 170 | trailingActions?: { |
| ... | ... | @@ -161,6 +172,7 @@ vi.mock("./PreviewChromeRow", () => ({ |
| 161 | 172 | }; |
| 162 | 173 | }) => { |
| 163 | 174 | mocks.submittedUrl = props.onSubmit; |
| 175 | mocks.toggleAnnotation = props.onPickElement ?? null; | |
| 164 | 176 | mocks.togglePictureInPicture = props.onPictureInPicture ?? null; |
| 165 | 177 | mocks.toggleNativePictureInPicture = |
| 166 | 178 | props.trailingActions?.props.onNativePictureInPicture ?? null; |
| ... | ... | @@ -213,6 +225,11 @@ describe("PreviewView navigation", () => { |
| 213 | 225 | mocks.closeRightPanel.mockClear(); |
| 214 | 226 | mocks.openPictureInPicture.mockClear(); |
| 215 | 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 | 233 | mocks.pictureInPicture = false; |
| 217 | 234 | mocks.showEmptyState = false; |
| 218 | 235 | }); |
| ... | ... | @@ -327,4 +344,70 @@ describe("PreviewView navigation", () => { |
| 327 | 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 | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | 5 | import { |
| 6 | 6 | FILL_PREVIEW_VIEWPORT, |
| 7 | type PreviewAnnotationPayload, | |
| 7 | 8 | type PreviewViewportSetting, |
| 8 | 9 | type ScopedThreadRef, |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 11 | 12 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 12 | 13 | |
| 13 | import { useComposerDraftStore } from "~/composerDraftStore"; | |
| 14 | import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; | |
| 14 | 15 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 15 | 16 | import { ensureLocalApi } from "~/localApi"; |
| 16 | 17 | import { |
| ... | ... | @@ -19,7 +20,6 @@ import { |
| 19 | 20 | useThreadPreviewState, |
| 20 | 21 | } from "~/previewStateStore"; |
| 21 | 22 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 22 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; | |
| 23 | 23 | import { previewEnvironment } from "~/state/preview"; |
| 24 | 24 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | 25 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; |
| ... | ... | @@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge"; |
| 29 | 29 | import { subscribePreviewAction } from "./previewActionBus"; |
| 30 | 30 | import { openPreviewSession } from "./openPreviewSession"; |
| 31 | 31 | import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 32 | import { formatPreviewUrl } from "./previewUrlPresentation"; | |
| 33 | 32 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 34 | 33 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 35 | 34 | import { |
| ... | ... | @@ -60,6 +59,10 @@ interface Props { |
| 60 | 59 | tabId?: string | null; |
| 61 | 60 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 62 | 61 | visible: boolean; |
| 62 | onSendAnnotation?: ( | |
| 63 | annotation: PreviewAnnotationPayload, | |
| 64 | image: ComposerImageAttachment | null, | |
| 65 | ) => void; | |
| 63 | 66 | } |
| 64 | 67 | |
| 65 | 68 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| ... | ... | @@ -68,7 +71,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 68 | 71 | * Single-tab preview surface: chrome row on top, one webview below, empty |
| 69 | 72 | * state when no session exists for the thread. |
| 70 | 73 | */ |
| 71 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { | |
| 74 | export function PreviewView({ | |
| 75 | threadRef, | |
| 76 | tabId: requestedTabId, | |
| 77 | configuredUrls, | |
| 78 | visible, | |
| 79 | onSendAnnotation, | |
| 80 | }: Props) { | |
| 72 | 81 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); |
| 73 | 82 | const [pickActive, setPickActive] = useState(false); |
| 74 | 83 | const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); |
| ... | ... | @@ -80,8 +89,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 80 | 89 | ); |
| 81 | 90 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 82 | 91 | const addImage = useComposerDraftStore((store) => store.addImage); |
| 83 | const environment = useEnvironment(threadRef.environmentId); | |
| 84 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); | |
| 85 | 92 | const open = useAtomCommand(previewEnvironment.open); |
| 86 | 93 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); |
| 87 | 94 | |
| ... | ... | @@ -116,14 +123,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 116 | 123 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); |
| 117 | 124 | const controller = desktopOverlay?.controller ?? "none"; |
| 118 | 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 | 126 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; |
| 128 | 127 | const panelRect = useBrowserSurfaceStore((state) => |
| 129 | 128 | runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, |
| ... | ... | @@ -523,20 +522,34 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 523 | 522 | setPickActive(true); |
| 524 | 523 | void (async () => { |
| 525 | 524 | 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; | |
| 528 | 528 | 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); | |
| 540 | 553 | } |
| 541 | 554 | } catch { |
| 542 | 555 | // Picker failed (e.g. webview navigated). Treat as silent cancel. |
| ... | ... | @@ -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]); | |
| 565 | 578 | |
| 566 | 579 | // If the active tab changes mid-pick (close, thread switch, hot restart), |
| 567 | 580 | // 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 | 624 | > |
| 612 | 625 | <PreviewChromeRow |
| 613 | 626 | url={url} |
| 614 | displayUrl={displayUrl} | |
| 615 | 627 | loading={loading} |
| 616 | 628 | loadProgress={loadProgress} |
| 617 | 629 | canGoBack={canGoBack} |
apps/web/src/components/preview/previewUrlPresentation.test.ts deleted-45| ... | ... | @@ -1,45 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { formatPreviewUrl } from "./previewUrlPresentation"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | interface PreviewUrlPresentationInput { | |
| 2 | readonly url: string; | |
| 3 | readonly environmentLabel: string; | |
| 4 | readonly environmentHttpBaseUrl: string; | |
| 5 | } | |
| 6 | ||
| 7 | export 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 | 896 | }, |
| 897 | 897 | ); |
| 898 | 898 | |
| 899 | export type PreviewAnnotationSubmission = "attach" | "send"; | |
| 900 | export const PreviewAnnotationSubmissionSchema: Schema.Codec<PreviewAnnotationSubmission> = | |
| 901 | Schema.Literals(["attach", "send"]); | |
| 902 | ||
| 903 | export interface PreviewAnnotationSubmissionResult { | |
| 904 | annotation: PreviewAnnotationPayload; | |
| 905 | submission: PreviewAnnotationSubmission; | |
| 906 | } | |
| 907 | export const PreviewAnnotationSubmissionResultSchema: Schema.Codec<PreviewAnnotationSubmissionResult> = | |
| 908 | Schema.Struct({ | |
| 909 | annotation: PreviewAnnotationPayloadSchema, | |
| 910 | submission: PreviewAnnotationSubmissionSchema, | |
| 911 | }); | |
| 912 | ||
| 899 | 913 | export const DesktopPreviewTabInputSchema = Schema.Struct({ |
| 900 | 914 | tabId: DesktopPreviewTabIdSchema, |
| 901 | 915 | }); |
| ... | ... | @@ -1062,10 +1076,11 @@ export interface DesktopPreviewBridge { |
| 1062 | 1076 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; |
| 1063 | 1077 | /** |
| 1064 | 1078 | * 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.). | |
| 1067 | 1082 | */ |
| 1068 | pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; | |
| 1083 | pickElement: (tabId: string) => Promise<PreviewAnnotationSubmissionResult | null>; | |
| 1069 | 1084 | /** Cancel an in-flight preview annotation session. */ |
| 1070 | 1085 | cancelPickElement: (tabId: string) => Promise<void>; |
| 1071 | 1086 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; |