From f572ba34bcaefc22eb03285f74993b8fc6516aac Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:38:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(browser):=20address-bar=20convergence=20?= =?UTF-8?q?=E2=80=94=20previews=20and=20browser=20tabs=20convert=20in=20pl?= =?UTF-8?q?ace=20(STA-5681)=20(#16998)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/reliability-gates.jsonc | 15 + ...ofile-project-session-field-disposition.ts | 1 + .../ClientHostedBrowserPagePane.tsx | 53 +-- .../assemble-chrome/BrowserAddressBar.tsx | 69 ++- .../browser-address-bar-suggestions.test.ts | 50 +++ .../browser-address-bar-suggestions.ts | 62 ++- .../browser-page-chrome-header.tsx | 2 + .../assemble-chrome/browser-page-toolbar.tsx | 46 +- .../use-browser-address-bar-dismissal.ts | 45 ++ .../use-browser-page-navigation-downloads.ts | 10 + .../use-client-hosted-page-url-submission.ts | 79 ++++ .../workspace-doc-address-submission.ts | 39 ++ .../remote-browser-page-pane.tsx | 4 + .../remote-browser-page-toolbar.tsx | 5 + .../use-remote-browser-page-navigation.ts | 15 + .../HtmlDocPreview.failure-message.test.tsx | 2 + .../HtmlDocPreview.toolbar.test.tsx | 85 +++- .../workspace-doc/HtmlDocPreview.tsx | 151 +++---- .../doc-preview-address-edit.tsx | 148 +++++++ .../doc-preview-document-chip.tsx | 20 +- .../workspace-doc/doc-preview-toolbar.tsx | 11 +- .../doc-preview-webview-attach.ts | 79 ++++ .../workspace-doc/workspace-doc-page-pane.tsx | 2 + src/renderer/src/i18n/locales/en.json | 3 +- .../browser-page-conversion-history.test.ts | 83 ++++ .../lib/browser-page-conversion-history.ts | 50 +++ src/renderer/src/lib/file-preview.test.ts | 107 +++++ src/renderer/src/lib/file-preview.ts | 81 ++++ .../lib/workspace-doc-address-input.test.ts | 147 +++++++ .../src/lib/workspace-doc-address-input.ts | 110 +++++ .../workspace-session-browser-history.test.ts | 1 + .../workspace-session-editor-drafts.test.ts | 1 + .../workspace-session-host-field-ownership.ts | 1 + .../lib/workspace-session-liveness.test.ts | 1 + .../src/lib/workspace-session-patch.ts | 4 + .../workspace-session-relevant-fields.test.ts | 1 + src/renderer/src/lib/workspace-session.ts | 4 + ...ient-hosted-browser-close-intent-replay.ts | 8 +- .../client-hosted-browser-close-intents.ts | 24 +- ...c-runtime-graph-conversion-publish.test.ts | 181 ++++++++ ...rowser-page-close-intent-recording.test.ts | 145 +++++++ .../slices/browser-page-conversion.test.ts | 404 +++++++++++++++++ .../store/slices/browser-page-conversion.ts | 137 ++++++ .../src/store/slices/browser-page-records.ts | 206 +++++++++ .../browser-workspace-doc-location.test.ts | 54 +++ src/renderer/src/store/slices/browser.ts | 410 +++++++++--------- src/shared/browser-url.ts | 9 + src/shared/browser-workspace-types.ts | 19 + src/shared/workspace-doc-history.test.ts | 56 +++ src/shared/workspace-doc-history.ts | 58 +++ .../workspace-session-browser-schema.ts | 37 +- ...pace-session-schema-field-coverage.test.ts | 1 + src/shared/workspace-session-schema.ts | 2 + src/shared/workspace-session-state-types.ts | 3 + ...ed-preview-address-bar-convergence.spec.ts | 386 +++++++++++++++++ ...d-remote-html-preview-local-render.spec.ts | 5 +- 56 files changed, 3323 insertions(+), 409 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts create mode 100644 src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts create mode 100644 src/renderer/src/components/browser-pane/navigate/workspace-doc-address-submission.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-address-edit.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.ts create mode 100644 src/renderer/src/lib/browser-page-conversion-history.test.ts create mode 100644 src/renderer/src/lib/browser-page-conversion-history.ts create mode 100644 src/renderer/src/lib/workspace-doc-address-input.test.ts create mode 100644 src/renderer/src/lib/workspace-doc-address-input.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts create mode 100644 src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts create mode 100644 src/renderer/src/store/slices/browser-page-conversion.test.ts create mode 100644 src/renderer/src/store/slices/browser-page-conversion.ts create mode 100644 src/renderer/src/store/slices/browser-page-records.ts create mode 100644 src/shared/workspace-doc-history.test.ts create mode 100644 src/shared/workspace-doc-history.ts create mode 100644 tests/e2e/paired-preview-address-bar-convergence.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f06a92c309e..f8372378a88 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2864,6 +2864,15 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + // STA-5681 address-bar convergence: conversion is page replacement (fresh id, one store + // commit flips page + mirror + mobile observables), typed workspace paths convert via the + // lexical-containment classifier, and doc history carries document identities with no url + // field at all. The chip now answers to "Edit address". + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-page-conversion.test.ts src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts src/renderer/src/lib/workspace-doc-address-input.test.ts src/shared/workspace-doc-history.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts", + // The convergence journey with real clicks and typing: chip → address bar → URL converts in + // place, Back returns across the conversion, a typed path converts a runtime-owned tab, the + // dropdown's doc row dedupe-activates. Caught the remote-pane routing gap before it shipped. + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-preview-address-bar-convergence.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=list", "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", // The changed-spec e2e job's own command, which is the only Linux run this gate has. @@ -2919,7 +2928,13 @@ "src/renderer/src/store/slices/tabs-hydration.test.ts", "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx", "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "src/renderer/src/store/slices/browser-page-conversion.test.ts", + "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts", + "src/renderer/src/lib/workspace-doc-address-input.test.ts", + "src/shared/workspace-doc-history.test.ts", + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts", "tests/e2e/paired-remote-html-preview-local-render.spec.ts", + "tests/e2e/paired-preview-address-bar-convergence.spec.ts", "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 994a65af68a..e1831ff3443 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -91,6 +91,7 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { }, activeTabTypeByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByOwnerKey' }, browserUrlHistory: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, + workspaceDocHistory: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, activeTabIdByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByOwnerKey' }, unifiedTabs: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByBespokeRule' }, tabGroups: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByBespokeRule' }, diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index 928178704f2..f4f698f516b 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import type { BrowserLoadError, BrowserPage as BrowserPageState } from '../../../../shared/browser-workspace-types' -import { redactKagiSessionToken, toHttpsRecoveryUrl } from '../../../../shared/browser-url' +import { toHttpsRecoveryUrl } from '../../../../shared/browser-url' import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' import { readBrowserClientPageGuestMetadata } from './browser-client-page-guest-metadata' import { @@ -33,14 +33,12 @@ import { useBrowserPageWebviewShortcuts } from './host-guest/use-browser-page-we import { useClientHostedGuestActivationFocus } from './host-guest/use-client-hosted-guest-activation-focus' import { useBrowserPageZoomFeedback } from './host-guest/use-browser-page-zoom-feedback' import { BrowserLoadFailureOverlay } from './navigate/browser-load-failure-overlay' -import { resolveBrowserAddressBarSubmission } from './navigate/browser-address-bar-navigation' +import { useClientHostedPageUrlSubmission } from './navigate/use-client-hosted-page-url-submission' +import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' import { useBrowserPageReloadActions } from './navigate/use-browser-page-reload-actions' import { resolveBrowserWebviewLoadFailure } from './navigate/browser-webview-load-failure' import { resolveActiveBrowserLoadFailure } from './navigate/browser-load-failure-for-url' -import { - consumeBrowserPageDeferredNavigation, - deferBrowserPageNavigation -} from './navigate/browser-page-deferred-navigation' +import { consumeBrowserPageDeferredNavigation } from './navigate/browser-page-deferred-navigation' import { getBrowserDisplayTitle, getOpenableExternalUrl, @@ -164,36 +162,14 @@ export function ClientHostedBrowserPagePane({ reloadWebviewOrRecoverGuest: reload.reloadWebviewOrRecoverGuest }) - const navigateToUrl = useCallback( - (value: string) => { - const submission = resolveBrowserAddressBarSubmission(value, { allowFileUrls: false }) - if (submission.status === 'invalid') { - onUpdatePageState(browserTab.id, { loadError: submission.loadError }) - return - } - const webview = webviewRef.current - if (!webview) { - // Why: the page is still an optimistic stage, so park the URL for the attach effect to - // replay rather than dropping what the user just typed. - deferBrowserPageNavigation(browserTab.id, submission.url) - setAddressBarValue(toDisplayUrl(redactKagiSessionToken(submission.url))) - return - } - // Why: the store and the address bar must never hold a Kagi session token, and an optimistic - // title keeps the tab from reading "New Tab" until the guest reports one — as local does. - const browserModelUrl = redactKagiSessionToken(submission.url) - activeLoadFailureRef.current = null - setAddressBarValue(toDisplayUrl(browserModelUrl)) - onUpdatePageState(browserTab.id, { - loading: true, - loadError: null, - title: getBrowserDisplayTitle(browserModelUrl, browserModelUrl) - }) - // Why: loadURL rejects on any failed navigation; did-fail-load owns error reporting. - void webview.loadURL(submission.url).catch(() => {}) - }, - [browserTab.id, onUpdatePageState, setAddressBarValue] - ) + const navigateToUrl = useClientHostedPageUrlSubmission({ + browserTabId: browserTab.id, + worktreeId, + webviewRef, + activeLoadFailureRef, + onUpdatePageState, + setAddressBarValue + }) const runDeferredNavigation = useEffectEvent(navigateToUrl) useLayoutEffect(() => { @@ -374,6 +350,9 @@ export function ClientHostedBrowserPagePane({ onChange={setAddressBarValue} onSubmit={() => navigateToUrl(addressBarValue)} onNavigate={navigateToUrl} + onOpenWorkspaceDoc={(docLocation) => + convertBrowserPageToWorkspaceDoc(browserTab.id, docLocation) + } inputRef={addressBarInputRef} editSession={addressBarEditSession} leadingIcon={ diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx index a41f9c4e56d..a91d9cb7744 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx @@ -5,9 +5,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../../shared/browser-url' +import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types' import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions' import { shouldOverlayBrowserAddressBar } from './browser-address-bar-expansion' import { saveBrowserAddressBarEditSession } from './browser-address-bar-edit-session' +import { useBrowserAddressBarDismissal } from './use-browser-address-bar-dismissal' import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session' import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList' @@ -16,6 +18,8 @@ type BrowserAddressBarProps = { onChange: (value: string) => void onSubmit: () => void onNavigate: (url: string) => void + /** Selecting a previewed-document suggestion; without it those rows fall back to onNavigate. */ + onOpenWorkspaceDoc?: (docLocation: BrowserPageDocLocation) => void inputRef: React.RefObject dismissSuggestionsRef?: React.MutableRefObject<(() => void) | null> /** @@ -32,6 +36,7 @@ export default function BrowserAddressBar({ onChange, onSubmit, onNavigate, + onOpenWorkspaceDoc, inputRef, dismissSuggestionsRef, editSession, @@ -44,6 +49,7 @@ export default function BrowserAddressBar({ // but suggestions must keep matching the original typed query. const autocompleteQuery = prePreviewValueRef.current ?? value const browserUrlHistory = useAppStore((s) => s.browserUrlHistory) + const workspaceDocHistory = useAppStore((s) => s.workspaceDocHistory) const browserDefaultSearchEngine = useAppStore((s) => s.browserDefaultSearchEngine) const browserKagiSessionLink = useAppStore((s) => s.browserKagiSessionLink) const closingRef = useRef(false) @@ -149,11 +155,18 @@ export default function BrowserAddressBar({ () => buildBrowserAddressBarSuggestions({ browserUrlHistory, + workspaceDocHistory, kagiSessionLink: browserKagiSessionLink, searchEngine, value: autocompleteQuery }), - [browserUrlHistory, autocompleteQuery, searchEngine, browserKagiSessionLink] + [ + browserUrlHistory, + workspaceDocHistory, + autocompleteQuery, + searchEngine, + browserKagiSessionLink + ] ) const clearSuggestionPreview = useCallback((): void => { @@ -263,7 +276,16 @@ export default function BrowserAddressBar({ closingRef.current = true setOpen(false) clearSuggestionPreview() - onNavigate(url) + // Why looked up by row: a workspace-doc suggestion opens on a fresh grant instead of + // navigating; its url is the document's path, so even the fallback routes via detection. + const docLocation = suggestions.find( + (suggestion) => suggestion.url === url && suggestion.docLocation + )?.docLocation + if (docLocation && onOpenWorkspaceDoc) { + onOpenWorkspaceDoc(docLocation) + } else { + onNavigate(url) + } if (closingResetTimerRef.current !== null) { window.clearTimeout(closingResetTimerRef.current) } @@ -272,7 +294,7 @@ export default function BrowserAddressBar({ closingRef.current = false }, 100) }, - [clearSuggestionPreview, onNavigate] + [clearSuggestionPreview, onNavigate, onOpenWorkspaceDoc, suggestions] ) const handleKeyDown = useCallback( @@ -338,46 +360,7 @@ export default function BrowserAddressBar({ ] ) - // Why: Electron guests run in a separate process, so clicking the - // page never dispatches pointerdown on the renderer document and Radix cannot - // detect an outside dismiss. Window blur and focus moves into the guest (the - // host tag) close the dropdown the same way BrowserImportHintButton - // does for its popover. - useEffect(() => { - if (!open) { - return - } - - const handleWindowBlur = (): void => { - dismissSuggestions() - } - - const handleFocusIn = (event: FocusEvent): void => { - const target = event.target - if (!(target instanceof HTMLElement) || target.tagName !== 'WEBVIEW') { - return - } - dismissSuggestions() - } - - const handleEscape = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') { - return - } - dismissSuggestions() - event.preventDefault() - event.stopImmediatePropagation() - } - - window.addEventListener('blur', handleWindowBlur) - document.addEventListener('focusin', handleFocusIn, true) - window.addEventListener('keydown', handleEscape, true) - return () => { - window.removeEventListener('blur', handleWindowBlur) - document.removeEventListener('focusin', handleFocusIn, true) - window.removeEventListener('keydown', handleEscape, true) - } - }, [dismissSuggestions, inputRef, open]) + useBrowserAddressBarDismissal(open, dismissSuggestions) useEffect(() => { if (!dismissSuggestionsRef) { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts index 60c93bea0ae..597bc7b990f 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts @@ -122,3 +122,53 @@ describe('browser address bar suggestions', () => { ).toEqual([]) }) }) + +describe('workspace document suggestions', () => { + const DOC_ENTRY = { + docLocation: { + kind: 'workspace-doc' as const, + worktreeId: 'wt-1', + filePath: '/repo/docs/report.html' + }, + title: 'Quarterly Report', + lastVisitedAt: 10, + visitCount: 3 + } + + it('merges previewed documents into the empty-query recents by recency', () => { + const rows = buildBrowserAddressBarSuggestions({ + value: '', + browserUrlHistory: [ + { + url: 'https://example.com', + title: 'Example', + lastVisitedAt: 5, + visitCount: 1, + normalizedUrl: 'https://example.com' + } + ], + workspaceDocHistory: [DOC_ENTRY] + }) + expect(rows.map((row) => row.title)).toEqual(['Quarterly Report', 'Example']) + expect(rows[0]?.docLocation).toEqual(DOC_ENTRY.docLocation) + // The row's selection identity is the document's path, never a preview URL. + expect(rows[0]?.url).toBe('/repo/docs/report.html') + expect(JSON.stringify(rows)).not.toContain('orca-preview://') + }) + + it('matches typed queries against the document title and path', () => { + const rows = buildBrowserAddressBarSuggestions({ + value: 'quarterly', + browserUrlHistory: [], + workspaceDocHistory: [DOC_ENTRY] + }) + expect(rows.some((row) => row.docLocation)).toBe(true) + + const byPath = buildBrowserAddressBarSuggestions({ + value: 'docs/report', + browserUrlHistory: [], + workspaceDocHistory: [DOC_ENTRY] + }) + expect(byPath.some((row) => row.docLocation)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts index 5319f268d97..a3122ceb8c6 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts @@ -6,7 +6,11 @@ import { SEARCH_ENGINE_LABELS, type SearchEngine } from '../../../../../shared/browser-url' -import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types' +import type { + BrowserHistoryEntry, + BrowserPageDocLocation +} from '../../../../../shared/browser-workspace-types' +import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' import { isClipboardTextByteLengthOverLimit } from '../../../../../shared/clipboard-text' import { translate } from '@/i18n/i18n' @@ -14,12 +18,28 @@ export const MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS = 8 export const BROWSER_ADDRESS_BAR_QUERY_MAX_BYTES = 2 * 1024 export type BrowserAddressBarSuggestion = { + /** For a workspace-doc row this is the document's absolute path — a selection identity that even + * the url fallback routes correctly, since every pane's navigate runs path detection first. */ url: string title: string subtitle: string lastVisitedAt: number visitCount: number isSearch: boolean + /** Set on a previewed-document row; selecting it opens the document on a fresh grant. */ + docLocation?: BrowserPageDocLocation +} + +function toWorkspaceDocSuggestion(entry: WorkspaceDocHistoryEntry): BrowserAddressBarSuggestion { + return { + url: entry.docLocation.filePath, + title: entry.title, + subtitle: entry.docLocation.filePath, + lastVisitedAt: entry.lastVisitedAt, + visitCount: entry.visitCount, + isSearch: false, + docLocation: entry.docLocation + } } export function isBrowserAddressBarQueryTooLarge( @@ -53,11 +73,13 @@ function scoreBrowserAddressBarSuggestion( export function buildBrowserAddressBarSuggestions({ browserUrlHistory, + workspaceDocHistory = [], kagiSessionLink, searchEngine = DEFAULT_SEARCH_ENGINE, value }: { browserUrlHistory: readonly BrowserHistoryEntry[] + workspaceDocHistory?: readonly WorkspaceDocHistoryEntry[] kagiSessionLink?: string | null searchEngine?: SearchEngine value: string @@ -67,26 +89,30 @@ export function buildBrowserAddressBarSuggestions({ } const trimmed = value.trim() if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) { - if (browserUrlHistory.length === 0) { - return [] - } - return [...browserUrlHistory] + const recents: BrowserAddressBarSuggestion[] = [ + ...browserUrlHistory.map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false })), + ...workspaceDocHistory.map(toWorkspaceDocSuggestion) + ] + return recents .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) - .map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false })) } - const historySuggestions: BrowserAddressBarSuggestion[] = - browserUrlHistory.length > 0 - ? browserUrlHistory - .map((entry) => ({ - entry, - score: scoreBrowserAddressBarSuggestion(entry, trimmed) - })) - .filter((item) => item.score >= 0) - .sort((a, b) => b.score - a.score) - .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1) - .map((item) => ({ ...item.entry, subtitle: item.entry.url, isSearch: false })) - : [] + const scoredRows: { row: BrowserAddressBarSuggestion; score: number }[] = [ + ...browserUrlHistory.map((entry) => ({ + row: { ...entry, subtitle: entry.url, isSearch: false }, + score: scoreBrowserAddressBarSuggestion(entry, trimmed) + })), + // The scorer only reads url/title/recency/visits, and a doc row's url is its path. + ...workspaceDocHistory.map(toWorkspaceDocSuggestion).map((row) => ({ + row, + score: scoreBrowserAddressBarSuggestion(row, trimmed) + })) + ] + const historySuggestions: BrowserAddressBarSuggestion[] = scoredRows + .filter((item) => item.score >= 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1) + .map((item) => item.row) const isQuery = looksLikeSearchQuery(trimmed) let topAction: BrowserAddressBarSuggestion | null diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx index 737680e6a33..8d5b5b68ade 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx @@ -72,6 +72,8 @@ export function BrowserPageChromeHeader({ isActive={isActive} canGoBack={browserTab.canGoBack} canGoForward={browserTab.canGoForward} + convertedFrom={browserTab.convertedFrom ?? null} + convertedTo={browserTab.convertedTo ?? null} loading={browserTab.loading} webviewRef={webviewRef} reloadMenuOpen={reload.reloadMenuOpen} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx index 86e75427b38..616cca726ec 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx @@ -11,7 +11,15 @@ import { SshEgressIndicator } from './browser-egress-indicator' import { destroyPersistentWebview } from '../host-guest/webview-registry' import { readBrowserHtmlArtifactRequest } from '../describe-page/browser-artifact-upload' import type { GrabModeHook } from '../annotate/useGrabMode' -import type { BrowserViewportPresetId } from '../../../../../shared/browser-workspace-types' +import type { + BrowserPageConversionOrigin, + BrowserViewportPresetId +} from '../../../../../shared/browser-workspace-types' +import { + advanceAcrossBrowserPageConversion, + returnAcrossBrowserPageConversion +} from '@/lib/browser-page-conversion-history' +import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' import type { GrabIntent } from '../describe-page/browser-page-types' /** Binds the shared browser chrome to a browsing page: an editable address bar and session tools. */ @@ -24,6 +32,8 @@ export function BrowserPageToolbar({ isActive, canGoBack, canGoForward, + convertedFrom, + convertedTo, loading, webviewRef, reloadMenuOpen, @@ -60,6 +70,10 @@ export function BrowserPageToolbar({ isActive: boolean canGoBack: boolean canGoForward: boolean + /** Set on a page the address bar converted; Back returns across it once guest history runs out. */ + convertedFrom?: BrowserPageConversionOrigin | null + /** Set on a page Back returned to; Forward re-crosses it once guest history runs out. */ + convertedTo?: BrowserPageConversionOrigin | null loading: boolean webviewRef: RefObject reloadMenuOpen: boolean @@ -92,11 +106,30 @@ export function BrowserPageToolbar({ webviewRef.current?.goBack(), - goForward: () => webviewRef.current?.goForward(), + // Why the fallbacks: guest history cannot survive a conversion (the guest was replaced), + // so once it runs out Back returns across the conversion — and Forward re-crosses it — + // instead of going dead. + goBack: () => { + if (canGoBack) { + webviewRef.current?.goBack() + return + } + if (convertedFrom) { + returnAcrossBrowserPageConversion(browserPageId, convertedFrom) + } + }, + goForward: () => { + if (canGoForward) { + webviewRef.current?.goForward() + return + } + if (convertedTo) { + advanceAcrossBrowserPageConversion(browserPageId, convertedTo) + } + }, reload: () => runReloadTrigger('button'), navigate: navigateToUrl }} @@ -106,6 +139,9 @@ export function BrowserPageToolbar({ onChange={setAddressBarValue} onSubmit={submitAddressBar} onNavigate={navigateToUrl} + onOpenWorkspaceDoc={(docLocation) => + convertBrowserPageToWorkspaceDoc(browserPageId, docLocation) + } inputRef={addressBarInputRef} dismissSuggestionsRef={dismissAddressBarSuggestionsRef} leadingIcon={} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts new file mode 100644 index 00000000000..ec3aae5c12e --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react' + +/** + * Why: Electron guests run in a separate process, so clicking the page never dispatches + * pointerdown on the renderer document and Radix cannot detect an outside dismiss. Window blur and + * focus moves into the guest (the host tag) close the dropdown the same way + * BrowserImportHintButton does for its popover; Escape closes it at window capture. + */ +export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions: () => void): void { + useEffect(() => { + if (!open) { + return + } + + const handleWindowBlur = (): void => { + dismissSuggestions() + } + + const handleFocusIn = (event: FocusEvent): void => { + const target = event.target + if (!(target instanceof HTMLElement) || target.tagName !== 'WEBVIEW') { + return + } + dismissSuggestions() + } + + const handleEscape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') { + return + } + dismissSuggestions() + event.preventDefault() + event.stopImmediatePropagation() + } + + window.addEventListener('blur', handleWindowBlur) + document.addEventListener('focusin', handleFocusIn, true) + window.addEventListener('keydown', handleEscape, true) + return () => { + window.removeEventListener('blur', handleWindowBlur) + document.removeEventListener('focusin', handleFocusIn, true) + window.removeEventListener('keydown', handleEscape, true) + } + }, [dismissSuggestions, open]) +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts index 92440e91265..9d8f33f67de 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts @@ -11,6 +11,7 @@ import { } from 'react' import { translate } from '@/i18n/i18n' import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview' +import { routeWorkspaceDocAddressSubmission } from './workspace-doc-address-submission' import { getWorkspaceFileDragRejectionMessage, readWorkspaceFileDragPaths, @@ -127,6 +128,15 @@ export function useBrowserPageNavigationDownloads({ const submitAddressBar = (): void => { keepAddressBarFocusRef.current = false + const consumedAsWorkspaceDoc = routeWorkspaceDocAddressSubmission({ + worktreeId, + pageId: browserTabId, + value: addressBarValue, + onLoadError: (loadError) => onUpdatePageStateRef.current(browserTabId, { loadError }) + }) + if (consumedAsWorkspaceDoc) { + return + } const submission = resolveBrowserAddressBarSubmission(addressBarValue) if (submission.status === 'invalid') { onUpdatePageStateRef.current(browserTabId, { loadError: submission.loadError }) diff --git a/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts b/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts new file mode 100644 index 00000000000..9a67c285612 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts @@ -0,0 +1,79 @@ +import { useCallback } from 'react' +import type { MutableRefObject, RefObject } from 'react' +import { redactKagiSessionToken } from '../../../../../shared/browser-url' +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { resolveBrowserAddressBarSubmission } from './browser-address-bar-navigation' +import { routeWorkspaceDocAddressSubmission } from './workspace-doc-address-submission' +import { deferBrowserPageNavigation } from './browser-page-deferred-navigation' +import { getBrowserDisplayTitle, toDisplayUrl } from '../describe-page/browser-page-url-display' +import type { BrowserTabPageState } from '../describe-page/browser-page-types' + +/** + * Address-bar submission for a client-hosted page: the workspace-document leg first (a typed + * workspace path converts the page instead of navigating), then URL resolution with file: refused, + * then loadURL against the live guest — or a deferred navigation parked for the attach effect when + * the guest has not been minted yet. + */ +export function useClientHostedPageUrlSubmission(params: { + browserTabId: string + worktreeId: string + webviewRef: RefObject + activeLoadFailureRef: MutableRefObject + onUpdatePageState: (pageId: string, updates: Partial) => void + setAddressBarValue: (value: string) => void +}): (value: string) => void { + const { + browserTabId, + worktreeId, + webviewRef, + activeLoadFailureRef, + onUpdatePageState, + setAddressBarValue + } = params + return useCallback( + (value: string) => { + const consumedAsWorkspaceDoc = routeWorkspaceDocAddressSubmission({ + worktreeId, + pageId: browserTabId, + value, + onLoadError: (loadError) => onUpdatePageState(browserTabId, { loadError }) + }) + if (consumedAsWorkspaceDoc) { + return + } + const submission = resolveBrowserAddressBarSubmission(value, { allowFileUrls: false }) + if (submission.status === 'invalid') { + onUpdatePageState(browserTabId, { loadError: submission.loadError }) + return + } + const webview = webviewRef.current + if (!webview) { + // Why: the page is still an optimistic stage, so park the URL for the attach effect to + // replay rather than dropping what the user just typed. + deferBrowserPageNavigation(browserTabId, submission.url) + setAddressBarValue(toDisplayUrl(redactKagiSessionToken(submission.url))) + return + } + // Why: the store and the address bar must never hold a Kagi session token, and an optimistic + // title keeps the tab from reading "New Tab" until the guest reports one — as local does. + const browserModelUrl = redactKagiSessionToken(submission.url) + activeLoadFailureRef.current = null + setAddressBarValue(toDisplayUrl(browserModelUrl)) + onUpdatePageState(browserTabId, { + loading: true, + loadError: null, + title: getBrowserDisplayTitle(browserModelUrl, browserModelUrl) + }) + // Why: loadURL rejects on any failed navigation; did-fail-load owns error reporting. + void webview.loadURL(submission.url).catch(() => {}) + }, + [ + activeLoadFailureRef, + browserTabId, + onUpdatePageState, + setAddressBarValue, + webviewRef, + worktreeId + ] + ) +} diff --git a/src/renderer/src/components/browser-pane/navigate/workspace-doc-address-submission.ts b/src/renderer/src/components/browser-pane/navigate/workspace-doc-address-submission.ts new file mode 100644 index 00000000000..43eb9f0f799 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/workspace-doc-address-submission.ts @@ -0,0 +1,39 @@ +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' +import { resolveWorkspaceDocAddressTarget } from '@/lib/workspace-doc-address-input' +import { useAppStore } from '@/store' + +/** + * The workspace-document leg of an address-bar submission, shared by every URL pane: a typed + * workspace path converts the page (or activates the tab already showing that document) instead of + * navigating — checked before the URL pipeline turns paths into file://, which a client-hosted + * guest refuses and which resolves on the wrong machine for a remote worktree. + * + * Returns true when the submission was consumed (converted, activated, or refused with an error); + * false hands the input to the URL pipeline untouched. + */ +export function routeWorkspaceDocAddressSubmission(params: { + worktreeId: string + pageId: string + value: string + onLoadError: (loadError: BrowserLoadError) => void +}): boolean { + const docTarget = resolveWorkspaceDocAddressTarget( + useAppStore.getState(), + params.worktreeId, + params.value + ) + if (docTarget.status === 'workspace-doc') { + convertBrowserPageToWorkspaceDoc(params.pageId, docTarget.docLocation) + return true + } + if (docTarget.status === 'unsupported') { + params.onLoadError({ + code: 0, + description: docTarget.message, + validatedUrl: params.value.trim() || 'about:blank' + }) + return true + } + return false +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx index bb216befdd2..443264c1f1b 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' import { useBrowserPageChromeFocus } from '../assemble-chrome/use-browser-page-chrome-focus' import { useBrowserAddressBarEditSession } from '../assemble-chrome/use-browser-address-bar-edit-session' @@ -378,6 +379,9 @@ export function RemoteBrowserPagePane({ onAddressBarChange={setAddressBarValue} onSubmitAddressBar={submitAddressBar} onNavigateToUrl={navigateToUrl} + onOpenWorkspaceDoc={(docLocation) => + convertBrowserPageToWorkspaceDoc(browserTab.id, docLocation) + } addressBarInputRef={addressBarInputRef} addressBarEditSession={addressBarEditSession} busy={busy} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx index a923263d1be..d659647e6d3 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx @@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n' import { BrowserNavigationControlRow } from '../assemble-chrome/browser-navigation-control-row' import BrowserAddressBar from '../assemble-chrome/BrowserAddressBar' import type { BrowserAddressBarEditSessionBinding } from '../assemble-chrome/use-browser-address-bar-edit-session' +import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types' import { RemoteRuntimeEgressIndicator } from '../assemble-chrome/browser-egress-indicator' import { MarkupDrawButton } from '../annotate/MarkupDrawButton' import type { MarkupModeController } from '../annotate/useMarkupMode' @@ -15,6 +16,7 @@ export function RemoteBrowserPageToolbar({ onAddressBarChange, onSubmitAddressBar, onNavigateToUrl, + onOpenWorkspaceDoc, addressBarInputRef, addressBarEditSession, busy, @@ -31,6 +33,8 @@ export function RemoteBrowserPageToolbar({ onAddressBarChange: (value: string) => void onSubmitAddressBar: () => void onNavigateToUrl: (url: string) => void + /** A previewed-document suggestion opens on a fresh grant instead of navigating the remote guest. */ + onOpenWorkspaceDoc: (docLocation: BrowserPageDocLocation) => void addressBarInputRef: React.RefObject addressBarEditSession: BrowserAddressBarEditSessionBinding busy: boolean @@ -61,6 +65,7 @@ export function RemoteBrowserPageToolbar({ onChange={onAddressBarChange} onSubmit={onSubmitAddressBar} onNavigate={onNavigateToUrl} + onOpenWorkspaceDoc={onOpenWorkspaceDoc} inputRef={addressBarInputRef} editSession={addressBarEditSession} leadingIcon={ diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts index 93d5da4f603..d04f791adb7 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { redactKagiSessionToken } from '../../../../../shared/browser-url' import { normalizeBrowserHistoryUrl } from '../../../../../shared/workspace-session-browser-history' import { resolveBrowserAddressBarSubmission } from '../navigate/browser-address-bar-navigation' +import { routeWorkspaceDocAddressSubmission } from '../navigate/workspace-doc-address-submission' import { deferBrowserPageNavigation } from '../navigate/browser-page-deferred-navigation' import { keybindingMatchesAction } from '../../../../../shared/keybindings' import type { @@ -222,6 +223,20 @@ export function useRemoteBrowserPageNavigation({ }, [isActive, keybindings, runRemoteNavigation]) const submitAddressBar = (): void => { + // A typed workspace path converts this page to its client-local document preview instead of + // navigating the remote guest — a runtime-owned tab is where a paired reader actually types. + const consumedAsWorkspaceDoc = routeWorkspaceDocAddressSubmission({ + worktreeId: browserTab.worktreeId, + pageId: browserTab.id, + value: addressBarValue, + onLoadError: (loadError) => { + setPaneNotice({ kind: 'direct', text: loadError.description }) + onUpdatePageState(browserTab.id, { loadError }) + } + }) + if (consumedAsWorkspaceDoc) { + return + } const submission = resolveBrowserAddressBarSubmission(addressBarValue, { allowFileUrls: false }) if (submission.status === 'invalid') { // 'direct': the only response to what the user just typed. With an empty address bar no diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx index a925431b449..8fbc26b447c 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx @@ -65,6 +65,8 @@ const storeState = { deleteBrowserPageAnnotation: () => undefined, clearBrowserPageAnnotations: () => undefined, recordFeatureInteraction: () => undefined, + recordWorkspaceDocVisit: () => undefined, + workspaceDocHistory: [], openFile: () => 'file-1' } diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx index d989303c9f9..32ddf0a6d2b 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx @@ -52,7 +52,8 @@ vi.mock('@/lib/execution-host-display-label', () => ({ const store = vi.hoisted(() => ({ openedFiles: [] as unknown[], downloads: [] as string[], - pageStateUpdates: [] as { pageId: string; updates: { title?: string } }[] + pageStateUpdates: [] as { pageId: string; updates: { title?: string } }[], + conversions: [] as { pageId: string; target: unknown }[] })) // The document lives on the SSH host that owns the workspace, which is what makes the preview a @@ -94,6 +95,15 @@ const storeState = { }, updateBrowserPageState: (pageId: string, updates: { title?: string }) => { store.pageStateUpdates.push({ pageId, updates }) + }, + browserUrlHistory: [], + workspaceDocHistory: [], + recordWorkspaceDocVisit: () => undefined, + browserDefaultSearchEngine: 'google', + browserKagiSessionLink: null, + convertBrowserPage: (pageId: string, target: unknown) => { + store.conversions.push({ pageId, target }) + return { id: 'converted-1' } } } @@ -283,7 +293,7 @@ describe('HtmlDocPreview browser chrome', () => { it('hands the identity chip straight to the height-pinned address slot', async () => { await renderPreview(container, root) - const chip = button(container, 'Copy file path') + const chip = button(container, 'Edit address') const slot = container.querySelector('[data-browser-chrome-address-slot]') expect(slot).not.toBeNull() expect(chip.parentElement).toBe(slot) @@ -306,7 +316,57 @@ describe('HtmlDocPreview browser chrome', () => { expect(store.pageStateUpdates).toEqual([ { pageId: 'preview-1', updates: { title: 'Quarterly Report' } } ]) - expect(button(container, 'Copy file path').textContent).toContain(ENTRY_RELATIVE_PATH) + expect(button(container, 'Edit address').textContent).toContain(ENTRY_RELATIVE_PATH) + }) + + // The convergence contract (STA-5681): clicking the chip swaps in the real address bar, + // prefilled with the file the reader can retype, and a committed web URL converts the page — + // it never navigates a doc guest, whose policy would deny the URL anyway. + it('edits the address in place and converts a committed URL instead of navigating', async () => { + await renderPreview(container, root) + store.conversions.length = 0 + + await act(async () => { + button(container, 'Edit address').click() + }) + const input = container.querySelector( + '[data-browser-chrome-address-slot] input' + ) + expect(input).not.toBeNull() + expect(input?.value).toBe(ENTRY_RELATIVE_PATH) + + await act(async () => { + input!.value = 'https://example.com/' + input!.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(store.conversions).toEqual([ + { pageId: 'preview-1', target: { kind: 'web', url: 'https://example.com/' } } + ]) + }) + + // Escape hands the slot back to the chip with nothing converted — the reader looked, then left. + // The first press belongs to the address bar (it closes the suggestion dropdown, as in the URL + // pane); the second one reaches the wrapper and exits the edit. + it('returns to the chip on Escape without converting', async () => { + await renderPreview(container, root) + store.conversions.length = 0 + + await act(async () => { + button(container, 'Edit address').click() + }) + const input = container.querySelector( + '[data-browser-chrome-address-slot] input' + ) + await act(async () => { + input!.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + await act(async () => { + input!.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + + expect(store.conversions).toEqual([]) + expect(button(container, 'Edit address')).not.toBeNull() }) // Why: the browsing tour walks anchors by name, and a preview answering to the browser pane's @@ -317,16 +377,29 @@ describe('HtmlDocPreview browser chrome', () => { expect(container.querySelector('[data-contextual-tour-target]')).toBeNull() }) + // Why the menu and no longer the chip: clicking the chip now edits the address, so the menu is + // the one copy affordance left for the absolute path the owning machine spells. it('copies the absolute path the owning machine spells, not the workspace-relative one', async () => { await renderPreview(container, root) await act(async () => { - button(container, 'Copy file path').click() + // Why not click(): the Radix trigger opens on pointerdown, which happy-dom does not synthesize. + button(container, 'Preview options').dispatchEvent( + new window.PointerEvent('pointerdown', { bubbles: true, button: 0 }) + ) + }) + const absoluteCopy = [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => + item.textContent?.includes('Copy file path') && + !item.textContent.includes('Copy relative path') + ) + expect(absoluteCopy).toBeDefined() + + await act(async () => { + ;(absoluteCopy as HTMLElement).click() }) expect(clipboard.writes).toEqual([ABSOLUTE_PATH]) - // The icon swap alone says nothing to a screen reader, so the control renames itself. - expect(button(container, 'Copied')).not.toBeNull() }) it('starts with both history controls disabled', async () => { diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx index 343c5bdb11b..7e790df561a 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx @@ -1,15 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' -import { - DOC_PREVIEW_PARTITION, - type DocPreviewFileFailure, - type DocPreviewFileFailureReason +import type { + DocPreviewFileFailure, + DocPreviewFileFailureReason } from '../../../../../shared/doc-preview-scheme' -import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences' +import type { BrowserPageConversionOrigin } from '../../../../../shared/browser-workspace-types' +import { + advanceAcrossBrowserPageConversion, + returnAcrossBrowserPageConversion +} from '@/lib/browser-page-conversion-history' import { BrowserGuestAnnotateOverlays } from '@/components/browser-pane/annotate/browser-guest-annotate-overlays' import { useGuestDragPassthrough } from '@/components/browser-pane/host-guest/use-guest-drag-passthrough' -import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough' -import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry' +import { attachDocPreviewWebview } from './doc-preview-webview-attach' import { buildDocPreviewGrantRequest, ensureDocPreviewGrant, @@ -35,81 +37,6 @@ import { useDocPreviewGuestTools } from './use-doc-preview-guest-tools' type PreviewState = 'loading' | 'ready' | 'unavailable' -function attachDocPreviewWebview({ - container, - url, - ariaLabel, - onLoadStarted, - onLoadStopped, - onLoadFailed, - onNavigated, - onTitleUpdated -}: { - container: HTMLDivElement - url: string - ariaLabel: string - onLoadStarted: () => void - onLoadStopped: () => void - onLoadFailed: (event: Electron.DidFailLoadEvent) => void - onNavigated: () => void - onTitleUpdated: (event: Electron.PageTitleUpdatedEvent) => void -}): { webview: Electron.WebviewTag; detach: () => void; reload: () => void } { - const webview = document.createElement('webview') as Electron.WebviewTag - // Why no allowpopups: the guest's preload intercepts a trusted click on a link before Chromium - // considers a popup at all, so target="_blank" needs no popup path and every one stays denied. - webview.setAttribute('partition', DOC_PREVIEW_PARTITION) - webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE) - webview.setAttribute('aria-label', ariaLabel) - // Browsers paint an undeclared page canvas white; the guest is transparent, so without this the - // editor's dark surface shows through and default black text becomes unreadable. - webview.style.backgroundColor = '#fff' - webview.style.display = 'flex' - webview.style.width = '100%' - webview.style.height = '100%' - webview.style.border = 'none' - webview.addEventListener('did-start-loading', onLoadStarted) - webview.addEventListener('did-stop-loading', onLoadStopped) - webview.addEventListener('did-fail-load', onLoadFailed) - // Both: a link to a sibling document is a full navigation, a fragment link is an in-page one, - // and only the pair together tracks what Back can actually return to. - webview.addEventListener('did-navigate', onNavigated) - webview.addEventListener('did-navigate-in-page', onNavigated) - // Why the document names its own tab: a preview is a browser tab, and this is how every other - // one is named. What the document cannot do is name it the grant it is served over. - webview.addEventListener('page-title-updated', onTitleUpdated) - // Why here and not in the enrolling hook: appending is what makes this guest hittable, and the - // registry's contract is that the path doing so settles it. Dragging the preview's own tab - // remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of - // that turn the fresh guest eats the pointer stream and the drag freezes. - if (isWebviewDragPassthroughActive()) { - webview.style.pointerEvents = 'none' - } - container.appendChild(webview) - webview.setAttribute('src', url) - - return { - webview, - detach: () => { - webview.removeEventListener('did-start-loading', onLoadStarted) - webview.removeEventListener('did-stop-loading', onLoadStopped) - webview.removeEventListener('did-fail-load', onLoadFailed) - webview.removeEventListener('did-navigate', onNavigated) - webview.removeEventListener('did-navigate-in-page', onNavigated) - webview.removeEventListener('page-title-updated', onTitleUpdated) - moveFocusToRendererBeforeWebviewDetach(webview) - webview.remove() - }, - // Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk. - reload: () => { - try { - webview.reload() - } catch { - webview.setAttribute('src', url) - } - } - } -} - /** Frames a preview keeps offering focus to a guest that is still attaching. */ const GUEST_FOCUS_FRAMES = 10 const MAX_ASSET_FAILURES = 50 @@ -121,7 +48,9 @@ export function HtmlDocPreview({ worktreeId, holdsGuestFocus = false, runtimeEnvironmentId = null, - externalSshTargetId = null + externalSshTargetId = null, + convertedFrom = null, + convertedTo = null }: { previewId: string filePath: string @@ -131,6 +60,10 @@ export function HtmlDocPreview({ holdsGuestFocus?: boolean runtimeEnvironmentId?: string | null externalSshTargetId?: string | null + /** Set when the address bar converted this page; Back returns across it once guest history runs out. */ + convertedFrom?: BrowserPageConversionOrigin | null + /** Set when Back returned across a conversion to this page; Forward re-crosses it. */ + convertedTo?: BrowserPageConversionOrigin | null }): React.JSX.Element { const containerRef = useRef(null) const webviewRef = useRef(null) @@ -152,6 +85,38 @@ export function HtmlDocPreview({ const history = useDocPreviewWebviewHistory(webviewRef) const { sync: syncHistory, reset: resetHistory } = history + // Why wrapped rather than a second control: guest history cannot survive a conversion (the + // guest was replaced), so once it runs out Back returns across the conversion — and Forward + // re-crosses it — instead of dying. + const historyWithConversionCrossings = useMemo( + () => + convertedFrom || convertedTo + ? { + ...history, + canGoBack: history.canGoBack || Boolean(convertedFrom), + canGoForward: history.canGoForward || Boolean(convertedTo), + goBack: (): void => { + if (history.canGoBack) { + history.goBack() + return + } + if (convertedFrom) { + returnAcrossBrowserPageConversion(previewId, convertedFrom) + } + }, + goForward: (): void => { + if (history.canGoForward) { + history.goForward() + return + } + if (convertedTo) { + advanceAcrossBrowserPageConversion(previewId, convertedTo) + } + } + } + : history, + [convertedFrom, convertedTo, history, previewId] + ) const worktreeRoot = useAppStore((store) => store.getKnownWorktreeById(worktreeId)?.path ?? null) const hostLabel = useAppStore((store) => selectWorktreeHostDisplayLabel(store, worktreeId)) @@ -264,6 +229,14 @@ export function HtmlDocPreview({ onNavigated: syncHistory, onTitleUpdated: (event) => { useAppStore.getState().updateBrowserPageState(previewId, { title: event.title }) + // A rename only — the mount already recorded this document's visit. + useAppStore + .getState() + .recordWorkspaceDocVisit( + { kind: 'workspace-doc', worktreeId, filePath }, + event.title, + { bump: false } + ) } }) detach = attached.detach @@ -297,6 +270,14 @@ export function HtmlDocPreview({ worktreeId ]) + // The dropdown's doc-history source: opening a document is a visit, once per document per mount + // (a hard reload re-mints the grant but is not a new visit). + useEffect(() => { + useAppStore + .getState() + .recordWorkspaceDocVisit({ kind: 'workspace-doc', worktreeId, filePath }, null) + }, [filePath, worktreeId]) + // Why the guest is handed focus: a preview has no address bar to make the usual handoff, so a // surfaced document would otherwise look active while its keyboard and link input land elsewhere. useEffect(() => { @@ -378,7 +359,9 @@ export function HtmlDocPreview({
(null) + const dismissSuggestionsRef = useRef<(() => void) | null>(null) + const exitTimerRef = useRef(null) + + const beginEdit = useCallback((): void => { + setValue(`${identity.directoryPrefix}${identity.fileName}`) + setEditing(true) + }, [identity.directoryPrefix, identity.fileName]) + + const exitEdit = useCallback((): void => { + if (exitTimerRef.current !== null) { + window.clearTimeout(exitTimerRef.current) + exitTimerRef.current = null + } + setEditing(false) + }, []) + + useEffect(() => { + if (!editing) { + return + } + const input = inputRef.current + input?.focus() + input?.select() + return () => { + if (exitTimerRef.current !== null) { + window.clearTimeout(exitTimerRef.current) + exitTimerRef.current = null + } + } + }, [editing]) + + const navigateToUrl = useCallback( + (url: string): void => { + // Why refused here too: a suggestion click bypasses the typed-input resolver, and a doc tab + // exists because its file is not this machine's — a file: target would resolve on the wrong + // host exactly like the typed case. + if (url.startsWith('file:')) { + toast.error( + translate( + 'auto.components.browser.pane.BrowserPane.fileUrlUnsupported', + 'This browser tab cannot open local files. Use "Open Preview to the Side" on the file instead.' + ) + ) + return + } + const converted = useAppStore.getState().convertBrowserPage(previewId, { kind: 'web', url }) + if (!converted) { + exitEdit() + } + }, + [exitEdit, previewId] + ) + + const submit = useCallback((): void => { + const typed = inputRef.current?.value ?? '' + // A workspace path retargets the preview (fresh grant, same tab) — or activates the tab the + // document is already open in, which is what opening a document has always meant. + const docTarget = resolveWorkspaceDocAddressTarget(useAppStore.getState(), worktreeId, typed) + if (docTarget.status === 'workspace-doc') { + convertBrowserPageToWorkspaceDoc(previewId, docTarget.docLocation) + exitEdit() + return + } + if (docTarget.status === 'unsupported') { + toast.error(docTarget.message) + return + } + const submission = resolveBrowserAddressBarSubmission(typed, { + allowFileUrls: false + }) + if (submission.status === 'navigate') { + navigateToUrl(submission.url) + return + } + toast.error(submission.loadError.description) + }, [exitEdit, navigateToUrl, previewId, worktreeId]) + + if (!editing) { + return + } + + return ( +
{ + if (event.key === 'Escape') { + exitEdit() + } + }} + onBlur={(event) => { + // Why a grace timer and not an immediate exit: a suggestion click blurs the input before + // its own click lands, and unmounting the list mid-gesture would swallow the commit. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return + } + exitTimerRef.current = window.setTimeout(() => setEditing(false), 200) + }} + onFocus={() => { + if (exitTimerRef.current !== null) { + window.clearTimeout(exitTimerRef.current) + exitTimerRef.current = null + } + }} + > + { + convertBrowserPageToWorkspaceDoc(previewId, docLocation) + exitEdit() + }} + inputRef={inputRef} + dismissSuggestionsRef={dismissSuggestionsRef} + /> +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx index 8fafb551725..d17d025ccde 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx @@ -11,12 +11,19 @@ import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity * machine it was read from, which is the part a paired or SSH reader cannot otherwise tell. */ export function DocPreviewDocumentChip({ - identity + identity, + onBeginEdit }: { identity: DocPreviewDocumentIdentity + /** When set, a click edits the address instead of copying the path (copy stays in the menu). */ + onBeginEdit?: () => void }): React.JSX.Element { const { copyText, status } = useClipboardTextCopyFeedback(identity.absolutePath) const copied = status === 'copied' + const editLabel = translate( + 'auto.components.editor.HtmlDocPreview.editAddressControl', + 'Edit address' + ) const copyLabel = translate( 'auto.components.editor.HtmlDocPreview.copyDocumentPathControl', 'Copy file path' @@ -27,14 +34,15 @@ export function DocPreviewDocumentChip({ 'auto.components.editor.HtmlDocPreview.documentPathCopied', 'Copied' ) + const label = onBeginEdit ? editLabel : copied ? copiedLabel : copyLabel return ( - {copied ? copiedLabel : `${copyLabel} · ${identity.absolutePath}`} + {onBeginEdit + ? `${editLabel} · ${identity.absolutePath}` + : copied + ? copiedLabel + : `${copyLabel} · ${identity.absolutePath}`} ) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx index 7fc75dc20e0..df8f9116ca2 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx @@ -5,7 +5,7 @@ import { } from '@/components/browser-pane/assemble-chrome/browser-chrome-toolbar' import { BrowserReloadControl } from '@/components/browser-pane/assemble-chrome/browser-reload-control' import { translate } from '@/i18n/i18n' -import { DocPreviewDocumentChip } from './doc-preview-document-chip' +import { DocPreviewAddressEdit } from './doc-preview-address-edit' import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity' import type { DocPreviewHistory } from './doc-preview-webview-history' import { DocPreviewOverflowMenu } from './doc-preview-overflow-menu' @@ -16,6 +16,8 @@ import { DocPreviewOverflowMenu } from './doc-preview-overflow-menu' */ export function DocPreviewToolbar({ identity, + previewId, + worktreeId, history, loading, onReload, @@ -30,6 +32,9 @@ export function DocPreviewToolbar({ markupDisabled }: { identity: DocPreviewDocumentIdentity + /** The browser page this preview is open in — what an address commit converts. */ + previewId: string + worktreeId: string history: DocPreviewHistory loading: boolean onReload: () => void @@ -62,7 +67,9 @@ export function DocPreviewToolbar({ // Why a no-op: the identity chip has nothing to submit, so nothing can reach this. navigate: () => {} }} - addressSlot={} + addressSlot={ + + } reloadControl={ void + onLoadStopped: () => void + onLoadFailed: (event: Electron.DidFailLoadEvent) => void + onNavigated: () => void + onTitleUpdated: (event: Electron.PageTitleUpdatedEvent) => void +}): { webview: Electron.WebviewTag; detach: () => void; reload: () => void } { + const webview = document.createElement('webview') as Electron.WebviewTag + // Why no allowpopups: the guest's preload intercepts a trusted click on a link before Chromium + // considers a popup at all, so target="_blank" needs no popup path and every one stays denied. + webview.setAttribute('partition', DOC_PREVIEW_PARTITION) + webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE) + webview.setAttribute('aria-label', ariaLabel) + // Browsers paint an undeclared page canvas white; the guest is transparent, so without this the + // editor's dark surface shows through and default black text becomes unreadable. + webview.style.backgroundColor = '#fff' + webview.style.display = 'flex' + webview.style.width = '100%' + webview.style.height = '100%' + webview.style.border = 'none' + webview.addEventListener('did-start-loading', onLoadStarted) + webview.addEventListener('did-stop-loading', onLoadStopped) + webview.addEventListener('did-fail-load', onLoadFailed) + // Both: a link to a sibling document is a full navigation, a fragment link is an in-page one, + // and only the pair together tracks what Back can actually return to. + webview.addEventListener('did-navigate', onNavigated) + webview.addEventListener('did-navigate-in-page', onNavigated) + // Why the document names its own tab: a preview is a browser tab, and this is how every other + // one is named. What the document cannot do is name it the grant it is served over. + webview.addEventListener('page-title-updated', onTitleUpdated) + // Why here and not in the enrolling hook: appending is what makes this guest hittable, and the + // registry's contract is that the path doing so settles it. Dragging the preview's own tab + // remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of + // that turn the fresh guest eats the pointer stream and the drag freezes. + if (isWebviewDragPassthroughActive()) { + webview.style.pointerEvents = 'none' + } + container.appendChild(webview) + webview.setAttribute('src', url) + + return { + webview, + detach: () => { + webview.removeEventListener('did-start-loading', onLoadStarted) + webview.removeEventListener('did-stop-loading', onLoadStopped) + webview.removeEventListener('did-fail-load', onLoadFailed) + webview.removeEventListener('did-navigate', onNavigated) + webview.removeEventListener('did-navigate-in-page', onNavigated) + webview.removeEventListener('page-title-updated', onTitleUpdated) + moveFocusToRendererBeforeWebviewDetach(webview) + webview.remove() + }, + // Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk. + reload: () => { + try { + webview.reload() + } catch { + webview.setAttribute('src', url) + } + } + } +} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx b/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx index 98f988762a0..59f52ea6f23 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx @@ -54,6 +54,8 @@ export function WorkspaceDocPagePane({ relativePath={relativePath} worktreeId={worktreeId} runtimeEnvironmentId={runtimeEnvironmentId} + convertedFrom={page.convertedFrom ?? null} + convertedTo={page.convertedTo ?? null} />
) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6660bb6c200..a964720922b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14866,7 +14866,8 @@ "allowDirectory": "Allow folder", "directoryAccessRequestOverflow": "{{folders}}, and {{count}} more", "directoryAccessRequestMultiple": "This preview wants to read files in {{folders}}.", - "allowDirectories": "Allow {{count}} folders" + "allowDirectories": "Allow {{count}} folders", + "editAddressControl": "Edit address" } }, "diff": { diff --git a/src/renderer/src/lib/browser-page-conversion-history.test.ts b/src/renderer/src/lib/browser-page-conversion-history.test.ts new file mode 100644 index 00000000000..ca822eca437 --- /dev/null +++ b/src/renderer/src/lib/browser-page-conversion-history.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + advanceAcrossBrowserPageConversion, + returnAcrossBrowserPageConversion +} from './browser-page-conversion-history' + +const mocks = vi.hoisted(() => ({ + convertBrowserPage: vi.fn(), + convertBrowserPageToWorkspaceDoc: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ convertBrowserPage: mocks.convertBrowserPage }) } +})) +vi.mock('@/lib/file-preview', () => ({ + convertBrowserPageToWorkspaceDoc: mocks.convertBrowserPageToWorkspaceDoc +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +// The one line guarding the ssh-execution-boundary concern: ownership must ride the return leg, +// or Back moves a remote tab's browsing onto this desktop. +describe('returnAcrossBrowserPageConversion', () => { + it('passes the recorded runtime ownership through to the rebuilt web page', () => { + returnAcrossBrowserPageConversion('page-1', { + kind: 'url', + url: 'https://remote.example/', + browserRuntimeEnvironmentId: 'env-1' + }) + + expect(mocks.convertBrowserPage).toHaveBeenCalledWith( + 'page-1', + { kind: 'web', url: 'https://remote.example/', browserRuntimeEnvironmentId: 'env-1' }, + { leg: 'history-return' } + ) + }) + + it('says inferred explicitly when the origin recorded no ownership', () => { + returnAcrossBrowserPageConversion('page-1', { kind: 'url', url: 'https://remote.example/' }) + + const target = mocks.convertBrowserPage.mock.calls[0]?.[1] as Record + // Property present with undefined — the plan's "worktree-inferred", never client-local. + expect('browserRuntimeEnvironmentId' in target).toBe(true) + expect(target.browserRuntimeEnvironmentId).toBeUndefined() + }) + + it('routes a workspace-doc origin through the doc door as a return leg', () => { + const docLocation = { kind: 'workspace-doc' as const, worktreeId: 'wt-1', filePath: '/a.html' } + returnAcrossBrowserPageConversion('page-1', { kind: 'workspace-doc', docLocation }) + + expect(mocks.convertBrowserPageToWorkspaceDoc).toHaveBeenCalledWith('page-1', docLocation, { + leg: 'history-return' + }) + expect(mocks.convertBrowserPage).not.toHaveBeenCalled() + }) +}) + +describe('advanceAcrossBrowserPageConversion', () => { + it('re-crosses to a web target with ownership intact, as an advance leg', () => { + advanceAcrossBrowserPageConversion('page-1', { + kind: 'url', + url: 'https://remote.example/', + browserRuntimeEnvironmentId: 'env-1' + }) + + expect(mocks.convertBrowserPage).toHaveBeenCalledWith( + 'page-1', + { kind: 'web', url: 'https://remote.example/', browserRuntimeEnvironmentId: 'env-1' }, + { leg: 'history-advance' } + ) + }) + + it('re-crosses to a workspace-doc target as an advance leg', () => { + const docLocation = { kind: 'workspace-doc' as const, worktreeId: 'wt-1', filePath: '/a.html' } + advanceAcrossBrowserPageConversion('page-1', { kind: 'workspace-doc', docLocation }) + + expect(mocks.convertBrowserPageToWorkspaceDoc).toHaveBeenCalledWith('page-1', docLocation, { + leg: 'history-advance' + }) + expect(mocks.convertBrowserPage).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/browser-page-conversion-history.ts b/src/renderer/src/lib/browser-page-conversion-history.ts new file mode 100644 index 00000000000..fc021fc3b1d --- /dev/null +++ b/src/renderer/src/lib/browser-page-conversion-history.ts @@ -0,0 +1,50 @@ +import type { BrowserPageConversionOrigin } from '../../../shared/browser-workspace-types' +import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion' +import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' +import { useAppStore } from '@/store' + +function crossBrowserPageConversion( + pageId: string, + origin: BrowserPageConversionOrigin, + leg: BrowserPageConversionLeg +): void { + if (origin.kind === 'workspace-doc') { + convertBrowserPageToWorkspaceDoc(pageId, origin.docLocation, { leg }) + return + } + useAppStore.getState().convertBrowserPage( + pageId, + { + kind: 'web', + url: origin.url, + // Deliberately present even when undefined: absent-on-origin means worktree-inferred, and + // returning it as client-local would silently move the tab's browsing onto this desktop. + browserRuntimeEnvironmentId: origin.browserRuntimeEnvironmentId + }, + { leg } + ) +} + +/** + * Back's one-level return across an address-bar conversion: with no guest history left to go back + * through, a page that was converted returns to what it was converted from. Arriving back consumes + * `convertedFrom` and records `convertedTo` on the restored page, so Forward can re-cross — + * the pair behaves like two history entries. + */ +export function returnAcrossBrowserPageConversion( + pageId: string, + origin: BrowserPageConversionOrigin +): void { + crossBrowserPageConversion(pageId, origin, 'history-return') +} + +/** + * Forward's re-crossing after Back returned: consumes `convertedTo` and records `convertedFrom` + * again on the rebuilt page, so Back keeps working on the other side. + */ +export function advanceAcrossBrowserPageConversion( + pageId: string, + origin: BrowserPageConversionOrigin +): void { + crossBrowserPageConversion(pageId, origin, 'history-advance') +} diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index 27c8ca1e2ce..561d92bbc8a 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE, canShowWorkspaceFileBrowserAction, + convertBrowserPageToWorkspaceDoc, getWorkspaceFileBrowserOpenTarget, openFileInBrowserTab, openFilePreviewToSide @@ -27,10 +28,14 @@ const mocks = vi.hoisted(() => ({ createEmptySplitGroup: vi.fn(() => 'group-2'), setActiveBrowserTab: vi.fn(), setActiveBrowserPage: vi.fn(), + setActiveWorktree: vi.fn(), + activeWorktreeId: 'wt-1', focusGroup: vi.fn(), activateTab: vi.fn(), unifiedTabsByWorktree: {} as Record, browserTabsByWorktree: {} as Record, + browserPagesByWorkspace: {} as Record, + convertBrowserPage: vi.fn(), environmentId: null as string | null, connectionId: null as string | null, layoutByWorktree: {} as Record, @@ -54,10 +59,14 @@ vi.mock('@/store', () => ({ createEmptySplitGroup: mocks.createEmptySplitGroup, setActiveBrowserTab: mocks.setActiveBrowserTab, setActiveBrowserPage: mocks.setActiveBrowserPage, + setActiveWorktree: mocks.setActiveWorktree, + activeWorktreeId: mocks.activeWorktreeId, focusGroup: mocks.focusGroup, activateTab: mocks.activateTab, unifiedTabsByWorktree: mocks.unifiedTabsByWorktree, browserTabsByWorktree: mocks.browserTabsByWorktree, + browserPagesByWorkspace: mocks.browserPagesByWorkspace, + convertBrowserPage: mocks.convertBrowserPage, getKnownWorktreeById: () => ({ id: 'wt-1', path: '/srv/repo' }), groupsByWorktree: {}, layoutByWorktree: mocks.layoutByWorktree, @@ -76,7 +85,9 @@ beforeEach(() => { mocks.connectionId = null mocks.layoutByWorktree = {} mocks.browserTabsByWorktree = {} + mocks.browserPagesByWorkspace = {} mocks.unifiedTabsByWorktree = {} + mocks.activeWorktreeId = 'wt-1' }) /** @@ -459,3 +470,99 @@ describe('getWorkspaceFileBrowserOpenTarget', () => { }) }) }) + +// Why the reuse case is pinned here too: the address bar's way into a document must obey the same +// one-grant-per-document rule the preview action does. +describe('convertBrowserPageToWorkspaceDoc', () => { + const DOC_LOCATION = { + kind: 'workspace-doc' as const, + worktreeId: 'wt-1', + filePath: '/home/alice/report.html' + } + + // Why per-page and not the workspace mirror: a mixed workspace whose doc page is inactive + // mirrors docLocation null, and conversion is what makes mixed workspaces routine. + it('activates the tab already showing the document, even as an inactive page', () => { + mocks.browserTabsByWorktree = { + 'wt-1': [{ id: 'browser-9', docLocation: null }] + } + mocks.browserPagesByWorkspace = { + 'browser-9': [ + { id: 'page-web', worktreeId: 'wt-1' }, + { id: 'page-doc', worktreeId: 'wt-1', docLocation: DOC_LOCATION } + ] + } + + const outcome = convertBrowserPageToWorkspaceDoc('page-1', DOC_LOCATION) + + expect(outcome).toBe('activated-existing') + expect(mocks.convertBrowserPage).not.toHaveBeenCalled() + expect(mocks.setActiveBrowserTab).toHaveBeenCalledWith('browser-9') + expect(mocks.setActiveBrowserPage).toHaveBeenCalledWith('browser-9', 'page-doc') + }) + + it('converts the page in place when no tab shows the document', () => { + mocks.browserPagesByWorkspace = { + 'browser-1': [{ id: 'page-1', worktreeId: 'wt-1' }] + } + mocks.convertBrowserPage.mockReturnValue({ id: 'new-page' }) + + const outcome = convertBrowserPageToWorkspaceDoc('page-1', DOC_LOCATION) + + expect(outcome).toBe('converted') + expect(mocks.convertBrowserPage).toHaveBeenCalledWith( + 'page-1', + { kind: 'workspace-doc', docLocation: DOC_LOCATION }, + undefined + ) + }) + + // Why a document in another worktree opens there instead of converting here: a converted row + // keeps its worktree, and a row whose worktree differs from its document's can never be the + // reader's surface — its guest would never take focus and every link would be dead. + it('opens a document from another worktree in that worktree instead of converting', () => { + mocks.connectionId = 'ssh-1' + mocks.activeWorktreeId = 'wt-other' + mocks.browserPagesByWorkspace = { + 'browser-1': [{ id: 'page-1', worktreeId: 'wt-other' }] + } + + const outcome = convertBrowserPageToWorkspaceDoc('page-1', DOC_LOCATION) + + expect(outcome).toBe('opened-in-owning-worktree') + expect(mocks.convertBrowserPage).not.toHaveBeenCalled() + // The reader follows the document to its worktree — a tab opened out of sight is + // indistinguishable from nothing having happened. + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('wt-1') + // The document opened through the preview action's own door, in its owning worktree. + expect(mocks.createBrowserTab).toHaveBeenCalledWith( + ...docPreviewCall(DOC_LOCATION.filePath, { activate: true }) + ) + }) + + // Back and Forward mean "this tab, as it was": a history leg converts in place even when the + // document is also open elsewhere, or history would jump to the other tab forever. + it.each(['history-return', 'history-advance'] as const)( + 'skips reuse on the %s leg and converts in place', + (leg) => { + mocks.browserTabsByWorktree = { + 'wt-1': [{ id: 'browser-9', docLocation: null }] + } + mocks.browserPagesByWorkspace = { + 'browser-9': [{ id: 'page-doc', worktreeId: 'wt-1', docLocation: DOC_LOCATION }], + 'browser-1': [{ id: 'page-1', worktreeId: 'wt-1' }] + } + mocks.convertBrowserPage.mockReturnValue({ id: 'new-page' }) + + const outcome = convertBrowserPageToWorkspaceDoc('page-1', DOC_LOCATION, { leg }) + + expect(outcome).toBe('converted') + expect(mocks.setActiveBrowserTab).not.toHaveBeenCalled() + expect(mocks.convertBrowserPage).toHaveBeenCalledWith( + 'page-1', + { kind: 'workspace-doc', docLocation: DOC_LOCATION }, + { leg } + ) + } + ) +}) diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 8c3a8782fea..7094d87165c 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -13,6 +13,9 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { findSiblingGroupId } from '@/store/slices/tabs' import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location' +import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types' +import { findPage } from '@/store/slices/browser-page-records' +import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion' import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' export type PreviewableLanguage = 'html' @@ -218,6 +221,84 @@ export function openFileInBrowserTab(params: { return plan } +/** The tab a document is already open in, found by every PAGE's docLocation and not just the + * workspace mirror — a mixed workspace whose doc page is inactive mirrors null. */ +function findWorkspaceShowingDoc( + state: AppState, + docLocation: BrowserPageDocLocation +): { workspaceId: string; pageId: string } | null { + for (const tab of state.browserTabsByWorktree[docLocation.worktreeId] ?? []) { + const page = (state.browserPagesByWorkspace[tab.id] ?? []).find((candidate) => + browserPageDocLocationsEqual(candidate.docLocation ?? null, docLocation) + ) + if (page) { + return { workspaceId: tab.id, pageId: page.id } + } + } + return null +} + +/** + * The address bar's way into a workspace document: reuse before converting. A document already on + * screen is a request to look at it — two tabs of one document would each hold their own grant on + * the same file — so an existing tab wins and the current page stays what it was; only otherwise + * does the page convert in place. + */ +export function convertBrowserPageToWorkspaceDoc( + pageId: string, + docLocation: BrowserPageDocLocation, + options?: { leg?: BrowserPageConversionLeg } +): 'activated-existing' | 'opened-in-owning-worktree' | 'converted' | 'failed' { + const state = useAppStore.getState() + // Why a history leg skips reuse: Back and Forward both mean "this tab, as it was" — activating + // another tab showing the document would leave this one a web page with live provenance, so + // history could jump there forever. A history leg's document was this tab's own, so converting + // in place is right. + const isHistoryLeg = options?.leg !== undefined + const existing = isHistoryLeg ? null : findWorkspaceShowingDoc(state, docLocation) + if (existing) { + // Why the worktree switches first: activation is deliberately scoped to the active worktree, + // so without the switch a cross-worktree reuse would happen entirely out of sight. + if (state.activeWorktreeId !== docLocation.worktreeId) { + state.setActiveWorktree(docLocation.worktreeId) + } + if ( + !activateBrowserWorkspaceTab({ + worktreeId: docLocation.worktreeId, + workspaceId: existing.workspaceId + }) + ) { + state.setActiveBrowserTab(existing.workspaceId) + } + state.setActiveBrowserPage(existing.workspaceId, existing.pageId) + return 'activated-existing' + } + // Why another worktree's document opens a tab there instead of converting this one: a converted + // page keeps its workspace row, and a row whose worktree differs from its document's can never + // be the reader's surface under the per-worktree activity slots — its guest would never take + // focus, and every link in the document would be a dead end. + const owningPage = findPage(state.browserPagesByWorkspace, pageId) + if (!isHistoryLeg && owningPage && owningPage.worktreeId !== docLocation.worktreeId) { + // The reader follows the document to its worktree; opening it out of sight is indistinguishable + // from nothing having happened. + if (state.activeWorktreeId !== docLocation.worktreeId) { + state.setActiveWorktree(docLocation.worktreeId) + } + const plan = openFileInBrowserTab({ + filePath: docLocation.filePath, + worktreeId: docLocation.worktreeId + }) + if (plan.status === 'unsupported') { + toast.error(plan.message) + return 'failed' + } + return 'opened-in-owning-worktree' + } + return state.convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation }, options) + ? 'converted' + : 'failed' +} + export function canPreviewLanguage(language: string): language is PreviewableLanguage { return language === 'html' } diff --git a/src/renderer/src/lib/workspace-doc-address-input.test.ts b/src/renderer/src/lib/workspace-doc-address-input.test.ts new file mode 100644 index 00000000000..e4a0e7b7477 --- /dev/null +++ b/src/renderer/src/lib/workspace-doc-address-input.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveWorkspaceDocAddressTarget } from './workspace-doc-address-input' +import type { AppState } from '@/store/types' + +const plan = vi.hoisted(() => ({ + result: { status: 'doc-preview' } as + | { status: 'doc-preview' } + | { status: 'browser-tab'; url: string; title: string } + | { status: 'unsupported'; message: string; reason: 'no-channel' }, + calls: [] as { worktreeId: string; filePath: string }[] +})) +vi.mock('@/lib/file-preview', () => ({ + getWorkspaceFilePreviewPlan: (_state: unknown, worktreeId: string, filePath: string) => { + plan.calls.push({ worktreeId, filePath }) + return plan.result + } +})) + +const CURRENT = 'repo1::/home/alice/wt1' +const OTHER = 'repo1::/home/alice/wt2' + +function makeState(): AppState { + return { + getKnownWorktreeById: (id: string) => + id === CURRENT + ? { id: CURRENT, path: '/home/alice/wt1' } + : id === OTHER + ? { id: OTHER, path: '/home/alice/wt2' } + : undefined, + allWorktrees: () => [ + { id: CURRENT, path: '/home/alice/wt1' }, + { id: OTHER, path: '/home/alice/wt2' } + ], + folderWorkspaces: [{ id: 'folder-1', folderPath: '/srv/site' }] + } as unknown as AppState +} + +beforeEach(() => { + plan.result = { status: 'doc-preview' } + plan.calls.length = 0 +}) + +describe('resolveWorkspaceDocAddressTarget', () => { + it('resolves an absolute path inside the current worktree to its document', () => { + const target = resolveWorkspaceDocAddressTarget( + makeState(), + CURRENT, + '/home/alice/wt1/docs/report.html' + ) + expect(target).toEqual({ + status: 'workspace-doc', + docLocation: { + kind: 'workspace-doc', + worktreeId: CURRENT, + filePath: '/home/alice/wt1/docs/report.html' + } + }) + }) + + it('falls back to another known worktree only when the current one does not contain the path', () => { + const target = resolveWorkspaceDocAddressTarget( + makeState(), + CURRENT, + '/home/alice/wt2/index.html' + ) + expect(target).toMatchObject({ + status: 'workspace-doc', + docLocation: { worktreeId: OTHER, filePath: '/home/alice/wt2/index.html' } + }) + }) + + // Why most-specific-first: the outer root lexically contains the nested workspace's files, and + // attributing them to it selects the wrong owner — and so the wrong host — for the grant. + it('attributes a file in a nested workspace to the nested root, not the outer one', () => { + const state = makeState() + const stateWithNested = { + ...state, + allWorktrees: () => [...state.allWorktrees(), { id: 'repo2::/srv', path: '/srv' }] + } as typeof state + + const target = resolveWorkspaceDocAddressTarget( + stateWithNested, + CURRENT, + '/srv/site/index.html' + ) + + expect(target).toMatchObject({ + status: 'workspace-doc', + docLocation: { worktreeId: 'folder:folder-1' } + }) + }) + + it('resolves a folder workspace root under its folder key', () => { + const target = resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/srv/site/index.html') + expect(target).toMatchObject({ + status: 'workspace-doc', + docLocation: { worktreeId: 'folder:folder-1', filePath: '/srv/site/index.html' } + }) + }) + + it('leaves a path outside every known workspace to the URL pipeline', () => { + expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/etc/motd.html')).toEqual({ + status: 'not-a-workspace-doc' + }) + }) + + it('resolves a ./ relative path against the current worktree', () => { + const target = resolveWorkspaceDocAddressTarget(makeState(), CURRENT, './docs/report.html') + expect(target).toMatchObject({ + status: 'workspace-doc', + docLocation: { worktreeId: CURRENT, filePath: '/home/alice/wt1/docs/report.html' } + }) + }) + + // Containment is lexical, so dot segments are refused rather than resolved. + it('refuses dot segments in both the relative and the absolute form', () => { + expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, './a/../../etc/x.html')).toEqual({ + status: 'not-a-workspace-doc' + }) + expect( + resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/../wt9/x.html') + ).toEqual({ status: 'not-a-workspace-doc' }) + }) + + // "docs/report.html" is indistinguishable from a hostname with a path. + it('leaves bare relative text and non-previewable extensions to the URL pipeline', () => { + expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'docs/report.html')).toEqual({ + status: 'not-a-workspace-doc' + }) + expect( + resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/notes.md') + ).toEqual({ status: 'not-a-workspace-doc' }) + expect(plan.calls).toEqual([]) + }) + + it('keeps a local file on the file:// pipeline and surfaces an unsupported plan as its message', () => { + plan.result = { status: 'browser-tab', url: 'file:///x', title: 'x' } + expect( + resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/x.html') + ).toEqual({ status: 'not-a-workspace-doc' }) + + plan.result = { status: 'unsupported', message: 'no channel', reason: 'no-channel' } + expect( + resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/x.html') + ).toEqual({ status: 'unsupported', message: 'no channel' }) + }) +}) diff --git a/src/renderer/src/lib/workspace-doc-address-input.ts b/src/renderer/src/lib/workspace-doc-address-input.ts new file mode 100644 index 00000000000..941a905b1cf --- /dev/null +++ b/src/renderer/src/lib/workspace-doc-address-input.ts @@ -0,0 +1,110 @@ +import { isAbsoluteFilesystemPathInput } from '../../../shared/browser-url' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types' +import { getRelativePathInsideRoot, joinPath } from '@/lib/path' +import { getWorkspaceFilePreviewPlan } from '@/lib/file-preview' +import type { AppState } from '@/store/types' + +export type WorkspaceDocAddressTarget = + | { status: 'workspace-doc'; docLocation: BrowserPageDocLocation } + | { status: 'unsupported'; message: string } + | { status: 'not-a-workspace-doc' } + +/** Only what the doc preview renders. Bare relative input ("docs/x.html") is indistinguishable + * from a hostname, so it stays a URL; the explicit ./ prefix is the reader saying "path". */ +const PREVIEWABLE_PATH_PATTERN = /\.html?$/i + +function ownedWorktreeRoots( + state: AppState, + currentWorktreeId: string +): { worktreeId: string; root: string }[] { + const roots: { worktreeId: string; root: string }[] = [] + const currentPath = state.getKnownWorktreeById(currentWorktreeId)?.path ?? null + for (const worktree of state.allWorktrees()) { + if (worktree.id !== currentWorktreeId && worktree.path) { + roots.push({ worktreeId: worktree.id, root: worktree.path }) + } + } + for (const folder of state.folderWorkspaces) { + const worktreeId = folderWorkspaceKey(folder.id) + if (worktreeId !== currentWorktreeId && folder.folderPath) { + roots.push({ worktreeId, root: folder.folderPath }) + } + } + // Most-specific root first (after the current worktree), so a file inside a nested workspace is + // attributed to that workspace and not to the outer one that lexically contains it too. + roots.sort((a, b) => b.root.length - a.root.length) + return currentPath ? [{ worktreeId: currentWorktreeId, root: currentPath }, ...roots] : roots +} + +function planToTarget( + state: AppState, + worktreeId: string, + filePath: string +): WorkspaceDocAddressTarget { + const plan = getWorkspaceFilePreviewPlan(state, worktreeId, filePath) + if (plan.status === 'doc-preview') { + return { + status: 'workspace-doc', + docLocation: { kind: 'workspace-doc', worktreeId, filePath } + } + } + if (plan.status === 'unsupported') { + return { status: 'unsupported', message: plan.message } + } + // A local file keeps today's file:// tab; the URL pipeline handles it. + return { status: 'not-a-workspace-doc' } +} + +/** + * Decides whether typed address-bar input names a previewable workspace document, BEFORE the URL + * pipeline turns paths into file:// (which a client-hosted guest refuses and which would resolve + * on the wrong machine for a remote worktree). An absolute path is resolved against the current + * tab's worktree first, then every other known workspace root; a ./-relative path resolves against + * the current worktree only. Anything else — including bare relative text, which is + * indistinguishable from a hostname — falls through to the URL pipeline untouched. + */ +export function resolveWorkspaceDocAddressTarget( + state: AppState, + currentWorktreeId: string, + rawInput: string +): WorkspaceDocAddressTarget { + const input = rawInput.trim() + if (!PREVIEWABLE_PATH_PATTERN.test(input)) { + return { status: 'not-a-workspace-doc' } + } + + if (isAbsoluteFilesystemPathInput(input)) { + // Same refusal as the relative branch: the containment check is lexical, so an absolute path + // routed through dot segments could claim a workspace it then escapes. + if (input.split(/[\\/]+/).some((segment) => segment === '..' || segment === '.')) { + return { status: 'not-a-workspace-doc' } + } + for (const { worktreeId, root } of ownedWorktreeRoots(state, currentWorktreeId)) { + if (getRelativePathInsideRoot(input, root)) { + return planToTarget(state, worktreeId, input) + } + } + return { status: 'not-a-workspace-doc' } + } + + if (input.startsWith('./')) { + const relative = input.slice(2) + const root = state.getKnownWorktreeById(currentWorktreeId)?.path ?? null + if (!root || relative.length === 0) { + return { status: 'not-a-workspace-doc' } + } + // Why refused outright: joinPath does not resolve dot segments and the containment check is + // lexical, so "./a/../../x.html" would pass while naming a file outside the worktree. + if (relative.split(/[\\/]+/).some((segment) => segment === '..' || segment === '.')) { + return { status: 'not-a-workspace-doc' } + } + const filePath = joinPath(root, relative) + if (!getRelativePathInsideRoot(filePath, root)) { + return { status: 'not-a-workspace-doc' } + } + return planToTarget(state, currentWorktreeId, filePath) + } + + return { status: 'not-a-workspace-doc' } +} diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index 152642f65ae..63369fa873d 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -23,6 +23,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess remoteBrowserPageHandlesByPageId: {}, activeBrowserTabIdByWorktree: {}, browserUrlHistory, + workspaceDocHistory: [], unifiedTabsByWorktree: {}, groupsByWorktree: {}, layoutByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index c1292a9c74d..e0718028956 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -24,6 +24,7 @@ function createSnapshot( remoteBrowserPageHandlesByPageId: {}, activeBrowserTabIdByWorktree: {}, browserUrlHistory: [], + workspaceDocHistory: [], unifiedTabsByWorktree: {}, groupsByWorktree: {}, layoutByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-host-field-ownership.ts b/src/renderer/src/lib/workspace-session-host-field-ownership.ts index 0303fda7d4d..a44c7669bda 100644 --- a/src/renderer/src/lib/workspace-session-host-field-ownership.ts +++ b/src/renderer/src/lib/workspace-session-host-field-ownership.ts @@ -18,6 +18,7 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { activeWorkspaceExecutionHostId: 'global', activeTabId: 'global', browserUrlHistory: 'global', + workspaceDocHistory: 'global', // Why: SSH remains local-owned, so its connection identifiers stay in the local slice. activeConnectionIdsAtShutdown: 'global', // Why global: keyed by runtime environment rather than by worktree, and it is this client's diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index 6c10d2d0c54..59a2a9f67de 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -23,6 +23,7 @@ function createSnapshot( remoteBrowserPageHandlesByPageId: {}, activeBrowserTabIdByWorktree: {}, browserUrlHistory: [], + workspaceDocHistory: [], unifiedTabsByWorktree: {}, groupsByWorktree: {}, layoutByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index b67d7f3ebe4..17fd11a7787 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/workspace-session-state-types' import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-session-browser-history' +import { normalizeWorkspaceDocHistoryEntries } from '../../../shared/workspace-doc-history' import { buildActiveConnectionIdsAtShutdown, buildEditorSessionData, @@ -136,6 +137,9 @@ export function buildWorkspaceSessionPatch( if (changed.has('browserUrlHistory')) { patch.browserUrlHistory = normalizeBrowserHistoryEntries(snapshot.browserUrlHistory) } + if (changed.has('workspaceDocHistory')) { + patch.workspaceDocHistory = normalizeWorkspaceDocHistoryEntries(snapshot.workspaceDocHistory) + } if (changed.has('clientHostedBrowserCloseIntentsByEnvironment')) { patch.clientHostedBrowserCloseIntentsByEnvironment = snapshot.clientHostedBrowserCloseIntentsByEnvironment diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index e20f4195d3e..25c96b3bd2e 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -23,6 +23,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { browserPagesByWorkspace: true, activeBrowserTabIdByWorktree: true, browserUrlHistory: true, + workspaceDocHistory: true, remoteBrowserPageHandlesByPageId: true, unifiedTabsByWorktree: true, groupsByWorktree: true, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 73eaa482606..31ddc63704a 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -5,6 +5,7 @@ import type { } from '../../../shared/workspace-session-state-types' import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-session-browser-history' +import { normalizeWorkspaceDocHistoryEntries } from '../../../shared/workspace-doc-history' import type { AppState } from '../store' import type { OpenFile } from '../store/slices/editor' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' @@ -43,6 +44,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'browserPagesByWorkspace' | 'activeBrowserTabIdByWorktree' | 'browserUrlHistory' + | 'workspaceDocHistory' | 'remoteBrowserPageHandlesByPageId' | 'unifiedTabsByWorktree' | 'groupsByWorktree' @@ -81,6 +83,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'browserPagesByWorkspace', 'activeBrowserTabIdByWorktree', 'browserUrlHistory', + 'workspaceDocHistory', 'remoteBrowserPageHandlesByPageId', 'unifiedTabsByWorktree', 'groupsByWorktree', @@ -292,6 +295,7 @@ export function buildWorkspaceSessionPayload( ), // Why: enforce the history storage cap here so stale renderer state can't make every write stringify an oversized legacy array. browserUrlHistory: normalizeBrowserHistoryEntries(snapshot.browserUrlHistory), + workspaceDocHistory: normalizeWorkspaceDocHistoryEntries(snapshot.workspaceDocHistory ?? []), // Why: persist only layouts backed by real tabs so a reload can't restore a blank split pane from the split-before-tab midpoint. ...buildPersistedUnifiedTabSessionData(snapshot), activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown( diff --git a/src/renderer/src/runtime/client-hosted-browser-close-intent-replay.ts b/src/renderer/src/runtime/client-hosted-browser-close-intent-replay.ts index 09197886ea1..23405e38c43 100644 --- a/src/renderer/src/runtime/client-hosted-browser-close-intent-replay.ts +++ b/src/renderer/src/runtime/client-hosted-browser-close-intent-replay.ts @@ -14,6 +14,12 @@ const REPLAY_TIMEOUT_MS = 15_000 */ const PAGE_UNKNOWN_CODES = ['browser_tab_not_found', 'browser_no_tab', 'selector_not_found'] +/** True when a failed close means the runtime has definitively forgotten the page — nothing left + * to resurrect, so no intent should be recorded or kept. Anything else is a "not now". */ +export function isBrowserPageDefinitivelyGone(error: unknown): boolean { + return PAGE_UNKNOWN_CODES.some((code) => hasRuntimeRpcErrorCode(error, code)) +} + const replayingEnvironmentIds = new Set() export type ClientHostedBrowserCloseIntentReplayStore = { @@ -65,7 +71,7 @@ export async function replayClientHostedBrowserCloseIntents( ) settled.push(intent.browserPageId) } catch (error) { - if (PAGE_UNKNOWN_CODES.some((code) => hasRuntimeRpcErrorCode(error, code))) { + if (isBrowserPageDefinitivelyGone(error)) { settled.push(intent.browserPageId) continue } diff --git a/src/renderer/src/runtime/client-hosted-browser-close-intents.ts b/src/renderer/src/runtime/client-hosted-browser-close-intents.ts index 173511919b2..4b99a5d4fb4 100644 --- a/src/renderer/src/runtime/client-hosted-browser-close-intents.ts +++ b/src/renderer/src/runtime/client-hosted-browser-close-intents.ts @@ -21,6 +21,25 @@ export type PendingClientHostedBrowserClose = { worktreeId: string } +/** + * Whether an unheard close of this page would resurrect it — the qualification for a durable + * intent. Only client-hosted pages qualify: a server-placed page dies with the runtime that ran + * it, so there is nothing left to resurrect; a staged handle names a page the host never minted, + * so replaying a close at it would be answered as unknown forever. + */ +export function isDurableClientHostedBrowserHandle(handle: { + environmentId?: string + staged?: boolean + placement?: { kind?: string } | null + restoredClientHosted?: boolean +}): boolean { + return ( + (handle.environmentId?.trim().length ?? 0) > 0 && + handle.staged !== true && + (handle.placement?.kind === 'client' || handle.restoredClientHosted === true) + ) +} + /** * The client-hosted pages a workspace close leaves unaccounted for on their host. * @@ -44,10 +63,7 @@ export function collectPendingClientHostedBrowserCloses( !handle || !environmentId || !environmentIds.has(environmentId) || - // Why staged is excluded: the host was never told the page exists, so it has nothing to - // forget, and replaying a close at an id it never minted would be answered as unknown forever. - handle.staged === true || - !(handle.placement?.kind === 'client' || handle.restoredClientHosted === true) + !isDurableClientHostedBrowserHandle(handle) ) { return [] } diff --git a/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts b/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts new file mode 100644 index 00000000000..dd29d9a0e02 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '../lib/agent-status' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph' +import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' + +vi.mock('@/lib/doc-preview-grants', () => ({ + releaseDocPreviewGrant: vi.fn(), + ensureDocPreviewGrant: vi.fn(), + buildDocPreviewGrantRequest: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const WORKTREE_ID = 'repo1::/path/wt1' +const DOC_LOCATION = { + kind: 'workspace-doc' as const, + worktreeId: WORKTREE_ID, + filePath: '/home/alice/wt1/report/index.html' +} + +function createStoreWithWorktree(): ReturnType { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: WORKTREE_ID + }) + return store +} + +function worktreeSnapshot( + store: ReturnType +): ReturnType[number] | undefined { + return buildMobileSessionTabSnapshots(store.getState()).find( + (entry) => entry.worktree === WORKTREE_ID + ) +} + +function publishedBrowserWorkspaceIds(store: ReturnType): string[] { + return (worktreeSnapshot(store)?.tabs ?? []) + .filter((tab) => tab.type === 'browser') + .map((tab) => (tab as { browserWorkspaceId?: string }).browserWorkspaceId ?? '') +} + +function collectLayoutGroupIds(node: unknown, into: string[] = []): string[] { + if (!node || typeof node !== 'object') { + return into + } + const candidate = node as { type?: string; groupId?: string; first?: unknown; second?: unknown } + if (candidate.type === 'leaf' && candidate.groupId) { + into.push(candidate.groupId) + return into + } + collectLayoutGroupIds(candidate.first, into) + collectLayoutGroupIds(candidate.second, into) + return into +} + +/** The mechanical invariant across all three observables: nothing a published group or the layout + * names may be missing from the published tab list, whatever was held back and why. */ +function expectGroupsAndLayoutConsistent(store: ReturnType): void { + const snapshot = worktreeSnapshot(store) + const publishedTabIds = new Set((snapshot?.tabs ?? []).map((tab) => tab.id)) + for (const group of snapshot?.tabGroups ?? []) { + expect(group.tabOrder.filter((tabId) => !publishedTabIds.has(tabId))).toEqual([]) + expect((group.recentTabIds ?? []).filter((tabId) => !publishedTabIds.has(tabId))).toEqual([]) + expect(group.activeTabId === null || publishedTabIds.has(group.activeTabId)).toBe(true) + } + const groupIds = new Set((snapshot?.tabGroups ?? []).map((group) => group.id)) + for (const layoutGroupId of collectLayoutGroupIds(snapshot?.tabGroupLayout)) { + expect(groupIds.has(layoutGroupId)).toBe(true) + } +} + +function publishedUnifiedTabIds(store: ReturnType): Set { + return new Set((worktreeSnapshot(store)?.tabs ?? []).map((tab) => tab.id)) +} + +// The publish boundary is a predicate over docLocation, so a conversion must flip it in the same +// store commit that flips the page — no intermediate state may publish a document or hold back a +// web page. Driven through the real store actions, not a hand-built state, so the mirror path the +// conversion writes is the one the publisher reads. +describe('mobile publish across an address-bar conversion', () => { + it('starts publishing a doc tab the moment it converts to web, and stops on the way back', () => { + const store = createStoreWithWorktree() + // Presence precondition: an ordinary URL tab publishes throughout, so an empty answer would + // fail rather than pass by the publisher being broken for browser tabs entirely. + const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + const docTab = store.getState().createBrowserTab(WORKTREE_ID, '', { + docLocation: DOC_LOCATION, + title: 'index.html', + browserRuntimeEnvironmentId: null + }) + const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? '' + + expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id]) + + const webPage = store.getState().convertBrowserPage(docPageId, { + kind: 'web', + url: 'https://converted.example/' + }) + expect(webPage).not.toBeNull() + expect(publishedBrowserWorkspaceIds(store).sort()).toEqual([urlTab.id, docTab.id].sort()) + + const docPage = store.getState().convertBrowserPage(webPage?.id ?? '', { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + expect(docPage).not.toBeNull() + expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id]) + }) + + // Why groups and the layout are driven separately: the group projection was the historically + // untested half of the publish boundary, and a doc tab surviving in a group's tabOrder names a + // tab the phone is never sent. + it('holds the doc tab out of published groups and the layout, and admits it on conversion', () => { + const store = createStoreWithWorktree() + const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + // The doc tab gets a split group of its own, so holding it back must also take its group out + // of the projection AND its leaf out of the layout — the observable no earlier test drove. + const sourceGroupId = store.getState().activeGroupIdByWorktree[WORKTREE_ID] ?? '' + expect(sourceGroupId).not.toBe('') + const splitGroupId = store.getState().createEmptySplitGroup(WORKTREE_ID, sourceGroupId, 'right') + expect(splitGroupId).not.toBeNull() + const docTab = store.getState().createBrowserTab(WORKTREE_ID, '', { + docLocation: DOC_LOCATION, + title: 'index.html', + browserRuntimeEnvironmentId: null, + targetGroupId: splitGroupId ?? undefined + }) + const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? '' + const docUnifiedTabId = + (store.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).find( + (tab) => tab.contentType === 'browser' && tab.entityId === docTab.id + )?.id ?? '' + const urlUnifiedTabId = + (store.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).find( + (tab) => tab.contentType === 'browser' && tab.entityId === urlTab.id + )?.id ?? '' + expect(docUnifiedTabId).not.toBe('') + + const publishedGroupIds = (): Set => + new Set((worktreeSnapshot(store)?.tabGroups ?? []).map((group) => group.id)) + const layoutGroupIds = (): string[] => + collectLayoutGroupIds(worktreeSnapshot(store)?.tabGroupLayout) + + // Presence half: the URL tab's unified id is published; the doc tab's is not, and neither the + // group projection nor the layout tree names the split the doc tab sits alone in. + expect(publishedUnifiedTabIds(store).has(urlUnifiedTabId)).toBe(true) + expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(false) + expect(publishedGroupIds().has(sourceGroupId)).toBe(true) + expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(false) + expect(layoutGroupIds()).toEqual([sourceGroupId]) + expectGroupsAndLayoutConsistent(store) + + const webPage = store.getState().convertBrowserPage(docPageId, { + kind: 'web', + url: 'https://converted.example/' + }) + expect(webPage).not.toBeNull() + expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(true) + expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(true) + expect(layoutGroupIds().sort()).toEqual([sourceGroupId, splitGroupId ?? ''].sort()) + expectGroupsAndLayoutConsistent(store) + + const docPage = store.getState().convertBrowserPage(webPage?.id ?? '', { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + expect(docPage).not.toBeNull() + expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(false) + expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(false) + expect(layoutGroupIds()).toEqual([sourceGroupId]) + expectGroupsAndLayoutConsistent(store) + }) +}) diff --git a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts new file mode 100644 index 00000000000..a315a561b05 --- /dev/null +++ b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { createTestStore, makeWorktree } from './store-test-helpers' + +const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) +vi.mock('@/lib/doc-preview-grants', () => ({ + releaseDocPreviewGrant: vi.fn(), + ensureDocPreviewGrant: vi.fn(), + buildDocPreviewGrantRequest: vi.fn() +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc } +}) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const WORKTREE_ID = 'repo1::/path/wt1' +const DOC_LOCATION = { + kind: 'workspace-doc' as const, + worktreeId: WORKTREE_ID, + filePath: '/home/alice/wt1/report/index.html' +} + +function createStoreWithClientHostedPage(handleOverrides: Record = {}): { + store: ReturnType + pageId: string +} { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: WORKTREE_ID + }) + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/') + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + store.setState((s) => ({ + remoteBrowserPageHandlesByPageId: { + ...s.remoteBrowserPageHandlesByPageId, + [pageId]: { + environmentId: 'env-1', + remotePageId: 'remote-page-1', + placement: { kind: 'client' }, + ...handleOverrides + } as never + } + })) + mocks.callRuntimeRpc.mockClear() + return { store, pageId } +} + +function recordedIntents(store: ReturnType): string[] { + return (store.getState().clientHostedBrowserCloseIntentsByEnvironment['env-1'] ?? []).map( + (intent) => intent.browserPageId + ) +} + +// The runtime persists client-hosted pages so they survive its restarts — which turns a close it +// never heard into a resurrection. Every single-page close of such a page (plain close AND the +// address-bar conversion) must leave a durable intent behind when the runtime does not answer, +// so the reconnect replay can deliver the close late instead of the page coming back. +describe('durable close intents for unheard client-hosted page closes', () => { + it('records an intent when the conversion-away close never reaches the runtime', async () => { + const { store, pageId } = createStoreWithClientHostedPage() + mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + + const converted = store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + expect(converted).not.toBeNull() + await vi.waitFor(() => expect(recordedIntents(store)).toEqual(['remote-page-1'])) + }) + + it('records an intent when the plain page close never reaches the runtime', async () => { + const { store, pageId } = createStoreWithClientHostedPage() + mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + + store.getState().closeBrowserPage(pageId) + + await vi.waitFor(() => expect(recordedIntents(store)).toEqual(['remote-page-1'])) + }) + + it('records nothing when the close succeeds', async () => { + const { store, pageId } = createStoreWithClientHostedPage() + mocks.callRuntimeRpc.mockResolvedValue({}) + + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + expect(recordedIntents(store)).toEqual([]) + }) + + it('records nothing when the runtime has definitively forgotten the page', async () => { + const { store, pageId } = createStoreWithClientHostedPage() + mocks.callRuntimeRpc.mockRejectedValue(new Error('browser_tab_not_found')) + + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + expect(recordedIntents(store)).toEqual([]) + }) + + it('records nothing for a server-placed page, which dies with its runtime', async () => { + const { store, pageId } = createStoreWithClientHostedPage({ placement: { kind: 'server' } }) + mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + expect(recordedIntents(store)).toEqual([]) + }) + + it('records nothing for a staged page the runtime never minted', async () => { + const { store, pageId } = createStoreWithClientHostedPage({ staged: true }) + mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + expect(recordedIntents(store)).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/slices/browser-page-conversion.test.ts b/src/renderer/src/store/slices/browser-page-conversion.test.ts new file mode 100644 index 00000000000..7a0703b52d1 --- /dev/null +++ b/src/renderer/src/store/slices/browser-page-conversion.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import { browserPageSchema } from '../../../../shared/workspace-session-browser-schema' +import { createTestStore, makeWorktree } from './store-test-helpers' + +const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn(), callRuntimeRpc: vi.fn() })) +vi.mock('@/lib/doc-preview-grants', () => ({ + releaseDocPreviewGrant: mocks.releaseDocPreviewGrant, + ensureDocPreviewGrant: vi.fn(), + buildDocPreviewGrantRequest: vi.fn() +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({}) } +}) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const WORKTREE_ID = 'repo1::/path/wt1' +const DOC_LOCATION = { + kind: 'workspace-doc' as const, + worktreeId: WORKTREE_ID, + filePath: '/home/alice/wt1/report/index.html' +} +const OTHER_DOC_LOCATION = { + kind: 'workspace-doc' as const, + worktreeId: WORKTREE_ID, + filePath: '/home/alice/wt1/report/details.html' +} +const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html` + +function createStoreWithWorktree(): ReturnType { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: WORKTREE_ID + }) + return store +} + +function createDocTab(store: ReturnType): { + tabId: string + pageId: string +} { + const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { + docLocation: DOC_LOCATION, + title: 'index.html', + browserRuntimeEnvironmentId: null + }) + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + return { tabId: tab.id, pageId } +} + +describe('convertBrowserPage doc→web', () => { + it('replaces the page under a fresh id in the same workspace row', () => { + const store = createStoreWithWorktree() + const { tabId, pageId } = createDocTab(store) + + const converted = store.getState().convertBrowserPage(pageId, { + kind: 'web', + url: 'https://example.com/' + }) + + expect(converted).not.toBeNull() + // Fresh id: the old id must never resurface in the other registry half. + expect(converted?.id).not.toBe(pageId) + const pages = store.getState().browserPagesByWorkspace[tabId] ?? [] + expect(pages.map((page) => page.id)).toEqual([converted?.id]) + expect(pages[0]?.url).toBe('https://example.com/') + expect(pages[0]?.docLocation ?? null).toBeNull() + // Ownership stays client-local: an inferred runtime would render a streamed pane with no handle. + expect(pages[0]?.browserRuntimeEnvironmentId).toBeNull() + // The workspace row survives: same tab id, mirror flipped in the same commit. + const workspace = store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0] + expect(workspace?.id).toBe(tabId) + expect(workspace?.url).toBe('https://example.com/') + expect(workspace?.docLocation ?? null).toBeNull() + expect(workspace?.activePageId).toBe(converted?.id) + }) + + it('releases the old grant exactly once, after the store stops naming the document', () => { + const store = createStoreWithWorktree() + const { pageId } = createDocTab(store) + mocks.releaseDocPreviewGrant.mockClear() + + store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + + expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1) + expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId) + }) + + it('records one level of provenance so Back can return to the document', () => { + const store = createStoreWithWorktree() + const { tabId, pageId } = createDocTab(store) + + store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + + const page = store.getState().browserPagesByWorkspace[tabId]?.[0] + expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) + // Provenance survives the session schema (z.object strips what it does not name). + const parsed = browserPageSchema.parse(page) + expect(parsed.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) + }) + + it('refuses to convert a web page to web — that is navigation, not conversion', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + + const converted = store + .getState() + .convertBrowserPage(pageId, { kind: 'web', url: 'https://other.example/' }) + + expect(converted).toBeNull() + expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id).toBe(pageId) + expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.url).toBe('https://example.com/') + }) +}) + +describe('convertBrowserPage web→doc', () => { + it('replaces the page with a client-local document page and mirrors it', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + mocks.releaseDocPreviewGrant.mockClear() + + const converted = store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + expect(converted).not.toBeNull() + expect(converted?.id).not.toBe(pageId) + const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] + expect(page?.docLocation).toEqual(DOC_LOCATION) + expect(page?.url).toBe(ORCA_BROWSER_BLANK_URL) + // Client-local by construction: the grant is minted on this desktop. + expect(page?.browserRuntimeEnvironmentId).toBeNull() + expect(page?.convertedFrom).toEqual({ kind: 'url', url: 'https://example.com/' }) + const workspace = store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0] + expect(workspace?.docLocation).toEqual(DOC_LOCATION) + expect(workspace?.url).toBe(ORCA_BROWSER_BLANK_URL) + // A URL page owns no grant; nothing to release on this direction. + expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled() + }) + + it('never lets a grant url into the converted page, even handed one directly', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] + const written = JSON.stringify([page, store.getState().browserTabsByWorktree[WORKTREE_ID]]) + expect(written).not.toContain('orca-preview://') + }) +}) + +describe('convertBrowserPage doc→doc retarget', () => { + it('replaces the page and its grant for a different document', () => { + const store = createStoreWithWorktree() + const { tabId, pageId } = createDocTab(store) + mocks.releaseDocPreviewGrant.mockClear() + + const converted = store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: OTHER_DOC_LOCATION + }) + + expect(converted?.id).not.toBe(pageId) + expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId) + const page = store.getState().browserPagesByWorkspace[tabId]?.[0] + expect(page?.docLocation).toEqual(OTHER_DOC_LOCATION) + expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) + }) + + it('is a no-op for the same document', () => { + const store = createStoreWithWorktree() + const { tabId, pageId } = createDocTab(store) + mocks.releaseDocPreviewGrant.mockClear() + + const converted = store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + expect(converted).toBeNull() + expect(store.getState().browserPagesByWorkspace[tabId]?.[0]?.id).toBe(pageId) + expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled() + }) +}) + +describe('convertBrowserPage history legs', () => { + it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', () => { + const store = createStoreWithWorktree() + const { tabId, pageId } = createDocTab(store) + + const webPage = store.getState().convertBrowserPage(pageId, { + kind: 'web', + url: 'https://example.com/' + }) + expect(webPage?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) + expect(webPage?.convertedTo ?? null).toBeNull() + + const returned = store + .getState() + .convertBrowserPage( + webPage?.id ?? '', + { kind: 'workspace-doc', docLocation: DOC_LOCATION }, + { leg: 'history-return' } + ) + + expect(returned?.docLocation).toEqual(DOC_LOCATION) + expect(returned?.convertedFrom ?? null).toBeNull() + // Ownership rides the forward pointer too — explicit null keeps the page client-local. + const expectedForward = { + kind: 'url', + url: 'https://example.com/', + browserRuntimeEnvironmentId: null + } + expect(returned?.convertedTo).toEqual(expectedForward) + // Forward's target survives the session schema (z.object strips what it does not name). + const parsed = browserPageSchema.parse(store.getState().browserPagesByWorkspace[tabId]?.[0]) + expect(parsed.convertedTo).toEqual(expectedForward) + }) + + it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', () => { + const store = createStoreWithWorktree() + const { pageId } = createDocTab(store) + const webPage = store + .getState() + .convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + const docPage = store + .getState() + .convertBrowserPage( + webPage?.id ?? '', + { kind: 'workspace-doc', docLocation: DOC_LOCATION }, + { leg: 'history-return' } + ) + + const advanced = store + .getState() + .convertBrowserPage( + docPage?.id ?? '', + { kind: 'web', url: 'https://example.com/' }, + { leg: 'history-advance' } + ) + + expect(advanced?.url).toBe('https://example.com/') + expect(advanced?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) + expect(advanced?.convertedTo ?? null).toBeNull() + }) +}) + +describe('convertBrowserPage placement and activation', () => { + it('converts a background page without stealing the active page', () => { + const store = createStoreWithWorktree() + const { tabId, pageId: docPageId } = createDocTab(store) + const webPage = store.getState().createBrowserPage(tabId, 'https://active.example/', { + activate: true + }) + + const converted = store.getState().convertBrowserPage(docPageId, { + kind: 'web', + url: 'https://converted.example/' + }) + + const pages = store.getState().browserPagesByWorkspace[tabId] ?? [] + // Replacement holds the old page's position. + expect(pages.map((page) => page.url)).toEqual([ + 'https://converted.example/', + 'https://active.example/' + ]) + const workspace = store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0] + expect(workspace?.activePageId).toBe(webPage?.id) + // The mirror still shows the active page, not the converted one. + expect(workspace?.url).toBe('https://active.example/') + expect(converted?.id).not.toBe(docPageId) + }) + + it('clears the old page id from every per-page side table', () => { + const store = createStoreWithWorktree() + const { pageId } = createDocTab(store) + // Every table SEEDED, so a deletion that stops running fails instead of passing vacuously. + store.setState((s) => ({ + pendingAddressBarFocusByPageId: { ...s.pendingAddressBarFocusByPageId, [pageId]: true }, + pendingAddressBarFocusByTabId: { ...s.pendingAddressBarFocusByTabId, [pageId]: true }, + browserAnnotationsByPageId: { + ...s.browserAnnotationsByPageId, + [pageId]: [{ id: 'annotation-1' }] as never + }, + browserCertificateFailuresByPageId: { + ...s.browserCertificateFailuresByPageId, + [pageId]: { challengeId: 'challenge-1' } as never + } + })) + + store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + + expect(store.getState().pendingAddressBarFocusByPageId[pageId]).toBeUndefined() + expect(store.getState().pendingAddressBarFocusByTabId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + }) + + // The leg the remote pane's address bar reaches: converting a runtime-owned page must close the + // host's page and drop the handle, or a ghost page stays open on the remote host. + it('closes the remote page and drops its handle when a runtime-owned page converts', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/') + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + store.setState((s) => ({ + remoteBrowserPageHandlesByPageId: { + ...s.remoteBrowserPageHandlesByPageId, + [pageId]: { environmentId: 'env-1', remotePageId: 'remote-page-1' } as never + } + })) + mocks.callRuntimeRpc.mockClear() + + const converted = store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + + expect(converted).not.toBeNull() + expect(store.getState().remoteBrowserPageHandlesByPageId[pageId]).toBeUndefined() + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'browser.tabClose', + expect.objectContaining({ page: 'remote-page-1' }), + expect.anything() + ) + }) + + // Ownership rides provenance both ways, or Back silently moves a remote tab's browsing onto + // this desktop (the ssh-execution-boundary concern). + it('carries runtime ownership through provenance and honors it on the return leg', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/', { + browserRuntimeEnvironmentId: 'env-1' + }) + const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + + const docPage = store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: DOC_LOCATION + }) + expect(docPage?.convertedFrom).toEqual({ + kind: 'url', + url: 'https://remote.example/', + browserRuntimeEnvironmentId: 'env-1' + }) + + const returned = store.getState().convertBrowserPage( + docPage?.id ?? '', + { + kind: 'web', + url: 'https://remote.example/', + browserRuntimeEnvironmentId: 'env-1' + }, + { leg: 'history-return' } + ) + expect(returned?.browserRuntimeEnvironmentId).toBe('env-1') + }) + + it('returns a worktree-inferred remote page as inferred, never as client-local', () => { + const store = createStoreWithWorktree() + const { pageId } = createDocTab(store) + // The return leg says "inferred" by passing the property explicitly undefined. + const returned = store + .getState() + .convertBrowserPage( + pageId, + { kind: 'web', url: 'https://remote.example/', browserRuntimeEnvironmentId: undefined }, + { leg: 'history-return' } + ) + expect(returned).not.toBeNull() + expect('browserRuntimeEnvironmentId' in (returned ?? {})).toBe(false) + }) + + it('returns null for an unknown page and changes nothing', () => { + const store = createStoreWithWorktree() + createDocTab(store) + const before = store.getState().browserPagesByWorkspace + + const converted = store + .getState() + .convertBrowserPage('missing-page', { kind: 'web', url: 'https://example.com/' }) + + expect(converted).toBeNull() + expect(store.getState().browserPagesByWorkspace).toBe(before) + }) +}) diff --git a/src/renderer/src/store/slices/browser-page-conversion.ts b/src/renderer/src/store/slices/browser-page-conversion.ts new file mode 100644 index 00000000000..b7874f557c7 --- /dev/null +++ b/src/renderer/src/store/slices/browser-page-conversion.ts @@ -0,0 +1,137 @@ +import type { + BrowserPage, + BrowserPageConversionOrigin, + BrowserPageDocLocation, + BrowserWorkspace +} from '../../../../shared/browser-workspace-types' +import { browserPageDocLocationsEqual } from '../../../../shared/browser-page-doc-location' +import { + buildBrowserPage, + findPage, + findWorkspace, + mirrorWorkspaceFromActivePage +} from './browser-page-records' + +/** + * What the address bar asked a page to become. A `web` target's runtime owner is the caller's + * decision (a converted page in a remote worktree stays client-hosted, like the doc page it + * replaces); a `workspace-doc` target is always client-local, because the grant is minted here. + */ +export type BrowserPageConversionTarget = + | { kind: 'web'; url: string; browserRuntimeEnvironmentId?: string | null } + | { kind: 'workspace-doc'; docLocation: BrowserPageDocLocation } + +/** + * Which crossing of the conversion boundary this is. An address-bar conversion (absent) starts a + * new two-entry history: the destination records where it came from. `history-return` is Back + * crossing back — it consumes `convertedFrom` and records `convertedTo` on the page it restores, + * so Forward can re-cross. `history-advance` is Forward re-crossing — it consumes `convertedTo` + * and records `convertedFrom` again, so the pair ping-pongs exactly like two history entries. + */ +export type BrowserPageConversionLeg = 'history-return' | 'history-advance' + +export type BrowserPageConversionPlan = { + oldPage: BrowserPage + newPage: BrowserPage + workspace: BrowserWorkspace + nextPages: BrowserPage[] + nextWorkspace: BrowserWorkspace +} + +/** + * A conversion never mutates a page across the doc/web boundary — it replaces the page, under a + * fresh id, inside the surviving workspace row. The fresh id is what keeps the two main-process + * registry halves disjoint by construction: the old id dies with its half (grant revoke, guest + * teardown), the new id registers cleanly in the other, and no ordering between those two + * asynchronous teardowns is ever load-bearing. + */ +export function planBrowserPageConversion( + state: { + browserTabsByWorktree: Record + browserPagesByWorkspace: Record + }, + pageId: string, + target: BrowserPageConversionTarget, + options?: { leg?: BrowserPageConversionLeg } +): BrowserPageConversionPlan | null { + const oldPage = findPage(state.browserPagesByWorkspace, pageId) + if (!oldPage) { + return null + } + const workspace = findWorkspace(state.browserTabsByWorktree, oldPage.workspaceId) + if (!workspace) { + return null + } + if (target.kind === 'web' && !oldPage.docLocation) { + // Why refused: a web page taking a new URL is navigation, and it must ride the navigation + // doors (loading state, history, guest reuse) — conversion would silently drop all of them. + return null + } + if ( + target.kind === 'workspace-doc' && + browserPageDocLocationsEqual(oldPage.docLocation ?? null, target.docLocation) + ) { + return null + } + + const departed: BrowserPageConversionOrigin = oldPage.docLocation + ? { kind: 'workspace-doc', docLocation: oldPage.docLocation } + : // Why the stored url and not the guest's: the store url passed every fence on its way in, + // so provenance can be persisted without opening a new door. Ownership rides along — + // absent stays absent, so a worktree-inferred remote page returns as one. + { + kind: 'url', + url: oldPage.url, + ...(oldPage.browserRuntimeEnvironmentId !== undefined + ? { browserRuntimeEnvironmentId: oldPage.browserRuntimeEnvironmentId } + : {}) + } + // The crossed pointer is consumed by construction — the new page never inherits either field — + // so each leg leaves exactly one pointer behind and history stays two entries deep. + const convertedFrom = options?.leg === 'history-return' ? null : departed + const convertedTo = options?.leg === 'history-return' ? departed : null + + const newPage: BrowserPage = { + ...(target.kind === 'workspace-doc' + ? buildBrowserPage( + workspace.id, + oldPage.worktreeId, + '', + undefined, + // Why explicitly client-local: the document is read through a grant this desktop mints, + // so the page never belongs to a remote runtime even when the worktree does. + null, + undefined, + target.docLocation + ) + : buildBrowserPage( + workspace.id, + oldPage.worktreeId, + target.url, + undefined, + // Why the old page's ownership and never the inferred default: an omitted id infers the + // worktree's runtime and renders a streamed pane with no remote handle behind it. The + // page being converted lived on this desktop, so its replacement does too. The `in` + // check lets Back's return leg say "inferred" explicitly (property present, undefined) + // when it restores a worktree-owned remote page. + 'browserRuntimeEnvironmentId' in target + ? target.browserRuntimeEnvironmentId + : (oldPage.browserRuntimeEnvironmentId ?? null) + )), + ...(convertedFrom ? { convertedFrom } : {}), + ...(convertedTo ? { convertedTo } : {}) + } + + const currentPages = state.browserPagesByWorkspace[workspace.id] ?? [] + const nextPages = currentPages.map((page) => (page.id === pageId ? newPage : page)) + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: workspace.activePageId === pageId ? newPage.id : workspace.activePageId, + pageIds: nextPages.map((page) => page.id) + }, + nextPages + ) + + return { oldPage, newPage, workspace, nextPages, nextWorkspace } +} diff --git a/src/renderer/src/store/slices/browser-page-records.ts b/src/renderer/src/store/slices/browser-page-records.ts new file mode 100644 index 00000000000..a1715893421 --- /dev/null +++ b/src/renderer/src/store/slices/browser-page-records.ts @@ -0,0 +1,206 @@ +import type { + BrowserPage, + BrowserPageDocLocation, + BrowserWorkspace +} from '../../../../shared/browser-workspace-types' +import { browserPageDocLocationsEqual } from '../../../../shared/browser-page-doc-location' +import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import { redactKagiSessionToken } from '../../../../shared/browser-url' +import { isDocPreviewUrl } from '../../../../shared/doc-preview-scheme' +import { basename } from '@/lib/path' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { translate } from '@/i18n/i18n' + +export function normalizeUrl(url: string): string { + const trimmed = url.trim() + if (trimmed.length === 0) { + return 'about:blank' + } + // Why: redact at this single URL sink so the Kagi bearer token can't reach BrowserPage.url, which is persisted to disk. + return redactKagiSessionToken(trimmed) +} + +export function normalizeBrowserTitle( + title: string | null | undefined, + url: string, + docLocation?: BrowserPageDocLocation | null +): string { + if (docLocation) { + // Why a document page cannot go through the checks below: its url is the blank URL by design, + // which would name every previewed document "New Tab". Its fallback is the file it shows. + // Why the grant URL is refused rather than trusted: Chromium reports the URL as the title when + // a document declares none, and this title is stored, persisted and mirrored onto the tab — + // a grant reaching any of them would outlive the grant and name a document nothing can read. + if (!title || isDocPreviewUrl(title)) { + return basename(docLocation.filePath) || docLocation.filePath + } + return title + } + if ( + url === 'about:blank' || + url === ORCA_BROWSER_BLANK_URL || + title === 'about:blank' || + title === ORCA_BROWSER_BLANK_URL || + !title + ) { + // Why: don't surface the internal blank-guest URL as a title (leaks an impl detail, looks broken); show "New Tab" instead. + return 'New Tab' + } + return title +} + +export function buildBrowserPage( + workspaceId: string, + worktreeId: string, + url: string, + title?: string, + browserRuntimeEnvironmentId?: string | null, + browserPageId?: string, + docLocation?: BrowserPageDocLocation +): BrowserPage { + // Why the url is overridden rather than trusted: this is the one place a page's url is minted, + // and it is read by persistence, the mobile publisher, history and the address bar. A grant URL + // reaching any of them would outlive the grant and name a document that machine cannot read. + const normalizedUrl = docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(url) + return { + id: browserPageId ?? createBrowserUuid(), + workspaceId, + worktreeId, + url: normalizedUrl, + title: normalizeBrowserTitle(title, normalizedUrl, docLocation), + // Why: blank pages mount an inert guest (no real navigation); marking them loading would flash the loading affordance. + loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: Date.now(), + ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}), + ...(docLocation ? { docLocation } : {}) + } +} + +export function buildWorkspaceFromPage( + id: string, + worktreeId: string, + page: BrowserPage, + pageIds: string[], + sessionProfileId?: string | null, + sessionPartition?: string | null +): BrowserWorkspace { + return { + id, + worktreeId, + sessionProfileId: sessionProfileId ?? null, + sessionPartition: sessionPartition ?? null, + activePageId: page.id, + pageIds, + url: page.url, + title: page.title, + loading: page.loading, + faviconUrl: page.faviconUrl, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward, + loadError: page.loadError, + createdAt: page.createdAt, + docLocation: page.docLocation ?? null + } +} + +export function mirrorWorkspaceFromActivePage( + workspace: BrowserWorkspace, + pages: BrowserPage[] +): BrowserWorkspace { + const activePage = pages.find((page) => page.id === workspace.activePageId) ?? null + if (!activePage) { + return { + ...workspace, + activePageId: null, + pageIds: pages.map((page) => page.id), + url: 'about:blank', + title: translate('auto.store.slices.browser.08fc23631d', 'Browser'), + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + docLocation: null + } + } + return { + ...workspace, + activePageId: activePage.id, + pageIds: pages.map((page) => page.id), + url: activePage.url, + title: activePage.title, + loading: activePage.loading, + faviconUrl: activePage.faviconUrl, + canGoBack: activePage.canGoBack, + canGoForward: activePage.canGoForward, + loadError: activePage.loadError, + docLocation: activePage.docLocation ?? null + } +} + +export function browserWorkspaceMirrorFieldsEqual( + workspace: BrowserWorkspace, + mirrored: BrowserWorkspace +): boolean { + const workspacePageIds = workspace.pageIds ?? [] + const mirroredPageIds = mirrored.pageIds ?? [] + return ( + workspace.activePageId === mirrored.activePageId && + workspacePageIds.length === mirroredPageIds.length && + workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]) && + workspace.url === mirrored.url && + workspace.title === mirrored.title && + workspace.loading === mirrored.loading && + workspace.faviconUrl === mirrored.faviconUrl && + workspace.canGoBack === mirrored.canGoBack && + workspace.canGoForward === mirrored.canGoForward && + workspace.loadError === mirrored.loadError && + browserPageDocLocationsEqual(workspace.docLocation ?? null, mirrored.docLocation ?? null) + ) +} + +const browserWorkspaceByIdCache = new WeakMap< + Record, + Map +>() +const browserPageByIdCache = new WeakMap, Map>() + +export function findWorkspace( + browserTabsByWorktree: Record, + workspaceId: string +): BrowserWorkspace | null { + const cached = browserWorkspaceByIdCache.get(browserTabsByWorktree) + if (cached) { + return cached.get(workspaceId) ?? null + } + const workspaceById = new Map() + for (const workspaces of Object.values(browserTabsByWorktree)) { + for (const workspace of workspaces) { + workspaceById.set(workspace.id, workspace) + } + } + browserWorkspaceByIdCache.set(browserTabsByWorktree, workspaceById) + return workspaceById.get(workspaceId) ?? null +} + +export function findPage( + browserPagesByWorkspace: Record, + pageId: string +): BrowserPage | null { + const cached = browserPageByIdCache.get(browserPagesByWorkspace) + if (cached) { + return cached.get(pageId) ?? null + } + const pageById = new Map() + for (const pages of Object.values(browserPagesByWorkspace)) { + for (const page of pages) { + pageById.set(page.id, page) + } + } + browserPageByIdCache.set(browserPagesByWorkspace, pageById) + return pageById.get(pageId) ?? null +} diff --git a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts index d311216a807..c60fe7bd2f7 100644 --- a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts @@ -460,3 +460,57 @@ describe('a browser page that shows a workspace document', () => { ) }) }) + +// The provenance fields hold a url the way page.url does, so the disk door applies the same +// prefix fence: a session file carrying the preview scheme sheds the provenance, never history. +describe('conversion provenance at the session schema door', () => { + const PAGE_ROW = { + id: 'page-1', + workspaceId: 'ws-1', + worktreeId: WORKTREE_ID, + url: 'https://example.com/', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + + it('sheds url provenance carrying the preview scheme and keeps ordinary provenance', () => { + const tainted = browserPageSchema.parse({ + ...PAGE_ROW, + convertedFrom: { kind: 'url', url: LIVE_GRANT_URL } + }) + expect(tainted.convertedFrom ?? null).toBeNull() + + const kept = browserPageSchema.parse({ + ...PAGE_ROW, + convertedFrom: { + kind: 'url', + url: 'https://example.com/', + browserRuntimeEnvironmentId: 'env-1' + } + }) + expect(kept.convertedFrom).toEqual({ + kind: 'url', + url: 'https://example.com/', + browserRuntimeEnvironmentId: 'env-1' + }) + }) + + it('applies the same fence to convertedTo, Forward’s side of the crossing', () => { + const tainted = browserPageSchema.parse({ + ...PAGE_ROW, + convertedTo: { kind: 'url', url: LIVE_GRANT_URL } + }) + expect(tainted.convertedTo ?? null).toBeNull() + + const kept = browserPageSchema.parse({ + ...PAGE_ROW, + convertedTo: { kind: 'url', url: 'https://example.com/' } + }) + expect(kept.convertedTo).toEqual({ kind: 'url', url: 'https://example.com/' }) + }) +}) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 21fc58ebee5..f2d657eb771 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -14,15 +14,16 @@ import type { BrowserViewportPresetId, BrowserWorkspace } from '../../../../shared/browser-workspace-types' -import { browserPageDocLocationsEqual } from '../../../../shared/browser-page-doc-location' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' import { clearClientHostedBrowserCloseIntents, + isDurableClientHostedBrowserHandle, recordClientHostedBrowserCloseIntents, type ClientHostedBrowserCloseIntentsByEnvironment, type PendingClientHostedBrowserClose } from '@/runtime/client-hosted-browser-close-intents' +import { isBrowserPageDefinitivelyGone } from '@/runtime/client-hosted-browser-close-intent-replay' import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { redactKagiSessionToken } from '../../../../shared/browser-url' @@ -31,10 +32,30 @@ import { normalizeBrowserHistoryEntries, normalizeBrowserHistoryUrl } from '../../../../shared/workspace-session-browser-history' +import { + MAX_WORKSPACE_DOC_HISTORY_ENTRIES, + normalizeWorkspaceDocHistoryEntries, + normalizeWorkspaceDocHistoryTitle, + type WorkspaceDocHistoryEntry +} from '../../../../shared/workspace-doc-history' +import { browserPageDocLocationsEqual } from '../../../../shared/browser-page-doc-location' import { destroyWorkspaceWebviews } from './browser-webview-cleanup' +import { + browserWorkspaceMirrorFieldsEqual, + buildBrowserPage, + buildWorkspaceFromPage, + findPage, + findWorkspace, + mirrorWorkspaceFromActivePage, + normalizeBrowserTitle, + normalizeUrl +} from './browser-page-records' +import { + planBrowserPageConversion, + type BrowserPageConversionLeg, + type BrowserPageConversionTarget +} from './browser-page-conversion' import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' -import { basename } from '@/lib/path' -import { isDocPreviewUrl } from '../../../../shared/doc-preview-scheme' import { getRecentlyClosedTabPosition, restoreRecentlyClosedTabPosition, @@ -259,6 +280,14 @@ export type BrowserSlice = { options?: CreateBrowserPageOptions ) => BrowserPage | null closeBrowserPage: (pageId: string) => void + // Why replacement and not mutation: a page's kind (doc vs web) is immutable for its life, so the + // address bar converts by replacing the page — fresh id, same workspace row — keeping the two + // main-process registry halves disjoint by construction. + convertBrowserPage: ( + pageId: string, + target: BrowserPageConversionTarget, + options?: { leg?: BrowserPageConversionLeg } + ) => BrowserPage | null reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null setActiveBrowserPage: (workspaceId: string, pageId: string) => void // Focus that never yanks the user across worktrees: per-worktree slots always update, globals only when targeting the active worktree. @@ -335,50 +364,19 @@ export type BrowserSlice = { clearDefaultSessionCookies: () => Promise browserUrlHistory: BrowserHistoryEntry[] addBrowserHistoryEntry: (url: string, title: string) => void + workspaceDocHistory: WorkspaceDocHistoryEntry[] + /** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */ + recordWorkspaceDocVisit: ( + docLocation: BrowserPageDocLocation, + title: string | null, + options?: { bump?: boolean } + ) => void clearBrowserHistory: () => void defaultBrowserSessionProfileId: string | null defaultBrowserSessionProfileIdByHostId: Partial> setDefaultBrowserSessionProfileId: (profileId: string | null) => void } -function normalizeUrl(url: string): string { - const trimmed = url.trim() - if (trimmed.length === 0) { - return 'about:blank' - } - // Why: redact at this single URL sink so the Kagi bearer token can't reach BrowserPage.url, which is persisted to disk. - return redactKagiSessionToken(trimmed) -} - -function normalizeBrowserTitle( - title: string | null | undefined, - url: string, - docLocation?: BrowserPageDocLocation | null -): string { - if (docLocation) { - // Why a document page cannot go through the checks below: its url is the blank URL by design, - // which would name every previewed document "New Tab". Its fallback is the file it shows. - // Why the grant URL is refused rather than trusted: Chromium reports the URL as the title when - // a document declares none, and this title is stored, persisted and mirrored onto the tab — - // a grant reaching any of them would outlive the grant and name a document nothing can read. - if (!title || isDocPreviewUrl(title)) { - return basename(docLocation.filePath) || docLocation.filePath - } - return title - } - if ( - url === 'about:blank' || - url === ORCA_BROWSER_BLANK_URL || - title === 'about:blank' || - title === ORCA_BROWSER_BLANK_URL || - !title - ) { - // Why: don't surface the internal blank-guest URL as a title (leaks an impl detail, looks broken); show "New Tab" instead. - return 'New Tab' - } - return title -} - function getBrowserSettingsHostId( state: Pick ): ExecutionHostId { @@ -469,7 +467,10 @@ function browserImportStateForHostUpdate( function closeRemoteBrowserPageInOwningEnvironment( worktreeId: string, - handle: RemoteBrowserPageHandle + handle: RemoteBrowserPageHandle, + // Why a callback and not the store: this is module scope, and the recording action lives on the + // slice this file is still defining. + recordCloseIntents: (closes: readonly PendingClientHostedBrowserClose[]) => void ): void { const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } void callRuntimeRpc( @@ -477,121 +478,17 @@ function closeRemoteBrowserPageInOwningEnvironment( 'browser.tabClose', { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, { timeoutMs: 15_000 } - ).catch(() => {}) -} - -function buildBrowserPage( - workspaceId: string, - worktreeId: string, - url: string, - title?: string, - browserRuntimeEnvironmentId?: string | null, - browserPageId?: string, - docLocation?: BrowserPageDocLocation -): BrowserPage { - // Why the url is overridden rather than trusted: this is the one place a page's url is minted, - // and it is read by persistence, the mobile publisher, history and the address bar. A grant URL - // reaching any of them would outlive the grant and name a document that machine cannot read. - const normalizedUrl = docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(url) - return { - id: browserPageId ?? createBrowserUuid(), - workspaceId, - worktreeId, - url: normalizedUrl, - title: normalizeBrowserTitle(title, normalizedUrl, docLocation), - // Why: blank pages mount an inert guest (no real navigation); marking them loading would flash the loading affordance. - loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: Date.now(), - ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}), - ...(docLocation ? { docLocation } : {}) - } -} - -function buildWorkspaceFromPage( - id: string, - worktreeId: string, - page: BrowserPage, - pageIds: string[], - sessionProfileId?: string | null, - sessionPartition?: string | null -): BrowserWorkspace { - return { - id, - worktreeId, - sessionProfileId: sessionProfileId ?? null, - sessionPartition: sessionPartition ?? null, - activePageId: page.id, - pageIds, - url: page.url, - title: page.title, - loading: page.loading, - faviconUrl: page.faviconUrl, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward, - loadError: page.loadError, - createdAt: page.createdAt, - docLocation: page.docLocation ?? null - } -} - -function mirrorWorkspaceFromActivePage( - workspace: BrowserWorkspace, - pages: BrowserPage[] -): BrowserWorkspace { - const activePage = pages.find((page) => page.id === workspace.activePageId) ?? null - if (!activePage) { - return { - ...workspace, - activePageId: null, - pageIds: pages.map((page) => page.id), - url: 'about:blank', - title: translate('auto.store.slices.browser.08fc23631d', 'Browser'), - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - docLocation: null + ).catch((error) => { + // A close the runtime never heard is a resurrection waiting to happen: the runtime persists + // client-hosted pages, so a durable intent replays this same RPC on the next reconnect. + // A definitive page-unknown answer means there is nothing left to resurrect. + if (!isDurableClientHostedBrowserHandle(handle) || isBrowserPageDefinitivelyGone(error)) { + return } - } - return { - ...workspace, - activePageId: activePage.id, - pageIds: pages.map((page) => page.id), - url: activePage.url, - title: activePage.title, - loading: activePage.loading, - faviconUrl: activePage.faviconUrl, - canGoBack: activePage.canGoBack, - canGoForward: activePage.canGoForward, - loadError: activePage.loadError, - docLocation: activePage.docLocation ?? null - } -} - -function browserWorkspaceMirrorFieldsEqual( - workspace: BrowserWorkspace, - mirrored: BrowserWorkspace -): boolean { - const workspacePageIds = workspace.pageIds ?? [] - const mirroredPageIds = mirrored.pageIds ?? [] - return ( - workspace.activePageId === mirrored.activePageId && - workspacePageIds.length === mirroredPageIds.length && - workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]) && - workspace.url === mirrored.url && - workspace.title === mirrored.title && - workspace.loading === mirrored.loading && - workspace.faviconUrl === mirrored.faviconUrl && - workspace.canGoBack === mirrored.canGoBack && - workspace.canGoForward === mirrored.canGoForward && - workspace.loadError === mirrored.loadError && - browserPageDocLocationsEqual(workspace.docLocation ?? null, mirrored.docLocation ?? null) - ) + recordCloseIntents([ + { environmentId: handle.environmentId, browserPageId: handle.remotePageId, worktreeId } + ]) + }) } function getFallbackTabTypeForWorktree( @@ -612,48 +509,6 @@ function getFallbackTabTypeForWorktree( return 'terminal' } -const browserWorkspaceByIdCache = new WeakMap< - Record, - Map ->() -const browserPageByIdCache = new WeakMap, Map>() - -function findWorkspace( - browserTabsByWorktree: Record, - workspaceId: string -): BrowserWorkspace | null { - const cached = browserWorkspaceByIdCache.get(browserTabsByWorktree) - if (cached) { - return cached.get(workspaceId) ?? null - } - const workspaceById = new Map() - for (const workspaces of Object.values(browserTabsByWorktree)) { - for (const workspace of workspaces) { - workspaceById.set(workspace.id, workspace) - } - } - browserWorkspaceByIdCache.set(browserTabsByWorktree, workspaceById) - return workspaceById.get(workspaceId) ?? null -} - -function findPage( - browserPagesByWorkspace: Record, - pageId: string -): BrowserPage | null { - const cached = browserPageByIdCache.get(browserPagesByWorkspace) - if (cached) { - return cached.get(pageId) ?? null - } - const pageById = new Map() - for (const pages of Object.values(browserPagesByWorkspace)) { - for (const page of pages) { - pageById.set(page.id, page) - } - } - browserPageByIdCache.set(browserPagesByWorkspace, pageById) - return pageById.get(pageId) ?? null -} - export const createBrowserSlice: StateCreator = (set, get) => ({ browserTabsByWorktree: {}, browserPagesByWorkspace: {}, @@ -672,6 +527,7 @@ export const createBrowserSlice: StateCreator = browserSessionHostIdOverride: null, browserSessionImportState: null, browserUrlHistory: [], + workspaceDocHistory: [], defaultBrowserSessionProfileId: null, defaultBrowserSessionProfileIdByHostId: {}, @@ -1082,7 +938,11 @@ export const createBrowserSlice: StateCreator = }) for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) } for (const docPageId of docPageIdsToRelease) { @@ -1439,7 +1299,11 @@ export const createBrowserSlice: StateCreator = }) for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) } if (docPageIdToRelease) { @@ -1459,6 +1323,110 @@ export const createBrowserSlice: StateCreator = } }, + convertBrowserPage: (pageId, target, options) => { + // Why the same assert as createBrowserTab: a conversion materializes a browser surface the + // same way a creation does, and a paired web client that cannot host one must refuse here too. + // Ownership is resolved ONCE, on the plan's own terms — property-present-undefined means + // worktree-inferred — so the assert and the plan cannot disagree about what is being built. + const oldPageForOwnership = findPage(get().browserPagesByWorkspace, pageId) + const declaredOwnership = + target.kind === 'workspace-doc' + ? null + : 'browserRuntimeEnvironmentId' in target + ? target.browserRuntimeEnvironmentId + : (oldPageForOwnership?.browserRuntimeEnvironmentId ?? null) + assertManagedBrowserMaterializationAllowed( + get(), + declaredOwnership !== undefined + ? declaredOwnership + : oldPageForOwnership + ? (getRuntimeEnvironmentIdForWorktree(get(), oldPageForOwnership.worktreeId) ?? null) + : null + ) + let converted: BrowserPage | null = null + let docPageIdToRelease: string | null = null + let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null + set((s) => { + const plan = planBrowserPageConversion(s, pageId, target, options) + if (!plan) { + return s + } + converted = plan.newPage + // Why collected rather than released inside the reducer: revoking is main-process work, and + // it must happen exactly once even if a later set() retries the reducer. + if (plan.oldPage.docLocation) { + docPageIdToRelease = plan.oldPage.id + } + const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] + if (remoteHandle) { + remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } + } + const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } + delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } + delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [plan.workspace.id]: plan.nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [plan.workspace.worktreeId]: ( + s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] + ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does + // not track, so the initializers' null narrowing would otherwise read these as never. + const newPage = converted as BrowserPage | null + const remoteClose = remotePageToClose as { + worktreeId: string + handle: RemoteBrowserPageHandle + } | null + if (!newPage) { + return null + } + if (remoteClose) { + closeRemoteBrowserPageInOwningEnvironment( + remoteClose.worktreeId, + remoteClose.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + // Why after the reducer: a closed document must stop being readable, and the grant is the only + // authority the preview scheme honors — but the store row has to stop naming it first. + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) + if (workspaceAfter?.activePageId === newPage.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId) + if (item) { + get().setTabLabel(item.id, newPage.title) + } + } + return newPage + }, + reopenClosedBrowserPage: (workspaceId) => { // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same page (TOCTOU). let pageToRestore: BrowserPage | undefined @@ -2089,6 +2057,7 @@ export const createBrowserSlice: StateCreator = browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), + workspaceDocHistory: normalizeWorkspaceDocHistoryEntries(session.workspaceDocHistory ?? []), // Why restored before the rows are: a close the host never heard must outlive the relaunch // that also restores the row it closed, or the restore silently wins. clientHostedBrowserCloseIntentsByEnvironment: @@ -2604,6 +2573,44 @@ export const createBrowserSlice: StateCreator = } }, + recordWorkspaceDocVisit: (docLocation, title, options) => { + const bump = options?.bump ?? true + set((s) => { + const now = Date.now() + const existing = s.workspaceDocHistory.find((entry) => + browserPageDocLocationsEqual(entry.docLocation, docLocation) + ) + if (!existing && !bump) { + // A title refresh for a document never visited records nothing. + return s + } + const normalizedTitle = normalizeWorkspaceDocHistoryTitle( + title ?? existing?.title, + docLocation + ) + const next: WorkspaceDocHistoryEntry[] = existing + ? s.workspaceDocHistory.map((entry) => + entry === existing + ? { + ...entry, + title: normalizedTitle, + ...(bump ? { lastVisitedAt: now, visitCount: entry.visitCount + 1 } : {}) + } + : entry + ) + : [ + { docLocation, title: normalizedTitle, lastVisitedAt: now, visitCount: 1 }, + ...s.workspaceDocHistory + ] + return { + workspaceDocHistory: + next.length > MAX_WORKSPACE_DOC_HISTORY_ENTRIES + ? normalizeWorkspaceDocHistoryEntries(next) + : next + } + }) + }, + addBrowserHistoryEntry: (url, title) => { const safeUrl = redactKagiSessionToken(url) if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) { @@ -2637,5 +2644,6 @@ export const createBrowserSlice: StateCreator = }) }, - clearBrowserHistory: () => set({ browserUrlHistory: [] }) + // One clear for both sources: the dropdown presents them as one history. + clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] }) }) diff --git a/src/shared/browser-url.ts b/src/shared/browser-url.ts index f0583a8b1ab..eaaf8211c6d 100644 --- a/src/shared/browser-url.ts +++ b/src/shared/browser-url.ts @@ -239,6 +239,15 @@ export function looksLikeSearchQuery(input: string): boolean { return true } +/** True for input shaped like an absolute filesystem path (POSIX, Windows drive, or UNC). */ +export function isAbsoluteFilesystemPathInput(input: string): boolean { + return ( + UNIX_ABSOLUTE_PATH_PATTERN.test(input) || + WINDOWS_ABSOLUTE_PATH_PATTERN.test(input) || + WINDOWS_UNC_PATH_PATTERN.test(input) + ) +} + function absolutePathToFileUrl(filePath: string): string { const normalizedPath = filePath.replaceAll('\\', '/') const segments = normalizedPath.split('/').map((segment, index) => { diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts index 52c88642ab1..4cf477e644d 100644 --- a/src/shared/browser-workspace-types.ts +++ b/src/shared/browser-workspace-types.ts @@ -64,6 +64,21 @@ export type BrowserPageDocLocation = { filePath: string } +/** + * Where a converted page came from — one level deep, so Back can cross the conversion boundary. + * A `workspace-doc` origin carries the document identity only; a `url` origin carries the page's + * last stored (already-fenced) url, so no grant URL can ride provenance into persistence. + */ +export type BrowserPageConversionOrigin = + | { kind: 'workspace-doc'; docLocation: BrowserPageDocLocation } + | { + kind: 'url' + url: string + /** The origin page's runtime ownership, so Back cannot silently move browsing onto this + * desktop: absent = worktree-inferred, null = client-local, string = that runtime. */ + browserRuntimeEnvironmentId?: string | null + } + export type BrowserPage = { id: string workspaceId: string @@ -89,6 +104,10 @@ export type BrowserPage = { viewportPresetId?: BrowserViewportPresetId | null /** Set on a page that shows a workspace document; absent on every page that shows a URL. */ docLocation?: BrowserPageDocLocation | null + /** Set on a page the address bar converted from the other kind; absent everywhere else. */ + convertedFrom?: BrowserPageConversionOrigin | null + /** Set on a page Back returned across a conversion to; Forward re-crosses it. */ + convertedTo?: BrowserPageConversionOrigin | null } export type BrowserWorkspace = { diff --git a/src/shared/workspace-doc-history.test.ts b/src/shared/workspace-doc-history.test.ts new file mode 100644 index 00000000000..15ea1c2d3d7 --- /dev/null +++ b/src/shared/workspace-doc-history.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_WORKSPACE_DOC_HISTORY_ENTRIES, + normalizeWorkspaceDocHistoryEntries, + normalizeWorkspaceDocHistoryTitle, + type WorkspaceDocHistoryEntry +} from './workspace-doc-history' + +const DOC = { kind: 'workspace-doc' as const, worktreeId: 'wt-1', filePath: '/repo/a.html' } + +function entry(overrides: Partial = {}): WorkspaceDocHistoryEntry { + return { docLocation: DOC, title: 'A', lastVisitedAt: 1, visitCount: 1, ...overrides } +} + +describe('normalizeWorkspaceDocHistoryEntries', () => { + it('dedupes on the document, keeping the most recent visit', () => { + const entries = normalizeWorkspaceDocHistoryEntries([ + entry({ title: 'Old', lastVisitedAt: 1 }), + entry({ title: 'New', lastVisitedAt: 5 }) + ]) + expect(entries).toHaveLength(1) + expect(entries[0]?.title).toBe('New') + }) + + it('caps by recency, not input order', () => { + const oversized = Array.from({ length: MAX_WORKSPACE_DOC_HISTORY_ENTRIES + 10 }, (_, i) => + entry({ + docLocation: { ...DOC, filePath: `/repo/${i}.html` }, + lastVisitedAt: i + }) + ) + const entries = normalizeWorkspaceDocHistoryEntries(oversized) + expect(entries).toHaveLength(MAX_WORKSPACE_DOC_HISTORY_ENTRIES) + // The oldest rows are the dropped ones. + expect(entries.at(-1)?.lastVisitedAt).toBe(10) + }) + + it('drops malformed rows and fences a url-as-title back to the file name', () => { + const entries = normalizeWorkspaceDocHistoryEntries([ + entry({ title: `orca-preview://${'a'.repeat(32)}/a.html` }), + { + docLocation: { kind: 'workspace-doc', worktreeId: '', filePath: '' }, + title: 'x', + lastVisitedAt: 2, + visitCount: 1 + } + ]) + expect(entries).toHaveLength(1) + expect(entries[0]?.title).toBe('a.html') + }) + + it('title fence falls back to the file for empty titles', () => { + expect(normalizeWorkspaceDocHistoryTitle('', DOC)).toBe('a.html') + expect(normalizeWorkspaceDocHistoryTitle('Report', DOC)).toBe('Report') + }) +}) diff --git a/src/shared/workspace-doc-history.ts b/src/shared/workspace-doc-history.ts new file mode 100644 index 00000000000..f5965cdadfb --- /dev/null +++ b/src/shared/workspace-doc-history.ts @@ -0,0 +1,58 @@ +import { browserPageDocLocationsEqual } from './browser-page-doc-location' +import type { BrowserPageDocLocation } from './browser-workspace-types' +import { isDocPreviewUrl } from './doc-preview-scheme' + +/** + * A previewed workspace document the URL-bar dropdown can offer again. The document is the whole + * identity — there is deliberately no url field, so the grant URL a preview is served over has + * nowhere to land in history: confinement by absence, like the registry split. + */ +export type WorkspaceDocHistoryEntry = { + docLocation: BrowserPageDocLocation + title: string + lastVisitedAt: number + visitCount: number +} + +export const MAX_WORKSPACE_DOC_HISTORY_ENTRIES = 100 + +/** The title fence the page store applies, for history rows: a url-as-title falls back to the file. */ +export function normalizeWorkspaceDocHistoryTitle( + title: string | null | undefined, + docLocation: BrowserPageDocLocation +): string { + if (!title || isDocPreviewUrl(title)) { + const fileName = docLocation.filePath.split(/[\\/]/).at(-1) + return fileName || docLocation.filePath + } + return title +} + +export function normalizeWorkspaceDocHistoryEntries( + entries: readonly WorkspaceDocHistoryEntry[] +): WorkspaceDocHistoryEntry[] { + const normalized: WorkspaceDocHistoryEntry[] = [] + const candidates = [...entries].sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) + for (const entry of candidates) { + if ( + entry.docLocation?.kind !== 'workspace-doc' || + !entry.docLocation.worktreeId || + !entry.docLocation.filePath + ) { + continue + } + if ( + normalized.some((kept) => browserPageDocLocationsEqual(kept.docLocation, entry.docLocation)) + ) { + continue + } + normalized.push({ + ...entry, + title: normalizeWorkspaceDocHistoryTitle(entry.title, entry.docLocation) + }) + if (normalized.length >= MAX_WORKSPACE_DOC_HISTORY_ENTRIES) { + break + } + } + return normalized +} diff --git a/src/shared/workspace-session-browser-schema.ts b/src/shared/workspace-session-browser-schema.ts index 7f29b1c1806..71f88fb3b0c 100644 --- a/src/shared/workspace-session-browser-schema.ts +++ b/src/shared/workspace-session-browser-schema.ts @@ -5,6 +5,8 @@ import { z } from 'zod' import type { BrowserWorkspace } from './browser-workspace-types' import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' +import { normalizeWorkspaceDocHistoryEntries } from './workspace-doc-history' +import { isDocPreviewUrl } from './doc-preview-scheme' import { salvagingArray } from './zod-salvage' const browserLoadErrorSchema = z.object({ @@ -61,6 +63,21 @@ export const browserWorkspaceSchema: z.ZodType = z.object({ docLocation: browserPageDocLocationSchema.nullable().optional() }) +const browserPageConversionOriginSchema = z + .union([ + z.object({ kind: z.literal('workspace-doc'), docLocation: browserPageDocLocationSchema }), + z.object({ + kind: z.literal('url'), + url: z.string(), + browserRuntimeEnvironmentId: z.string().nullable().optional() + }) + ]) + .nullable() + .optional() + .transform((origin) => + origin && origin.kind === 'url' && isDocPreviewUrl(origin.url) ? null : origin + ) + export const browserPageSchema = z.object({ id: z.string(), workspaceId: z.string(), @@ -87,7 +104,13 @@ export const browserPageSchema = z.object({ // Why listed here and not just typed: z.object strips what it does not name, so an unlisted // docLocation restores a workspace document as a blank New Tab — the page keeps its blank url // and loses the only field that said which document it was. - docLocation: browserPageDocLocationSchema.nullable().optional() + docLocation: browserPageDocLocationSchema.nullable().optional(), + // Why persisted: one-level history across an address-bar conversion should survive a restart. + // The url variant holds a store url that already passed every fence on its way in — and the + // same prefix fence every other url sink applies stands at this door too, so a session file + // carrying the preview scheme sheds the provenance rather than handing it back to history. + convertedFrom: browserPageConversionOriginSchema, + convertedTo: browserPageConversionOriginSchema }) const browserHistoryEntrySchema = z.object({ @@ -101,3 +124,15 @@ const browserHistoryEntrySchema = z.object({ export const browserHistoryEntriesSchema = salvagingArray(browserHistoryEntrySchema).transform( (entries) => normalizeBrowserHistoryEntries(entries) ) + +// A document identity and nothing else: no url field exists for a grant URL to land in. +const workspaceDocHistoryEntrySchema = z.object({ + docLocation: browserPageDocLocationSchema, + title: z.string(), + lastVisitedAt: z.number(), + visitCount: z.number() +}) + +export const workspaceDocHistoryEntriesSchema = salvagingArray( + workspaceDocHistoryEntrySchema +).transform((entries) => normalizeWorkspaceDocHistoryEntries(entries)) diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index 4ace00825e4..d371d488b67 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -39,6 +39,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = { clientHostedBrowserCloseIntentsByEnvironment: true, activeTabTypeByWorktree: true, browserUrlHistory: true, + workspaceDocHistory: true, activeTabIdByWorktree: true, unifiedTabs: true, tabGroups: true, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 2ed368baedf..3778fa9c833 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -25,6 +25,7 @@ import { isTuiAgent } from './tui-agent-config' import { isWorkspaceKey } from './workspace-scope' import { browserHistoryEntriesSchema, + workspaceDocHistoryEntriesSchema, browserPageSchema, browserWorkspaceSchema } from './workspace-session-browser-schema' @@ -246,6 +247,7 @@ export const workspaceSessionStateSchema: z.ZodType = z.o salvagingRecord(worktreeIdSchema, workspaceVisibleTabTypeSchema) ), browserUrlHistory: salvagedOptional('browserUrlHistory', browserHistoryEntriesSchema), + workspaceDocHistory: salvagedOptional('workspaceDocHistory', workspaceDocHistoryEntriesSchema), activeTabIdByWorktree: salvagedOptional( 'activeTabIdByWorktree', salvagingRecord(worktreeIdSchema, z.string().nullable()) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index 271941b21c5..e0cc7190cbf 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -4,6 +4,7 @@ import type { WorkspaceKey } from './folder-workspace-types' import type { Tab, TabGroup, TabGroupLayoutNode, WorkspaceVisibleTabType } from './tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' import type { BrowserHistoryEntry, BrowserPage, BrowserWorkspace } from './browser-workspace-types' +import type { WorkspaceDocHistoryEntry } from './workspace-doc-history' import type { ClientHostedBrowserCloseIntent } from './client-hosted-browser-close-intent' import type { PersistedClientHostedBrowserPage } from './client-hosted-browser-page-record' import type { ClosedTerminalTabTombstonesByTabId } from './closed-terminal-tab-tombstones' @@ -76,6 +77,8 @@ export type WorkspaceSessionState = { activeTabTypeByWorktree?: Record /** Global browser URL history for address bar autocomplete. */ browserUrlHistory?: BrowserHistoryEntry[] + /** Previewed workspace documents for the same dropdown — document identities, never URLs. */ + workspaceDocHistory?: WorkspaceDocHistoryEntry[] /** Per-worktree last-active terminal tab ID at shutdown. */ activeTabIdByWorktree?: Record /** Unified tab model — present when saved by a build that includes TabsSlice. diff --git a/tests/e2e/paired-preview-address-bar-convergence.spec.ts b/tests/e2e/paired-preview-address-bar-convergence.spec.ts new file mode 100644 index 00000000000..ff3f663aa67 --- /dev/null +++ b/tests/e2e/paired-preview-address-bar-convergence.spec.ts @@ -0,0 +1,386 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { startClientHostedMarkerFixture } from './helpers/client-hosted-browser-fixture' +import { + readDocPreviewGuestUrl, + readDocPreviewRenderedText, + readPairedHtmlPreviewInventory, + requireSingleDocWorkspace +} from './helpers/paired-html-preview-inventory' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE_NAME = 'convergence-doc.html' +const FIXTURE_HEADING = 'address bar convergence' +const FIXTURE_TITLE = 'Convergence Document' + +type PreparedPairedClient = { + client: PairedElectronClient + worktreeId: string + worktreePath: string +} + +async function preparePairedClient( + offer: RuntimeDesktopPairingOffer, + testInfo: TestInfo, + testRepoPath: string +): Promise { + const client = await launchPairedElectronClient(offer, testInfo, 'Address-bar convergence') + try { + // A workstation-sized window: under CI's default size the split preview squeezes the document + // chip below its content width (min-w-0 + overflow-hidden), leaving no chip pixels to click. + await client.page.setViewportSize({ width: 1600, height: 900 }) + await expect + .poll( + () => + client.page.evaluate( + (repoPath) => + window.__store + ?.getState() + .allWorktrees() + .find((worktree) => worktree.path === repoPath)?.id ?? null, + testRepoPath + ), + { timeout: 120_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + const worktree = await client.page.evaluate((repoPath) => { + const match = window.__store + ?.getState() + .allWorktrees() + .find((candidate) => candidate.path === repoPath) + return match ? { id: match.id, path: match.path } : null + }, testRepoPath) + if (!worktree) { + throw new Error('paired client worktree disappeared after discovery') + } + await client.page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId: worktree.id } + ) + return { client, worktreeId: worktree.id, worktreePath: worktree.path } + } catch (error) { + await client.dispose() + throw error + } +} + +/** + * The canonical new-tab path the client-hosted specs use. A raw createBrowserTab from a paired + * client races the client-hosted creation machinery: the host's published snapshot can fail to + * match the locally minted row and place the same page twice (seen on CI as duplicate tabs). + */ +async function openPairedWebTab(page: Page, url: string): Promise { + await page.evaluate(async (pageUrl) => { + const state = window.__store?.getState() + if (!state?.activeWorktreeId) { + throw new Error('Paired client has no active worktree') + } + const groupId = state.activeGroupIdByWorktree[state.activeWorktreeId] + if (!groupId) { + throw new Error('Paired client has no active tab group') + } + state.setBrowserDefaultUrl(pageUrl) + await state.openNewBrowserTabInActiveWorkspace(groupId) + }, url) +} + +async function openPreviewFromExplorer(page: Page, fixtureName: string): Promise { + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: fixtureName }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + await openPreviewToSide.click() +} + +/** + * The STA-5681 naive journey, driven with real clicks and real typing: the preview's chip becomes + * an address bar; a committed URL makes the tab an ordinary browser tab in place; Back returns to + * the document; typing the document's path into the web tab's address bar converts it back to the + * preview on a fresh grant; and the URL dropdown offers the previewed document by name. The host's + * session snapshot stays empty throughout: a converted page is client-local like the preview was. + */ +test('converts a preview to a web tab and back from the address bar', async ({ + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + `${FIXTURE_TITLE}` + + `

${FIXTURE_HEADING}

\n` + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const marker = await startClientHostedMarkerFixture() + let prepared: PreparedPairedClient | null = null + try { + prepared = await preparePairedClient(offer, testInfo, testRepoPath) + const { client, worktreeId, worktreePath } = prepared + const page = client.page + const docFilePath = path.join(worktreePath, FIXTURE_NAME) + const inventoryArgs = { environmentId: client.environmentId, docFilePath, worktreeId } + + await openPreviewFromExplorer(page, FIXTURE_NAME) + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'the document browser tab never materialized' } + ) + .toBe(1) + await expect + .poll(() => readDocPreviewRenderedText(page, 'h1'), { timeout: 60_000 }) + .toContain(FIXTURE_HEADING) + const preview = requireSingleDocWorkspace( + await readPairedHtmlPreviewInventory(page, inventoryArgs) + ) + // The activation click a reader would make: the preview opened to the side, unfocused. + // Browser rows in the strip are keyed by workspace id. + await page.locator(`[data-tab-id="${preview.workspaceId}"]`).click() + + // The mobile-publish baseline: the document tab is held back from the tab snapshot. + const baseline = await readPairedHtmlPreviewInventory(page, inventoryArgs) + expect(baseline.hostSessionBrowserTabs).toHaveLength(0) + + // Chip → address bar, prefilled with the file the reader can retype. + // Why the left-edge position: under CI's narrow pane the squeezed chip's center hit-tests to + // the neighboring toolbar control's tooltip span; the file icon at the chip's left edge is + // always the chip's own pixels. + await page + .getByRole('button', { name: 'Edit address', exact: true }) + .click({ position: { x: 8, y: 10 } }) + const addressInput = page.locator('[data-browser-chrome-address-slot] input') + await expect(addressInput).toBeVisible({ timeout: 10_000 }) + await expect(addressInput).toHaveValue(FIXTURE_NAME) + + // A committed URL converts the tab in place: same workspace row, now an ordinary browser tab. + // The converted page stays client-local (it inherits the preview's ownership), so like every + // client-local page in a paired worktree it never enters the host's session tab snapshot — + // the phone-facing publish flip is a desktop-host observable, pinned at the snapshot-builder + // unit level (sync-runtime-graph-conversion-publish.test.ts). + await addressInput.fill(marker.markerUrl) + await addressInput.press('Enter') + await expect + .poll( + async () => { + const inventory = await readPairedHtmlPreviewInventory(page, inventoryArgs) + return { + docWorkspaces: inventory.docWorkspaces.length, + publishedTabs: inventory.hostSessionBrowserTabs.length + } + }, + { timeout: 60_000, message: 'the conversion never reached the store' } + ) + .toEqual({ docWorkspaces: 0, publishedTabs: 0 }) + const converted = await page.evaluate( + ({ targetWorktreeId, workspaceId }) => { + const state = window.__store?.getState() + const workspace = (state?.browserTabsByWorktree[targetWorktreeId] ?? []).find( + (tab) => tab.id === workspaceId + ) + const pages = state?.browserPagesByWorkspace[workspaceId] ?? [] + return workspace + ? { + url: workspace.url, + docLocation: workspace.docLocation ?? null, + convertedFrom: pages[0]?.convertedFrom ?? null + } + : null + }, + { targetWorktreeId: worktreeId, workspaceId: preview.workspaceId } + ) + expect(converted?.url).toBe(marker.markerUrl) + expect(converted?.docLocation).toBeNull() + expect(converted?.convertedFrom).toMatchObject({ kind: 'workspace-doc' }) + // The marker page really rendered in a browsing guest. + await expect + .poll(() => page.locator('webview').last().getAttribute('src'), { timeout: 30_000 }) + .toContain(marker.origin) + + // Back's one-level return: the guest has no history, so Back crosses the conversion. + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'Back never returned across the conversion' } + ) + .toBe(1) + await expect + .poll(() => readDocPreviewRenderedText(page, 'h1'), { timeout: 60_000 }) + .toContain(FIXTURE_HEADING) + await expect.poll(() => readDocPreviewGuestUrl(page)).toMatch(/^orca-preview:\/\//) + + // Forward re-crosses what Back consumed: the returned-to preview carries the web page as its + // forward target, and Forward rebuilds it — then Back still works, a real two-entry history. + // The converted web page is client-local (the doc it replaced held a desktop-minted grant), + // which is the scope of the crossing: a runtime-owned client-hosted origin cannot be rebuilt + // by a crossing yet, because the conversion closed its remote page (STA-5872). + await page.getByRole('button', { name: 'Forward', exact: true }).click() + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'Forward never re-crossed the conversion' } + ) + .toBe(0) + await expect + .poll(() => page.locator('webview').last().getAttribute('src'), { timeout: 30_000 }) + .toContain(marker.origin) + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'Back after Forward never returned across the conversion' } + ) + .toBe(1) + await expect + .poll(() => readDocPreviewRenderedText(page, 'h1'), { timeout: 60_000 }) + .toContain(FIXTURE_HEADING) + + // The other side of the two-entry history, on the same client-local tab: Forward to the web + // page, type the document's path over it, then Back restores the web page and the WEB + // toolbar's Forward (not the preview's) re-crosses to the document. + await page.getByRole('button', { name: 'Forward', exact: true }).click() + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'the second Forward never re-crossed the conversion' } + ) + .toBe(0) + // The split pane squeezes the address input to zero width (every other toolbar control is + // shrink-0); focusing the bar overlays it across the toolbar (#11090). Click the slot — the + // bar forwards padding clicks to the input — so it expands before typing. + const convertedAddressSlot = page.locator('[data-browser-chrome-address-slot]') + await expect(convertedAddressSlot).toBeVisible({ timeout: 30_000 }) + await convertedAddressSlot.click() + const convertedAddressInput = convertedAddressSlot.locator('input') + await expect(convertedAddressInput).toBeVisible({ timeout: 30_000 }) + await convertedAddressInput.fill(docFilePath) + await convertedAddressInput.press('Enter') + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'the typed path never converted the client-local web tab' } + ) + .toBe(1) + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'Back never restored the typed-over web page' } + ) + .toBe(0) + await expect + .poll(() => page.locator('webview').last().getAttribute('src'), { timeout: 30_000 }) + .toContain(marker.origin) + await page.getByRole('button', { name: 'Forward', exact: true }).click() + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: "the web toolbar's Forward never re-crossed to the document" } + ) + .toBe(1) + await expect + .poll(() => readDocPreviewRenderedText(page, 'h1'), { timeout: 60_000 }) + .toContain(FIXTURE_HEADING) + + // Reverse conversion by typing: close the preview, open a web tab, type the document's path. + const returned = requireSingleDocWorkspace( + await readPairedHtmlPreviewInventory(page, inventoryArgs) + ) + await page.evaluate((workspaceId) => { + window.__store?.getState().closeBrowserTab(workspaceId) + }, returned.workspaceId) + await openPairedWebTab(page, marker.markerUrl) + const webAddressInput = page.locator('[data-browser-chrome-address-slot] input') + await expect(webAddressInput).toBeVisible({ timeout: 30_000 }) + await webAddressInput.fill(docFilePath) + await webAddressInput.press('Enter') + await expect + .poll( + async () => + (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces.length, + { timeout: 60_000, message: 'the typed path never converted the web tab' } + ) + .toBe(1) + await expect + .poll(() => readDocPreviewRenderedText(page, 'h1'), { timeout: 60_000 }) + .toContain(FIXTURE_HEADING) + + // The dropdown offers the previewed document as a file identity; selecting it activates the + // tab it is already open in rather than minting a second grant. + const docTabsBefore = (await readPairedHtmlPreviewInventory(page, inventoryArgs)).docWorkspaces + await openPairedWebTab(page, marker.movedUrl) + // Why the count settles first: the converted tab's old pane unmounts a beat after the store + // flips, and a strict locator that races it resolves to two inputs. + await expect + .poll(() => page.locator('[data-browser-chrome-address-slot] input').count(), { + timeout: 30_000, + message: 'the web tab address input never settled to one' + }) + .toBe(1) + const secondInput = page.locator('[data-browser-chrome-address-slot] input') + // Why click-until-expanded: the freshly loaded guest steals focus once, and the dropdown + // dismisses on webview focus — a single click can land just before that steal. + await expect + .poll( + async () => { + await secondInput.click() + return secondInput.getAttribute('aria-expanded') + }, + { timeout: 30_000, message: 'the suggestion dropdown never opened' } + ) + .toBe('true') + await secondInput.pressSequentially('Convergence', { delay: 40 }) + // Scoped to the suggestion popover: the doc tab's own strip label carries the same title, and + // a strip click would satisfy a bare text locator without the dropdown existing at all. + const docSuggestion = page + .locator('[data-slot="popover-content"]') + .getByText(FIXTURE_TITLE, { exact: false }) + .first() + await expect(docSuggestion).toBeVisible({ timeout: 10_000 }) + await docSuggestion.click() + await expect + .poll( + async () => { + const inventory = await readPairedHtmlPreviewInventory(page, inventoryArgs) + const activeWorkspaceId = await page.evaluate( + (targetWorktreeId) => + window.__store?.getState().activeBrowserTabIdByWorktree[targetWorktreeId] ?? null, + worktreeId + ) + return { docTabs: inventory.docWorkspaces.length, activeWorkspaceId } + }, + { timeout: 30_000, message: 'selecting the doc suggestion did not activate the doc tab' } + ) + .toEqual({ + docTabs: docTabsBefore.length, + // Dedupe wins: selection activates the tab the document is already open in. + activeWorkspaceId: docTabsBefore[0]?.workspaceId ?? null + }) + } finally { + await marker.close() + await prepared?.client.dispose() + } +}) diff --git a/tests/e2e/paired-remote-html-preview-local-render.spec.ts b/tests/e2e/paired-remote-html-preview-local-render.spec.ts index a86721741dd..dd849091294 100644 --- a/tests/e2e/paired-remote-html-preview-local-render.spec.ts +++ b/tests/e2e/paired-remote-html-preview-local-render.spec.ts @@ -285,8 +285,9 @@ test('renders a paired HTML doc as a document browser tab while the host gains n expect(afterPreview.hostSessionBrowserTabs).toEqual(baseline.hostSessionBrowserTabs) // The chip stands in for the address bar the document page has none of, and keeps naming the - // file whatever the document calls itself. - const pathChip = page.getByRole('button', { name: 'Copy file path', exact: true }) + // file whatever the document calls itself. Since STA-5681 the chip is the way into the + // editable address bar, so it answers to that name now. + const pathChip = page.getByRole('button', { name: 'Edit address', exact: true }) await expect(pathChip).toBeVisible({ timeout: 30_000 }) await expect(pathChip).toContainText(FIXTURE_NAME) // Below 24rem of chip width the identity row hides whole instead of clipping into slivers;