mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(browser): address-bar convergence — previews and browser tabs convert in place (STA-5681) (#16998)
This commit is contained in:
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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<HTMLInputElement | null>
|
||||
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 <webview> 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 <webview> 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) {
|
||||
|
||||
+50
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+44
-18
@@ -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
|
||||
|
||||
+2
@@ -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}
|
||||
|
||||
@@ -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<Electron.WebviewTag | null>
|
||||
reloadMenuOpen: boolean
|
||||
@@ -92,11 +106,30 @@ export function BrowserPageToolbar({
|
||||
<BrowserChromeToolbar
|
||||
showTourAnchors
|
||||
controls={{
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
canGoBack: canGoBack || Boolean(convertedFrom),
|
||||
canGoForward: canGoForward || Boolean(convertedTo),
|
||||
loading,
|
||||
goBack: () => 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={<SshEgressIndicator worktreeId={worktreeId} />}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Why: Electron <webview> 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 <webview> 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])
|
||||
}
|
||||
+10
@@ -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 })
|
||||
|
||||
+79
@@ -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<Electron.WebviewTag | null>
|
||||
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
|
||||
onUpdatePageState: (pageId: string, updates: Partial<BrowserTabPageState>) => 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
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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<HTMLInputElement | null>
|
||||
addressBarEditSession: BrowserAddressBarEditSessionBinding
|
||||
busy: boolean
|
||||
@@ -61,6 +65,7 @@ export function RemoteBrowserPageToolbar({
|
||||
onChange={onAddressBarChange}
|
||||
onSubmit={onSubmitAddressBar}
|
||||
onNavigate={onNavigateToUrl}
|
||||
onOpenWorkspaceDoc={onOpenWorkspaceDoc}
|
||||
inputRef={addressBarInputRef}
|
||||
editSession={addressBarEditSession}
|
||||
leadingIcon={
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
+2
@@ -65,6 +65,8 @@ const storeState = {
|
||||
deleteBrowserPageAnnotation: () => undefined,
|
||||
clearBrowserPageAnnotations: () => undefined,
|
||||
recordFeatureInteraction: () => undefined,
|
||||
recordWorkspaceDocVisit: () => undefined,
|
||||
workspaceDocHistory: [],
|
||||
openFile: () => 'file-1'
|
||||
}
|
||||
|
||||
|
||||
+79
-6
@@ -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<HTMLInputElement>(
|
||||
'[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<HTMLInputElement>(
|
||||
'[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 () => {
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<Electron.WebviewTag | null>(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({
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-editor-surface">
|
||||
<DocPreviewToolbar
|
||||
identity={identity}
|
||||
history={history}
|
||||
previewId={previewId}
|
||||
worktreeId={worktreeId}
|
||||
history={historyWithConversionCrossings}
|
||||
loading={state === 'loading' && failureReason === null}
|
||||
onReload={handleReload}
|
||||
onHardReload={handleHardReload}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import BrowserAddressBar from '@/components/browser-pane/assemble-chrome/BrowserAddressBar'
|
||||
import { resolveBrowserAddressBarSubmission } from '@/components/browser-pane/navigate/browser-address-bar-navigation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview'
|
||||
import { resolveWorkspaceDocAddressTarget } from '@/lib/workspace-doc-address-input'
|
||||
import { useAppStore } from '@/store'
|
||||
import { DocPreviewDocumentChip } from './doc-preview-document-chip'
|
||||
import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity'
|
||||
|
||||
/**
|
||||
* The chip until the reader asks to type: clicking it swaps in the shared address bar, prefilled
|
||||
* with the document's workspace-relative path and selected. Committing a web URL converts the tab
|
||||
* in place — the preview becomes an ordinary browser tab from then on. The conversion replaces the
|
||||
* page, so a successful commit unmounts this whole pane; only a refused commit stays here.
|
||||
*/
|
||||
export function DocPreviewAddressEdit({
|
||||
identity,
|
||||
previewId,
|
||||
worktreeId
|
||||
}: {
|
||||
identity: DocPreviewDocumentIdentity
|
||||
previewId: string
|
||||
worktreeId: string
|
||||
}): React.JSX.Element {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const dismissSuggestionsRef = useRef<(() => void) | null>(null)
|
||||
const exitTimerRef = useRef<number | null>(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 <DocPreviewDocumentChip identity={identity} onBeginEdit={beginEdit} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-1"
|
||||
onKeyDown={(event) => {
|
||||
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
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BrowserAddressBar
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={submit}
|
||||
onNavigate={navigateToUrl}
|
||||
onOpenWorkspaceDoc={(docLocation) => {
|
||||
convertBrowserPageToWorkspaceDoc(previewId, docLocation)
|
||||
exitEdit()
|
||||
}}
|
||||
inputRef={inputRef}
|
||||
dismissSuggestionsRef={dismissSuggestionsRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+16
-4
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText()}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
onClick={onBeginEdit ?? (() => void copyText())}
|
||||
aria-label={label}
|
||||
className="@container flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xl border border-border bg-background px-3 py-1 text-left shadow-sm hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -68,7 +76,11 @@ export function DocPreviewDocumentChip({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{copied ? copiedLabel : `${copyLabel} · ${identity.absolutePath}`}
|
||||
{onBeginEdit
|
||||
? `${editLabel} · ${identity.absolutePath}`
|
||||
: copied
|
||||
? copiedLabel
|
||||
: `${copyLabel} · ${identity.absolutePath}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -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={<DocPreviewDocumentChip identity={identity} />}
|
||||
addressSlot={
|
||||
<DocPreviewAddressEdit identity={identity} previewId={previewId} worktreeId={worktreeId} />
|
||||
}
|
||||
reloadControl={
|
||||
<BrowserReloadControl
|
||||
menuOpen={reloadMenuOpen}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DOC_PREVIEW_PARTITION } from '../../../../../shared/doc-preview-scheme'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences'
|
||||
import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
|
||||
import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry'
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export function WorkspaceDocPagePane({
|
||||
relativePath={relativePath}
|
||||
worktreeId={worktreeId}
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
convertedFrom={page.convertedFrom ?? null}
|
||||
convertedTo={page.convertedTo ?? null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string, unknown>
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string, unknown[]>,
|
||||
browserTabsByWorktree: {} as Record<string, unknown[]>,
|
||||
browserPagesByWorkspace: {} as Record<string, unknown[]>,
|
||||
convertBrowserPage: vi.fn(),
|
||||
environmentId: null as string | null,
|
||||
connectionId: null as string | null,
|
||||
layoutByWorktree: {} as Record<string, unknown>,
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -23,6 +23,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory,
|
||||
workspaceDocHistory: [],
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
|
||||
@@ -24,6 +24,7 @@ function createSnapshot(
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory: [],
|
||||
workspaceDocHistory: [],
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ function createSnapshot(
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory: [],
|
||||
workspaceDocHistory: [],
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
|
||||
browserPagesByWorkspace: true,
|
||||
activeBrowserTabIdByWorktree: true,
|
||||
browserUrlHistory: true,
|
||||
workspaceDocHistory: true,
|
||||
remoteBrowserPageHandlesByPageId: true,
|
||||
unifiedTabsByWorktree: true,
|
||||
groupsByWorktree: true,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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<typeof AgentStatusModule>()
|
||||
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<typeof createTestStore> {
|
||||
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<typeof createTestStore>
|
||||
): ReturnType<typeof buildMobileSessionTabSnapshots>[number] | undefined {
|
||||
return buildMobileSessionTabSnapshots(store.getState()).find(
|
||||
(entry) => entry.worktree === WORKTREE_ID
|
||||
)
|
||||
}
|
||||
|
||||
function publishedBrowserWorkspaceIds(store: ReturnType<typeof createTestStore>): 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<typeof createTestStore>): 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<typeof createTestStore>): Set<string> {
|
||||
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<string> =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<object>()
|
||||
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<typeof AgentStatusModule>()
|
||||
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<string, unknown> = {}): {
|
||||
store: ReturnType<typeof createTestStore>
|
||||
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<typeof createTestStore>): 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([])
|
||||
})
|
||||
})
|
||||
@@ -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<object>()
|
||||
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<typeof AgentStatusModule>()
|
||||
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<typeof createTestStore> {
|
||||
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<typeof createTestStore>): {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, BrowserWorkspace[]>
|
||||
browserPagesByWorkspace: Record<string, BrowserPage[]>
|
||||
},
|
||||
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 }
|
||||
}
|
||||
@@ -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<string, BrowserWorkspace[]>,
|
||||
Map<string, BrowserWorkspace>
|
||||
>()
|
||||
const browserPageByIdCache = new WeakMap<Record<string, BrowserPage[]>, Map<string, BrowserPage>>()
|
||||
|
||||
export function findWorkspace(
|
||||
browserTabsByWorktree: Record<string, BrowserWorkspace[]>,
|
||||
workspaceId: string
|
||||
): BrowserWorkspace | null {
|
||||
const cached = browserWorkspaceByIdCache.get(browserTabsByWorktree)
|
||||
if (cached) {
|
||||
return cached.get(workspaceId) ?? null
|
||||
}
|
||||
const workspaceById = new Map<string, BrowserWorkspace>()
|
||||
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<string, BrowserPage[]>,
|
||||
pageId: string
|
||||
): BrowserPage | null {
|
||||
const cached = browserPageByIdCache.get(browserPagesByWorkspace)
|
||||
if (cached) {
|
||||
return cached.get(pageId) ?? null
|
||||
}
|
||||
const pageById = new Map<string, BrowserPage>()
|
||||
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
|
||||
}
|
||||
@@ -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/' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean>
|
||||
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<Record<ExecutionHostId, string | null>>
|
||||
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<AppState, 'browserSessionHostIdOverride' | 'settings'>
|
||||
): 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<string, BrowserWorkspace[]>,
|
||||
Map<string, BrowserWorkspace>
|
||||
>()
|
||||
const browserPageByIdCache = new WeakMap<Record<string, BrowserPage[]>, Map<string, BrowserPage>>()
|
||||
|
||||
function findWorkspace(
|
||||
browserTabsByWorktree: Record<string, BrowserWorkspace[]>,
|
||||
workspaceId: string
|
||||
): BrowserWorkspace | null {
|
||||
const cached = browserWorkspaceByIdCache.get(browserTabsByWorktree)
|
||||
if (cached) {
|
||||
return cached.get(workspaceId) ?? null
|
||||
}
|
||||
const workspaceById = new Map<string, BrowserWorkspace>()
|
||||
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<string, BrowserPage[]>,
|
||||
pageId: string
|
||||
): BrowserPage | null {
|
||||
const cached = browserPageByIdCache.get(browserPagesByWorkspace)
|
||||
if (cached) {
|
||||
return cached.get(pageId) ?? null
|
||||
}
|
||||
const pageById = new Map<string, BrowserPage>()
|
||||
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<AppState, [], [], BrowserSlice> = (set, get) => ({
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
@@ -672,6 +527,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
browserSessionHostIdOverride: null,
|
||||
browserSessionImportState: null,
|
||||
browserUrlHistory: [],
|
||||
workspaceDocHistory: [],
|
||||
defaultBrowserSessionProfileId: null,
|
||||
defaultBrowserSessionProfileIdByHostId: {},
|
||||
|
||||
@@ -1082,7 +938,11 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
})
|
||||
|
||||
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<AppState, [], [], BrowserSlice> =
|
||||
})
|
||||
|
||||
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<AppState, [], [], BrowserSlice> =
|
||||
}
|
||||
},
|
||||
|
||||
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<AppState, [], [], BrowserSlice> =
|
||||
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<AppState, [], [], BrowserSlice> =
|
||||
}
|
||||
},
|
||||
|
||||
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<AppState, [], [], BrowserSlice> =
|
||||
})
|
||||
},
|
||||
|
||||
clearBrowserHistory: () => set({ browserUrlHistory: [] })
|
||||
// One clear for both sources: the dropdown presents them as one history.
|
||||
clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] })
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<BrowserWorkspace> = 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))
|
||||
|
||||
@@ -39,6 +39,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = {
|
||||
clientHostedBrowserCloseIntentsByEnvironment: true,
|
||||
activeTabTypeByWorktree: true,
|
||||
browserUrlHistory: true,
|
||||
workspaceDocHistory: true,
|
||||
activeTabIdByWorktree: true,
|
||||
unifiedTabs: true,
|
||||
tabGroups: true,
|
||||
|
||||
@@ -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<WorkspaceSessionState> = z.o
|
||||
salvagingRecord(worktreeIdSchema, workspaceVisibleTabTypeSchema)
|
||||
),
|
||||
browserUrlHistory: salvagedOptional('browserUrlHistory', browserHistoryEntriesSchema),
|
||||
workspaceDocHistory: salvagedOptional('workspaceDocHistory', workspaceDocHistoryEntriesSchema),
|
||||
activeTabIdByWorktree: salvagedOptional(
|
||||
'activeTabIdByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, z.string().nullable())
|
||||
|
||||
@@ -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<string, WorkspaceVisibleTabType>
|
||||
/** 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<string, string | null>
|
||||
/** Unified tab model — present when saved by a build that includes TabsSlice.
|
||||
|
||||
@@ -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<PreparedPairedClient> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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),
|
||||
`<!doctype html><html><head><title>${FIXTURE_TITLE}</title></head>` +
|
||||
`<body><h1>${FIXTURE_HEADING}</h1></body></html>\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()
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user