diff --git a/Cargo.toml b/Cargo.toml index 297e9c11..24a7106d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/mcp-proxy", "crates/wt-migrate", "crates/agency-tools", + "crates/browse-core", ] [workspace.package] @@ -15,6 +16,8 @@ publish = false [workspace.dependencies] az-core = { path = "crates/core" } +# Browser policy shared with chuzz; see crates/browse-core/src/lib.rs. +ps-browse-core = { path = "crates/browse-core" } # One AgencyProxy release ships the client and the protocol together, and a # sidecar built from a client that disagrees with the protocol fails at connect diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml index 53dc7c24..b71e242e 100644 --- a/apps/gui/Cargo.toml +++ b/apps/gui/Cargo.toml @@ -24,7 +24,12 @@ experimental = ["dep:agent-experimental"] webview-runtime = ["tauri/wry"] blitz-runtime = [ "dep:blitz-dom", + # The browsing surface fetches page documents itself; `blitz-net` is the + # same provider the engine hands a document for its subresources, so a page + # and everything in it come through one client and one cache. + "dep:blitz-net", "dep:blitz-script", + "dep:blitz-traits", "dep:brotli", "dep:tauri-runtime-blitz", "dep:url", @@ -71,6 +76,10 @@ agent-experimental = { version = "^0.1.3", default-features = false, optional = promptsyntax = "0.2.0" shlex = "1.3" az-core.workspace = true +# Browser policy, shared with chuzz. Not gated on a renderer: tabs, history and +# address resolution are the same in a build that cannot draw a page, which is +# what lets such a build say so instead of appearing to work. +ps-browse-core.workspace = true # Shared with the migration and headless tools so one schema cannot resolve # against a different WorkTable implementation. worktable.workspace = true @@ -119,6 +128,11 @@ tauri-runtime-blitz = { version = "^0.3", optional = true, features = ["macos-pr # actually resolves. blitz-dom = { package = "ps-blitz-dom", version = "^0.3", features = ["system-fonts", "parallel-construct"], optional = true } blitz-script = { package = "ps-blitz-script", version = "^0.3", features = ["system-fonts"], optional = true } +# Same `^0.3` line as blitz-dom and blitz-script, and it has to be: two engine +# versions in one graph put two `NetProvider` traits in it, and the document +# config stops accepting the provider with an error that names neither. +blitz-net = { package = "ps-blitz-net", version = "^0.3", optional = true } +blitz-traits = { package = "ps-blitz-traits", version = "^0.3", optional = true } brotli = { version = "8.0.4", default-features = false, features = ["std"], optional = true } url = { version = "2.5.8", optional = true } tauri-plugin-updater = "2" diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index 90d714c6..1276c784 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -5,6 +5,8 @@ import type { AgentModels, AgentStatus, AvailableUpdate, + BrowseDebugEntry, + BrowseView, BuildInfo, ChatImportSource, ClaudeUsage, @@ -410,6 +412,39 @@ export interface AgencyZeroApi { */ resetTaskManager(): Promise; + // — Browsing ———————————————————————————————————————————————— + // + // Every one of these returns the whole surface rather than the field it + // changed. The surface also changes on its own — a load finishing, a page + // mounting — so the caller has to be able to redraw from a snapshot anyway, + // and a command that returned a fragment would give it two ways to be right. + + /** The browsing surface as it stands. */ + browseState(): Promise; + /** + * Go to whatever was typed. + * + * Nothing happens when the input is not an address: there is no search + * fallback, deliberately, so a typo goes nowhere visible instead of + * somewhere unrelated. + */ + browseNavigate(tab: number, input: string): Promise; + /** Open a tab, blank unless given an address. */ + browseOpenTab(input?: string): Promise; + /** Close a tab. Closing the last one blanks it rather than emptying the surface. */ + browseCloseTab(tab: number): Promise; + browseSelectTab(tab: number): Promise; + browseBack(tab: number): Promise; + browseForward(tab: number): Promise; + browseReload(tab: number): Promise; + /** + * The debugging stream from where the caller left off. + * + * A page that renders and then does nothing is almost always a subresource + * that never arrived, and without this that fact only exists on stderr. + */ + browseDebugLog(since?: number): Promise; + // — Events —————————————————————————————————————————————————— on( event: E, @@ -422,6 +457,13 @@ export interface AppEvents { /** An agent-authored restart is waiting for frontend-owned queued work. */ "app:restart-scheduled": { token: string }; "settings:updated": GlobalSettings; + /** + * The browsing surface changed: a load started, finished, or a page mounted. + * + * Carries the whole surface, not a delta. A dropped delta leaves a tab strip + * permanently wrong; a dropped snapshot is corrected by the next one. + */ + "browse:state": Omit; "project:created": Project; "project:updated": Project; "project:deleted": { id: string }; diff --git a/apps/gui/frontend/src/api/index.ts b/apps/gui/frontend/src/api/index.ts index d6d49a54..a8c3ba9a 100644 --- a/apps/gui/frontend/src/api/index.ts +++ b/apps/gui/frontend/src/api/index.ts @@ -32,6 +32,15 @@ export { type CommandMethod = Exclude; const COMMAND_FOR: Record = { + browseState: "browse_state", + browseNavigate: "browse_navigate", + browseOpenTab: "browse_open_tab", + browseCloseTab: "browse_close_tab", + browseSelectTab: "browse_select_tab", + browseBack: "browse_back", + browseForward: "browse_forward", + browseReload: "browse_reload", + browseDebugLog: "browse_debug_log", listProjects: "list_projects", getHomeSnapshot: "get_home_snapshot", createProject: "create_project", diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index 8bc6f9e0..4512fcc9 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -3,6 +3,9 @@ import type { AgentIoEntry, AgentModels, AgentStatus, + BrowseHistoryEntry, + BrowseTab, + BrowseView, CreatedProject, DataLocationView, GlobalSettings, @@ -1191,6 +1194,84 @@ export function createMockApi(): AgencyZeroApi { frontendSubscriptionsReady: () => settle(undefined), confirmAgentRestart: () => settle(undefined), + // — Browsing ———————————————————————————————————————————————— + // + // A real surface, not a canned answer. `docs/ui-verification.md` drives + // this app headlessly against the mock, so the browsing chrome can only be + // verified here if tabs, history and the Back button actually behave. What + // the mock cannot do is fetch or render: a navigation resolves to a + // "loaded" tab with no page behind it, and `canRender` says so. + + browseState: () => settle(browseView()), + browseNavigate: (tab, input) => { + const url = browseUrlFrom(input); + const state = browseTabs.find((candidate) => candidate.id === tab); + if (url && state) { + browseVisit(state, url); + } + return settle(browseView()); + }, + browseOpenTab: (input) => { + const url = input ? browseUrlFrom(input) : null; + const state: MockBrowseTab = { + id: browseNextId++, + history: [{ url: url ?? BROWSE_BLANK, title: "" }], + current: 0, + status: url ? "loaded" : "empty", + }; + browseTabs.push(state); + browseActive = state.id; + return settle(browseView()); + }, + browseCloseTab: (tab) => { + const index = browseTabs.findIndex((candidate) => candidate.id === tab); + if (index !== -1) { + if (browseTabs.length === 1) { + // Closing the last tab blanks it. A surface with no tab has nowhere + // to put a page and no address-bar target, so the Rust never lets it + // happen and neither does this. + browseTabs[0] = { + id: tab, + history: [{ url: BROWSE_BLANK, title: "" }], + current: 0, + status: "empty", + }; + } else { + browseTabs.splice(index, 1); + if (browseActive === tab) { + browseActive = browseTabs[Math.min(index, browseTabs.length - 1)].id; + } + } + } + return settle(browseView()); + }, + browseSelectTab: (tab) => { + if (browseTabs.some((candidate) => candidate.id === tab)) { + browseActive = tab; + } + return settle(browseView()); + }, + browseBack: (tab) => { + const state = browseTabs.find((candidate) => candidate.id === tab); + if (state && state.current > 0) { + state.current -= 1; + } + return settle(browseView()); + }, + browseForward: (tab) => { + const state = browseTabs.find((candidate) => candidate.id === tab); + if (state && state.current + 1 < state.history.length) { + state.current += 1; + } + return settle(browseView()); + }, + browseReload: (tab) => { + void tab; + return settle(browseView()); + }, + browseDebugLog: (since) => + settle(browseLog.filter((entry) => since === undefined || entry.seq > since)), + async on( event: E, handler: (payload: AppEvents[E]) => void, @@ -1222,3 +1303,80 @@ export function createMockApi(): AgencyZeroApi { return entry; } } + +/** A mock browsing tab: the same history model the Rust keeps, in miniature. */ +interface MockBrowseTab { + id: number; + history: { url: string; title: string }[]; + current: number; + status: BrowseTab["status"]; +} + +const BROWSE_BLANK = "about:blank"; + +let browseNextId = 1; +let browseActive = 0; +const browseTabs: MockBrowseTab[] = [ + { id: 0, history: [{ url: BROWSE_BLANK, title: "" }], current: 0, status: "empty" }, +]; +const browseLog: { + seq: number; + level: "info" | "warn" | "error"; + source: string; + message: string; +}[] = []; + +/** + * The same address policy the Rust applies, and it has to be the same: a UI + * test that types prose and sees a navigation would pass against a mock that + * was more permissive than the thing it stands in for. + */ +function browseUrlFrom(input: string): string | null { + const text = input.trim(); + if (!text) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return text; + if (/\s/.test(text)) return null; + const host = text.split(/[/?#]/)[0].split(":")[0]; + const bare = + host === "localhost" || (host.includes(".") && !host.startsWith(".") && !host.endsWith(".")); + return bare ? `https://${text}` : null; +} + +function browseVisit(tab: MockBrowseTab, url: string): void { + if (tab.history[tab.current].url === url) return; + tab.history = tab.history.slice(0, tab.current + 1); + tab.history.push({ url, title: "" }); + tab.current = tab.history.length - 1; + tab.status = "loaded"; + browseLog.push({ + seq: browseLog.length, + level: "info", + source: "nav", + message: `tab ${tab.id}: ${url}`, + }); +} + +function browseView(): BrowseView { + const active = browseTabs.find((tab) => tab.id === browseActive) ?? browseTabs[0]; + const history: BrowseHistoryEntry[] = active.history.map((entry, index) => ({ + url: entry.url, + title: entry.title || entry.url, + current: index === active.current, + })); + return { + tabs: browseTabs.map((tab) => ({ + id: tab.id, + title: tab.history[tab.current].title || tab.history[tab.current].url, + url: tab.history[tab.current].url, + status: tab.status, + canGoBack: tab.current > 0, + canGoForward: tab.current + 1 < tab.history.length, + })), + active: active.id, + history, + // The mock has no engine. Saying so is the point: the chrome renders its + // "this build cannot show pages" state against it, which is otherwise only + // reachable in a webview-only build. + canRender: false, + }; +} diff --git a/apps/gui/frontend/src/api/tauri.ts b/apps/gui/frontend/src/api/tauri.ts index ac6a9c53..c58ccc60 100644 --- a/apps/gui/frontend/src/api/tauri.ts +++ b/apps/gui/frontend/src/api/tauri.ts @@ -166,6 +166,16 @@ export function createCommandApi(call: CommandCaller, on: EventListener): Agency listApprovalRules: (projectId) => call("list_approval_rules", { projectId }), clearApprovalRules: (projectId) => call("clear_approval_rules", { projectId }), + browseState: () => call("browse_state"), + browseNavigate: (tab, input) => call("browse_navigate", { tab, input }), + browseOpenTab: (input) => call("browse_open_tab", { input: input ?? null }), + browseCloseTab: (tab) => call("browse_close_tab", { tab }), + browseSelectTab: (tab) => call("browse_select_tab", { tab }), + browseBack: (tab) => call("browse_back", { tab }), + browseForward: (tab) => call("browse_forward", { tab }), + browseReload: (tab) => call("browse_reload", { tab }), + browseDebugLog: (since) => call("browse_debug_log", { since: since ?? null }), + on, }; } diff --git a/apps/gui/frontend/src/features/browse/BrowseTab.tsx b/apps/gui/frontend/src/features/browse/BrowseTab.tsx new file mode 100644 index 00000000..2b59855d --- /dev/null +++ b/apps/gui/frontend/src/features/browse/BrowseTab.tsx @@ -0,0 +1,343 @@ +import { Input } from "@pathscale/ui"; +import type { JSX } from "@solidjs/web"; +import { createSignal, For, onCleanup, onSettled, Show } from "solid-js"; +import { Button } from "~/components/Button"; +import { Icon } from "~/components/Icon"; +import { whileMounted } from "~/lib/live"; +import { tx } from "~/stores/i18n"; +import { useWorkspace } from "~/stores/workspace"; +import type { BrowseDebugEntry, BrowseView } from "~/types"; + +/** + * The browsing surface: a page, rendered by the same engine that draws this + * window. + * + * # Where the state lives + * + * Not here. Tabs, history and the address are held in Rust + * (`apps/gui/src/browse.rs` over `ps-browse-core`), because the page itself is + * a document the engine owns and a second copy in the store would be a second + * truth to disagree with. This pane holds the last snapshot it was handed and + * replaces it wholesale — every command returns the whole surface, and + * `browse:state` delivers the changes nobody asked for, like a load finishing. + * + * # The mount + * + * `` is not a control. It is the rendezvous point between this + * document and the page's: Rust looks it up by id and attaches a sub-document + * to that node. Nothing is rendered into it from here, and its id must keep + * matching `mount_node` in `browse.rs` — when that drifts the page loads + * correctly and appears nowhere, which is indistinguishable from a page that + * failed. + */ +export function BrowseTab(): JSX.Element { + const { actions } = useWorkspace(); + const [view, setView] = createSignal(null); + const [address, setAddress] = createSignal(""); + /** True while the address bar has focus, so a refresh does not fight typing. */ + const [editing, setEditing] = createSignal(false); + const [debugOpen, setDebugOpen] = createSignal(false); + const [debug, setDebug] = createSignal([]); + + const alive = whileMounted(); + + const active = () => { + const current = view(); + return current?.tabs.find((tab) => tab.id === current.active) ?? null; + }; + + /** Adopt a snapshot, leaving the address bar alone while it is being typed in. */ + const adopt = (next: BrowseView) => { + setView(next); + if (!editing()) { + const tab = next.tabs.find((candidate) => candidate.id === next.active); + setAddress(tab && tab.url !== "about:blank" ? tab.url : ""); + } + }; + + const refresh = (): void => { + void actions + .browseState() + .then(alive(adopt)) + .catch(alive(() => setView(null))); + }; + + onSettled(() => { + refresh(); + + // The surface changes without being asked: a fetch finishes, a page + // mounts. Re-reading the whole state rather than patching from the payload + // keeps one code path for "draw the surface". + // + // The unlisten is owned here. This pane is unmounted whenever the window + // changes tab, and a subscription that outlived it would write into a + // disposed scope — which in Solid 2 halts reactivity for the whole app, + // not just for this component. + let unlisten: (() => void) | undefined; + void actions.onBrowseState(refresh).then( + alive((stop: () => void) => { + unlisten = stop; + }), + ); + onCleanup(() => unlisten?.()); + }); + + const run = (work: Promise) => { + void work.then(alive(adopt)).catch(alive(() => refresh())); + }; + + const submit = (event: Event) => { + event.preventDefault(); + const tab = active(); + if (!tab) return; + setEditing(false); + run(actions.browseNavigate(tab.id, address())); + }; + + const refreshDebug = () => { + void actions + .browseDebugLog() + .then(alive(setDebug)) + .catch(alive(() => setDebug([]))); + }; + + return ( +
+ {/* The browsing tab strip, which is not the window's tab strip. These are + pages inside one workspace tab. */} +
+ + {(tab) => ( +
+ + {/* + `role="img"`, because a bare span carries no role and an + aria-label on a roleless element is not announced. The dot is + the only thing that says how the last load went. + */} + + +
+ )} +
+ +
+ + {/* Address bar and history controls. */} +
+ + + + {/* + `Input.Field`, not a bare HTML input element. + `scripts/check-ui-controls.ts` bans the raw elements outright and + requires every value-bearing control to be one the QA suite already + drives — otherwise this ships an address bar that no rendered test + can type into. The ban is a line regex, so it also catches the + element named in a comment; that is why this sentence spells it out + rather than showing it. + */} + setAddress(event.currentTarget.value)} + onFocus={() => setEditing(true)} + onBlur={() => setEditing(false)} + class="h-7 min-w-0 flex-1 rounded-full bg-az-chip px-3 text-base-content text-ui-detail outline-none focus:ring-1 focus:ring-primary/40" + /> + + + + {/* + A build with no renderer says so. + The whole surface still works — tabs, history, the address bar — and + silently showing an empty viewport instead would look exactly like a + page that never loads. + */} + +

