Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"crates/mcp-proxy",
"crates/wt-migrate",
"crates/agency-tools",
"crates/browse-core",
]

[workspace.package]
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions apps/gui/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
AgentModels,
AgentStatus,
AvailableUpdate,
BrowseDebugEntry,
BrowseView,
BuildInfo,
ChatImportSource,
ClaudeUsage,
Expand Down Expand Up @@ -410,6 +412,39 @@ export interface AgencyZeroApi {
*/
resetTaskManager(): Promise<void>;

// — 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<BrowseView>;
/**
* 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<BrowseView>;
/** Open a tab, blank unless given an address. */
browseOpenTab(input?: string): Promise<BrowseView>;
/** Close a tab. Closing the last one blanks it rather than emptying the surface. */
browseCloseTab(tab: number): Promise<BrowseView>;
browseSelectTab(tab: number): Promise<BrowseView>;
browseBack(tab: number): Promise<BrowseView>;
browseForward(tab: number): Promise<BrowseView>;
browseReload(tab: number): Promise<BrowseView>;
/**
* 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<BrowseDebugEntry[]>;

// — Events ——————————————————————————————————————————————————
on<E extends keyof AppEvents>(
event: E,
Expand All @@ -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<BrowseView, "canRender">;
"project:created": Project;
"project:updated": Project;
"project:deleted": { id: string };
Expand Down
9 changes: 9 additions & 0 deletions apps/gui/frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export {
type CommandMethod = Exclude<keyof AgencyZeroApi, "on">;

const COMMAND_FOR: Record<CommandMethod, string> = {
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",
Expand Down
158 changes: 158 additions & 0 deletions apps/gui/frontend/src/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type {
AgentIoEntry,
AgentModels,
AgentStatus,
BrowseHistoryEntry,
BrowseTab,
BrowseView,
CreatedProject,
DataLocationView,
GlobalSettings,
Expand Down Expand Up @@ -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<E extends keyof AppEvents>(
event: E,
handler: (payload: AppEvents[E]) => void,
Expand Down Expand Up @@ -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,
};
}
10 changes: 10 additions & 0 deletions apps/gui/frontend/src/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
Loading
Loading