| 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,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({ |
| 227 | export const pickElement = DesktopIpc.makeIpcMethod({ | 227 | export 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 @@ | ||
| 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,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 | const { | 61 | const { |
| 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 | ]); |
| 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 | const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { | 417 | const 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"; |
| 15 | 16 | ||
| 17 | import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; | ||
| 16 | import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; | 18 | import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; |
| 17 | import { | 19 | import { |
| 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); |
| 428 | 430 | ||
| 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 | }; |
| 1184 | 1186 | ||
| 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 | }); |
| 1234 | 1241 | ||
| 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 | ); |
| 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 | 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.current | 4589 | 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 | visible | 5722 | 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 @@ | ||
| 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,7 +23,6 @@ import { cn } from "~/lib/utils"; |
| 23 | 23 | ||
| 24 | interface Props { | 24 | interface 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 = () => {}; |
| 65 | 64 | ||
| 66 | export function PreviewChromeRow({ | 65 | export 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 | <InputGroupInput | 171 | <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 | <InputGroupAddon | 206 | <InputGroupAddon |
apps/web/src/components/preview/PreviewPanel.tsx+15-2| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | "use client"; | 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 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | 6 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 6 | 7 | ||
| 7 | import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; | 8 | import { 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 | } |
| 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 | 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", () => ({ |
| 28 | vi.mock("~/composerDraftStore", () => ({ | 33 | vi.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 | })); |
| 33 | 42 | ||
| 34 | vi.mock("~/lib/previewAnnotation", () => ({ | 43 | vi.mock("~/lib/previewAnnotation", () => ({ |
| 35 | previewAnnotationScreenshotFile: vi.fn(), | 44 | previewAnnotationScreenshotFile: mocks.previewAnnotationScreenshotFile, |
| 36 | })); | 45 | })); |
| 37 | 46 | ||
| 38 | vi.mock("~/localApi", () => ({ | 47 | vi.mock("~/localApi", () => ({ |
| ... | @@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({ | ... | @@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({ |
| 144 | vi.mock("./previewBridge", () => ({ | 153 | vi.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", () => ({ |
| 154 | vi.mock("./PreviewChromeRow", () => ({ | 164 | vi.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"; |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | import { | 5 | import { |
| 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"; |
| 10 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 11 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 11 | import { useCallback, useEffect, useRef, useState } from "react"; | 12 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 12 | 13 | ||
| 13 | import { useComposerDraftStore } from "~/composerDraftStore"; | 14 | import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; |
| 14 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; | 15 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 15 | import { ensureLocalApi } from "~/localApi"; | 16 | import { ensureLocalApi } from "~/localApi"; |
| 16 | import { | 17 | import { |
| ... | @@ -19,7 +20,6 @@ import { | ... | @@ -19,7 +20,6 @@ import { |
| 19 | useThreadPreviewState, | 20 | useThreadPreviewState, |
| 20 | } from "~/previewStateStore"; | 21 | } from "~/previewStateStore"; |
| 21 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 22 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 22 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; | ||
| 23 | import { previewEnvironment } from "~/state/preview"; | 23 | import { previewEnvironment } from "~/state/preview"; |
| 24 | import { useAtomCommand } from "~/state/use-atom-command"; | 24 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | 25 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; |
| ... | @@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge"; | ... | @@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge"; |
| 29 | import { subscribePreviewAction } from "./previewActionBus"; | 29 | import { subscribePreviewAction } from "./previewActionBus"; |
| 30 | import { openPreviewSession } from "./openPreviewSession"; | 30 | import { openPreviewSession } from "./openPreviewSession"; |
| 31 | import { PreviewChromeRow } from "./PreviewChromeRow"; | 31 | import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 32 | import { formatPreviewUrl } from "./previewUrlPresentation"; | ||
| 33 | import { PreviewEmptyState } from "./PreviewEmptyState"; | 32 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 34 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | 33 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 35 | import { | 34 | import { |
| ... | @@ -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 | } |
| 64 | 67 | ||
| 65 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | 68 | const 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, empty | 71 | * 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 | */ |
| 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 | 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"); |
| 87 | 94 | ||
| ... | @@ -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]); |
| 565 | 578 | ||
| 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 toggle | 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,7 +624,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 611 | > | 624 | > |
| 612 | <PreviewChromeRow | 625 | <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 @@ | ||
| 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,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 | export const DesktopPreviewTabInputSchema = Schema.Struct({ | 913 | export 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 with | 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 | 1079 | * 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>; |