+ {tx("This build has no page renderer, so addresses resolve but nothing is drawn.")} +

+
+ + {/* The page. */} +
+ + +

{tx("Nothing open")}

+

+ {tx("Type an address above. Pages render with the same engine as this window.")} +

+
+ } + > + {/* + The mount, and only the mount. Rust finds this node by id and + attaches the page's document to it; anything rendered inside would + be replaced without warning. + */} + + +
+ + +
+
+

{tx("What the browser did")}

+ +
+
    + {tx("Nothing recorded yet.")} + } + > + {(entry) => ( +
  • + {entry.source} {entry.message} +
  • + )} +
    +
+
+
+ + ); +} + +/** The status dot's colour, by how the last load went. */ +const STATUS_TONE: Record = { + empty: "bg-az-hairline", + loading: "bg-info", + loaded: "bg-success", + partial: "bg-warning", + degraded: "bg-warning", + error: "bg-error", +}; + +const LEVEL_TONE: Record = { + info: "text-az-body", + warn: "text-warning", + error: "text-error", +}; diff --git a/apps/gui/frontend/src/features/tabs/TabStrip.tsx b/apps/gui/frontend/src/features/tabs/TabStrip.tsx index 9e7ff9e4..84d6e3c5 100644 --- a/apps/gui/frontend/src/features/tabs/TabStrip.tsx +++ b/apps/gui/frontend/src/features/tabs/TabStrip.tsx @@ -24,6 +24,7 @@ const TAB_ICON: Record = { draft: "file-plus-2", settings: "settings", analytics: "gauge", + browse: "search", project: null, }; @@ -194,6 +195,28 @@ export function TabStrip(): JSX.Element { {/* Room for the macOS traffic lights, which the window keeps. */}
+ {/* + Expand from the left: the full browsing surface. + + On this edge rather than beside the gear on the right, and that is the + whole point of it — it opens *outward* from where the window's own + content begins, so it reads as widening into a bigger surface rather + than as one more utility tab. The chevron points the way it opens. + */} + + nudge(-1)} /> diff --git a/apps/gui/frontend/src/features/tabs/WorkspacePanes.tsx b/apps/gui/frontend/src/features/tabs/WorkspacePanes.tsx index 0cd0660a..490892b8 100644 --- a/apps/gui/frontend/src/features/tabs/WorkspacePanes.tsx +++ b/apps/gui/frontend/src/features/tabs/WorkspacePanes.tsx @@ -1,6 +1,7 @@ import type { JSX } from "@solidjs/web"; import { createMemo, Match, Show, Switch } from "solid-js"; import { AnalyticsTab } from "~/features/analytics/AnalyticsTab"; +import { BrowseTab } from "~/features/browse/BrowseTab"; import { HomeTab } from "~/features/home/HomeTab"; import { ProjectPanel } from "~/features/project/ProjectPanel"; import { ProjectTab } from "~/features/project/ProjectTab"; @@ -55,6 +56,11 @@ export function WorkspacePanes(): JSX.Element {
+ +
+ +
+
diff --git a/apps/gui/frontend/src/i18n/ui/en.ts b/apps/gui/frontend/src/i18n/ui/en.ts index 61f1669e..0077bef3 100644 --- a/apps/gui/frontend/src/i18n/ui/en.ts +++ b/apps/gui/frontend/src/i18n/ui/en.ts @@ -1037,6 +1037,21 @@ const en = { Timings: "Timings", "worst total first": "worst total first", "Nothing measured yet": "Nothing measured yet", + Browse: "Browse", + "Show {title}": "Show {title}", + "Status: {status}": "Status: {status}", + "Close tab": "Close tab", + "New tab": "New tab", + Forward: "Forward", + Reload: "Reload", + Address: "Address", + "Enter an address": "Enter an address", + "What the browser did": "What the browser did", + "This build has no page renderer, so addresses resolve but nothing is drawn.": + "This build has no page renderer, so addresses resolve but nothing is drawn.", + "Type an address above. Pages render with the same engine as this window.": + "Type an address above. Pages render with the same engine as this window.", + "Nothing recorded yet.": "Nothing recorded yet.", } as const; export default en; diff --git a/apps/gui/frontend/src/i18n/ui/zh.ts b/apps/gui/frontend/src/i18n/ui/zh.ts index de8e4951..527a4196 100644 --- a/apps/gui/frontend/src/i18n/ui/zh.ts +++ b/apps/gui/frontend/src/i18n/ui/zh.ts @@ -998,6 +998,21 @@ const zh = { Timings: "耗时", "worst total first": "按总耗时降序", "Nothing measured yet": "尚无测量数据", + Browse: "浏览", + "Show {title}": "显示 {title}", + "Status: {status}": "状态:{status}", + "Close tab": "关闭标签页", + "New tab": "新建标签页", + Forward: "前进", + Reload: "重新加载", + Address: "地址", + "Enter an address": "输入地址", + "What the browser did": "浏览器做了什么", + "This build has no page renderer, so addresses resolve but nothing is drawn.": + "此版本没有页面渲染器,地址可以解析但不会绘制任何内容。", + "Type an address above. Pages render with the same engine as this window.": + "在上方输入地址。页面使用与本窗口相同的引擎渲染。", + "Nothing recorded yet.": "尚无记录。", } satisfies Record; export default zh; diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index ce93a99f..86e7d68a 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -2586,6 +2586,25 @@ export function createWorkspace() { focus("analytics", true); } + /** + * The expand control opens Browse as a real tab, beside Home. + * + * A tab rather than an overlay: a page you are reading is somewhere you go, + * not a mode you are trapped in, and everything the window already does with + * tabs — restore on boot, Cmd-number, close — then works on it for free. + */ + function openBrowse(): void { + if (!state.tabs.some((tab) => tab.kind === "browse")) { + setState((d) => { + d.tabs = ((tabs) => [ + ...tabs, + { ...HOME_TAB, key: "browse", kind: "browse", label: "Browse" }, + ])(d.tabs); + }); + } + focus("browse", true); + } + /** One draft at a time: a second "+" focuses the Untitled tab already open. */ function openDraft(): void { const existing = state.tabs.find((tab) => tab.kind === "draft"); @@ -3281,6 +3300,7 @@ export function createWorkspace() { deferOnboarding, completeOnboarding, openAnalytics, + openBrowse, openDraft, closeTab, setTabModel, @@ -3479,6 +3499,27 @@ export function createWorkspace() { await client().clearApprovalRules(projectId); patchProjectPanelData(projectId, { approvalRules: [] }); }, + // Browsing goes straight through. The surface's state lives in Rust — it + // has to, the page is a document the engine owns — so mirroring it into + // the workspace store would give the window two copies of one truth and a + // way for them to disagree. The Browse pane holds the snapshot it was + // last given and re-reads on `browse:state`. + browseState: () => client().browseState(), + browseNavigate: (tab: number, input: string) => client().browseNavigate(tab, input), + browseOpenTab: (input?: string) => client().browseOpenTab(input), + browseCloseTab: (tab: number) => client().browseCloseTab(tab), + browseSelectTab: (tab: number) => client().browseSelectTab(tab), + browseBack: (tab: number) => client().browseBack(tab), + browseForward: (tab: number) => client().browseForward(tab), + browseReload: (tab: number) => client().browseReload(tab), + browseDebugLog: (since?: number) => client().browseDebugLog(since), + /** + * Subscribe to the surface changing on its own — a load finishing, a page + * mounting. Returns the unlisten, which the caller owns: the Browse pane is + * unmounted every time the window changes tab, and a subscription that + * outlived it would write into a disposed scope. + */ + onBrowseState: (handler: () => void) => client().on("browse:state", handler), getCostSummary: () => client().getCostSummary(), getUsageAnalytics: () => client().getUsageAnalytics(), discoverChatImports: () => client().discoverChatImports(), diff --git a/apps/gui/frontend/src/types/index.ts b/apps/gui/frontend/src/types/index.ts index 5725b7ff..7ff822d8 100644 --- a/apps/gui/frontend/src/types/index.ts +++ b/apps/gui/frontend/src/types/index.ts @@ -8,7 +8,7 @@ */ /** Which screen a tab shows. `home` is not closable; the rest are. */ -export type TabKind = "home" | "draft" | "settings" | "project" | "analytics"; +export type TabKind = "home" | "draft" | "settings" | "project" | "analytics" | "browse"; /** One enum for both layers: a Project and its ProjectItems share it. */ /** @@ -1272,3 +1272,59 @@ export type MessagePage = { messages: Message[]; total: number; }; + +/** + * One tab of the browsing surface. + * + * Mirrors `ps_browse_core::TabSnapshot`. The id is the browser's own, not a + * workspace tab key: browsing tabs live inside the Browse surface and are + * unrelated to the window's tab strip. + */ +export interface BrowseTab { + id: number; + /** The page's title, or its address while it has none. */ + title: string; + url: string; + /** + * `loading` while a fetch is in flight, otherwise how the last load went. + * + * `partial` and `degraded` are the states worth having: a page whose + * subresources all failed looks exactly like one that worked if the only + * answer is loaded/not-loaded. + */ + status: "empty" | "loading" | "loaded" | "partial" | "degraded" | "error"; + canGoBack: boolean; + canGoForward: boolean; +} + +/** One entry in the active tab's back/forward stack. */ +export interface BrowseHistoryEntry { + url: string; + title: string; + current: boolean; +} + +/** The whole browsing surface in one read. */ +export interface BrowseView { + tabs: BrowseTab[]; + /** Which tab's page is showing. */ + active: number; + /** The active tab's history, oldest first. */ + history: BrowseHistoryEntry[]; + /** + * Whether this build can render a page at all. + * + * A webview-only build has the whole surface and no engine behind it. Saying + * so is the difference between an explained limitation and a browser that + * appears to work and shows nothing. + */ + canRender: boolean; +} + +/** One line of the browsing surface's debugging stream. */ +export interface BrowseDebugEntry { + seq: number; + level: "info" | "warn" | "error"; + source: string; + message: string; +} diff --git a/apps/gui/frontend/src/web-view.d.ts b/apps/gui/frontend/src/web-view.d.ts new file mode 100644 index 00000000..3bcaa5e6 --- /dev/null +++ b/apps/gui/frontend/src/web-view.d.ts @@ -0,0 +1,26 @@ +/** + * The page mount. + * + * `` is not a component and renders nothing on its own: Rust looks + * the element up by id and attaches the page's document to that node + * (`apps/gui/src/browse.rs`). It is declared here because it has no HTML + * definition to inherit one from, and without a declaration the Browse pane + * fails to typecheck on an element the engine defines rather than the DOM. + * + * A file of its own, with an `export {}`, and both halves matter. A + * `declare module` in a *script* file declares an ambient module that + * **replaces** the real one — putting this in `env.d.ts` made every + * `JSX.Element` in the app resolve to nothing. Only inside a module is it the + * augmentation it reads as. + */ +export {}; + +declare module "@solidjs/web" { + namespace JSX { + interface IntrinsicElements { + "web-view": HTMLAttributes & { + "data-tab-id"?: string; + }; + } + } +} diff --git a/apps/gui/src/browse.rs b/apps/gui/src/browse.rs new file mode 100644 index 00000000..e99d22fa --- /dev/null +++ b/apps/gui/src/browse.rs @@ -0,0 +1,551 @@ +//! The browsing surface: pages rendered inside AgencyZero. +//! +//! # Why there is anything to do here at all +//! +//! AgencyZero already renders on Blitz. The engine that draws this window is +//! the same one a browser would use to draw a page, so "embed a browser" is +//! not an engine integration — the engine is present, and `apps/blitz-preview` +//! already proves it can show a document. What was missing is *policy*: which +//! tab is showing what, what Back means, whether the string someone typed is +//! an address, and what to do when half a page arrives. +//! +//! That policy lives in [`ps_browse_core`], deliberately outside this file and +//! outside this application, because chuzz needs the same answers. This module +//! is the half that cannot be shared: the network provider, the document, and +//! the mount inside the chrome that a page is attached to. +//! +//! # The shape +//! +//! ```text +//! chrome (Solid) ──command──▶ ps_browse_core::Tabs ──Load──▶ fetch task +//! ▲ ▲ │ +//! └────── browse:state ──────────┴────── completed queue ◀──────┘ +//! │ +//! poll hook on the chrome +//! document attaches the page +//! ``` +//! +//! The policy never fetches and the fetch never decides. A load that comes +//! back carries the generation it was issued at, and [`ps_browse_core::Tabs`] +//! drops it if the tab has moved on — which is the only thing standing between +//! a slow page you navigated away from and it reappearing over the one you +//! asked for. +//! +//! # Why the attach happens in a poll hook +//! +//! A page can only be mounted from the UI thread, into the chrome document, +//! and only once the chrome has actually rendered the `` element +//! that receives it. A fetch finishing has no access to any of that. So a +//! finished page joins a queue, the chrome document's poll hook drains it, and +//! a bundle whose mount is not in the tree yet is put back rather than +//! dropped: the alternative is a page that loaded correctly and rendered +//! nowhere, which is indistinguishable from a page that failed. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "blitz-runtime")] +use blitz_dom::Document as _; +use ps_browse_core::{BrowseSnapshot, DebugEntry, DebugLog, Load, PageOutcome, TabId, Tabs}; +use serde::Serialize; +use tauri::{Emitter, Manager, State}; + +use crate::AppHandle; + +/// Emitted whenever anything about the browsing surface changes. +/// +/// One topic for the whole surface rather than one per field. The chrome +/// re-reads a snapshot; it does not reconstruct state from a stream of deltas, +/// which is the design that makes a dropped event a permanently wrong tab +/// strip. +pub const BROWSE_STATE_EVENT: &str = "browse:state"; + +/// How a page's bytes came back, on their way to being mounted. +struct PageBundle { + tab: TabId, + generation: u64, + /// Where the response actually came from, after redirects. + resolved: ps_browse_core::Url, + html: String, + title_hint: String, + outcome: PageOutcome, +} + +struct BrowseInner { + tabs: Mutex, + log: Mutex, + /// Pages fetched but not yet mounted. Drained by the poll hook on the UI + /// thread; see the module docs for why the attach cannot happen inline. + completed: Mutex>, + app: Mutex>, + #[cfg(feature = "blitz-runtime")] + net: Arc, +} + +/// The browsing surface, shared between the Tauri commands and the UI thread. +#[derive(Clone)] +pub struct Browse(Arc); + +impl Browse { + pub fn new() -> Self { + Self(Arc::new(BrowseInner { + tabs: Mutex::new(Tabs::new()), + log: Mutex::new(DebugLog::default()), + completed: Mutex::new(VecDeque::new()), + app: Mutex::new(None), + #[cfg(feature = "blitz-runtime")] + // No waker. A page's own subresources wake the window through the + // document's provider; this one exists to fetch documents, and its + // completion path is the queue below rather than a redraw. + net: Arc::new(blitz_net::Provider::new(None)), + })) + } + + /// Give the surface a handle to emit state on. Called once from `setup`. + pub fn attach_app(&self, app: AppHandle) { + *self.0.app.lock().unwrap() = Some(app); + } + + fn note(&self, level: &'static str, source: &'static str, message: String) { + self.0.log.lock().unwrap().push(level, source, message); + } + + fn snapshot(&self) -> BrowseSnapshot { + self.0.tabs.lock().unwrap().snapshot() + } + + /// Tell the chrome the surface changed. + fn emit(&self) { + let snapshot = self.snapshot(); + if let Some(app) = self.0.app.lock().unwrap().as_ref() { + let _ = app.emit(BROWSE_STATE_EVENT, snapshot); + } + } + + /// Perform a load the policy asked for, then tell the chrome. + /// + /// Takes the load by value because it is a one-shot instruction: acting on + /// the same `Load` twice would issue two fetches at one generation and let + /// whichever finished second overwrite the first for no reason. + fn dispatch(&self, load: Option) { + if let Some(load) = load { + self.note("info", "nav", format!("tab {}: {}", load.tab, load.url)); + self.fetch(load); + } + self.emit(); + } + + #[cfg(feature = "blitz-runtime")] + fn fetch(&self, load: Load) { + let browse = self.clone(); + let net = Arc::clone(&self.0.net); + // Tauri's runtime, not one of our own. The document a fetch produces is + // mounted on the UI thread, which is already inside this reactor, and + // a second runtime would give page loads their own thread pool and + // their own shutdown for no benefit. + tauri::async_runtime::spawn(async move { + let request = blitz_traits::net::Request::get(load.url.clone()); + let bundle = match net.fetch_response_async(request).await { + Ok(response) => { + let status = response.status; + let resolved = response.url.clone(); + let html = decode_body(&response); + let outcome = if status.is_success() { + PageOutcome::Loaded + } else { + // The bytes are kept: a 404 page is a page, and every + // browser shows the server's own version of it rather + // than replacing it with a generic one. + PageOutcome::Partial + }; + browse.note( + if status.is_success() { "info" } else { "warn" }, + "net", + format!("{} {}", status.as_u16(), resolved), + ); + PageBundle { + tab: load.tab, + generation: load.generation, + resolved, + html, + title_hint: String::new(), + outcome, + } + } + Err(error) => { + browse.note("error", "net", format!("{}: {error}", load.url)); + PageBundle { + tab: load.tab, + generation: load.generation, + resolved: load.url.clone(), + html: error_html(&error.to_string()), + title_hint: load.url.to_string(), + outcome: PageOutcome::Error, + } + } + }; + + browse.0.completed.lock().unwrap().push_back(bundle); + // The chrome is told immediately, before the page is mounted. The + // address bar and the spinner belong to the chrome and should not + // wait on a mount that happens on the next frame. + browse.emit(); + }); + } + + /// Without the Blitz runtime there is no engine to render a page in. + /// + /// A webview-only build still has the whole surface — tabs, history, the + /// address bar — and says so on the page rather than appearing to work and + /// showing nothing. Silently doing nothing here is the failure mode this + /// avoids: it looks exactly like a page that never loads. + #[cfg(not(feature = "blitz-runtime"))] + fn fetch(&self, load: Load) { + self.note( + "error", + "net", + format!( + "this build has no renderer for pages; {} not loaded", + load.url + ), + ); + self.0.tabs.lock().unwrap().finish_load( + load.tab, + load.generation, + &load.url.to_string(), + PageOutcome::Error, + ); + } +} + +impl Default for Browse { + fn default() -> Self { + Self::new() + } +} + +/// Turn a response body into text. +/// +/// Charset from the `Content-Type` header, UTF-8 otherwise, and lossy rather +/// than fallible: a page with one bad byte in it is still a page, and refusing +/// to show it is a worse answer than a replacement character in one word. +#[cfg(feature = "blitz-runtime")] +fn decode_body(response: &blitz_traits::platform::FetchResponse) -> String { + let charset = response + .headers + .get(blitz_traits::net::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + value + .split(';') + .find_map(|part| part.trim().strip_prefix("charset=")) + }) + .map(|charset| charset.trim_matches('"').to_ascii_lowercase()) + .unwrap_or_else(|| "utf-8".to_string()); + + match charset.as_str() { + "utf-8" | "utf8" | "us-ascii" | "ascii" => { + String::from_utf8_lossy(&response.body).into_owned() + } + // Everything else is treated as Latin-1, which is what a byte-for-byte + // mapping gives and is right for the western pages that still declare + // `iso-8859-1`. A full charset table is a real dependency and belongs + // in the engine, not here. + _ => response.body.iter().map(|byte| *byte as char).collect(), + } +} + +/// The document shown when a page could not be fetched. +/// +/// Explicit colours, and light ones. This document declares none of its own, +/// so it would inherit the engine's defaults — black text on a transparent +/// background — over a viewport the shell paints with the dark theme surface. +/// The error would render, lay out, and be unreadable, which is +/// indistinguishable from not rendering at all. +fn error_html(error: &str) -> String { + let escaped = escape(error); + format!( + r#"Cannot load + +
+

This page could not be loaded

+

{escaped}

+
"# + ) +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// The chrome element a tab's page is mounted into. +/// +/// Matched by id first and by `data-tab-id` second, so the chrome can render +/// the mount either way without this having to know which. Nothing else in the +/// document is a ``; the element exists for exactly this. +#[cfg(feature = "blitz-runtime")] +fn mount_node(document: &blitz_dom::BaseDocument, tab: TabId) -> Option { + if let Some(node) = document.get_element_by_id(&format!("az-browse-page-{tab}")) { + return Some(node); + } + let tab = tab.to_string(); + document + .query_selector_all("web-view") + .ok()? + .into_iter() + .find(|node| { + document + .get_node(*node) + .and_then(|node| node.element_data()) + .is_some_and(|element| { + element + .attrs + .iter() + .any(|attr| attr.name.local.as_ref() == "data-tab-id" && attr.value == tab) + }) + }) +} + +#[cfg(feature = "blitz-runtime")] +impl Browse { + /// Attach the surface to the chrome document. + /// + /// The poll hook runs on the UI thread on every frame the document is + /// polled, which is the only place a sub-document may be mounted. + pub fn install_document_lifecycle(&self, document: &mut blitz_script::ScriptDocument) { + let browse = self.clone(); + let mut pending: VecDeque = VecDeque::new(); + document.add_poll_hook(move |document, _| browse.poll_document(document, &mut pending)); + } + + /// Mount whatever has finished fetching. Returns whether anything changed. + fn poll_document( + &self, + chrome: &mut blitz_script::ScriptDocument, + pending: &mut VecDeque, + ) -> bool { + pending.extend(self.0.completed.lock().unwrap().drain(..)); + if pending.is_empty() { + return false; + } + + let mut retained = VecDeque::new(); + let mut changed = false; + + while let Some(bundle) = pending.pop_front() { + if !self + .0 + .tabs + .lock() + .unwrap() + .accepts(bundle.tab, bundle.generation) + { + // The tab moved on while this was in flight. Dropped, not + // mounted: this is the stale-load case the generation exists + // for. + continue; + } + + let Some(target) = mount_node(&chrome.inner(), bundle.tab) else { + // The chrome has not rendered the mount yet. Held rather than + // dropped — a page that loaded and rendered nowhere looks + // exactly like a page that failed. + retained.push_back(bundle); + continue; + }; + + let shell_provider = chrome.inner().shell_provider.clone(); + let config = blitz_dom::DocumentConfig { + base_url: Some(bundle.resolved.to_string()), + // The page's own provider, so its images, stylesheets and + // fonts are fetched under the ordinary subresource path rather + // than through the document fetch above. + net_provider: Some( + Arc::clone(&self.0.net) as Arc + ), + shell_provider: Some(shell_provider), + ..Default::default() + }; + let page = blitz_script::ScriptDocument::from_html(&bundle.html, config); + let title = page + .inner() + .find_title_node() + .map(|node| node.text_content()) + .unwrap_or_default(); + let title = if title.trim().is_empty() { + bundle.title_hint.clone() + } else { + title + }; + + self.note( + "info", + "page", + format!( + "attached {} to tab {} as {}", + bundle.resolved, + bundle.tab, + bundle.outcome.name() + ), + ); + + { + let mut tabs = self.0.tabs.lock().unwrap(); + tabs.record_redirect(bundle.tab, bundle.generation, bundle.resolved.clone()); + tabs.finish_load(bundle.tab, bundle.generation, &title, bundle.outcome); + } + + chrome.inner_mut().set_sub_document(target, Box::new(page)); + changed = true; + } + + *pending = retained; + if changed { + self.emit(); + } + changed + } +} + +/// Everything the chrome needs to draw the surface in one read. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowseView { + #[serde(flatten)] + pub snapshot: BrowseSnapshot, + /// Whether this build can actually render a page. A surface that cannot + /// says so up front rather than after a navigation that goes nowhere. + pub can_render: bool, +} + +#[tauri::command] +pub fn browse_state(state: State<'_, Browse>) -> BrowseView { + BrowseView { + snapshot: state.snapshot(), + can_render: cfg!(feature = "blitz-runtime"), + } +} + +#[tauri::command] +pub fn browse_navigate(tab: TabId, input: String, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().navigate(tab, &input); + if load.is_none() { + state.note("warn", "nav", format!("not an address: {input}")); + } + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_open_tab(input: Option, state: State<'_, Browse>) -> BrowseView { + let (_, load) = state.0.tabs.lock().unwrap().open(input.as_deref()); + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_close_tab(tab: TabId, state: State<'_, Browse>) -> BrowseView { + state.0.tabs.lock().unwrap().close(tab); + state.emit(); + browse_state(state) +} + +#[tauri::command] +pub fn browse_select_tab(tab: TabId, state: State<'_, Browse>) -> BrowseView { + state.0.tabs.lock().unwrap().select(tab); + state.emit(); + browse_state(state) +} + +#[tauri::command] +pub fn browse_back(tab: TabId, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().back(tab); + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_forward(tab: TabId, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().forward(tab); + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_reload(tab: TabId, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().reload(tab); + state.dispatch(load); + browse_state(state) +} + +/// The debugging stream, from where the caller left off. +#[tauri::command] +pub fn browse_debug_log(since: Option, state: State<'_, Browse>) -> Vec { + state.0.log.lock().unwrap().since(since) +} + +/// Wire the surface into the app. One call, so a build that forgets it fails +/// at the missing state rather than at a command that silently returns nothing. +pub fn manage(app: &AppHandle, browse: Browse) { + browse.attach_app(app.clone()); + app.manage(browse); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Not a rendering test. The policy is tested in `ps-browse-core`; what is + /// worth pinning here is that the commands route to it and that a surface + /// with no window still answers. + #[test] + fn a_fresh_surface_has_one_blank_tab() { + let browse = Browse::new(); + let snapshot = browse.snapshot(); + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.tabs[0].url, ps_browse_core::NEW_TAB_URL); + } + + /// Emitting with no app handle attached must not panic. `setup` runs after + /// the state is managed, so there is a real window in which commands can + /// arrive before a handle exists. + #[test] + fn emitting_before_the_window_exists_is_a_no_op() { + let browse = Browse::new(); + browse.emit(); + } + + #[test] + fn the_error_page_escapes_what_the_server_said() { + let html = error_html(""); + assert!(!html.contains("