Skip to content
Merged
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
7 changes: 7 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2
- **Accessibility**: disclosure buttons expose `aria-expanded` and `aria-controls`; all icon-only actions have names and titles, and every control preserves a visible focus ring.
- **Responsive behavior**: desktop and mobile preserve the same information order and one scroll owner, with touch-sized primary rows on mobile.

### Built-in Browser Panel

- **Structure**: a native right panel with a 44px page-title row and a 48px navigation row. The single page title uses a rounded pill with a 16px globe and close control; back, forward and reload use matching 18px stroke icons beside a rounded address field.
- **Surface**: browser chrome reads the app's computed semantic tokens from `src/index.css`; its `--browser-*` variables are aliases of the active app palette, not an independent palette. Muted backgrounds define the title pill and address field, with restrained border and focus-ring tokens.
- **Behavior**: the divider supports pointer and keyboard resizing. Expand preserves the hidden conversation viewport and draft; restore returns to its previous width. Closing always restores the conversation. Titles remain plain text, and loading/error feedback stays within the chrome.
- **Accessibility**: labelled native buttons, pressed state for expand/restore, a labelled address field, visible focus rings, reduced-motion support, and the app's selected UI language.

### Desktop Update Notice

- **Placement**: a compact `bg-card`, `border-border`, `rounded-lg` card sits immediately above Settings in the fixed sidebar footer. The collapsed rail keeps an accessible update-details icon immediately above its bottom Settings control; opening details only expands the sidebar, without a modal or automatic focus change.
Expand Down
93 changes: 83 additions & 10 deletions docs/BUILTIN-BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,47 @@ disabled under the app's existing tool allowlist policy.
## Separate manual and chat-link surfaces

Settings has an explicit **Open browser** action for the app's built-in browser
window. It is independent of the selected GJC backend: the selected session
owns the window, or the selected project's `project-<projectId>` scope is used
panel. It is independent of the selected GJC backend: the selected session
owns the panel, or the selected project's `project-<projectId>` scope is used
when no session is selected. Changing the agent backend therefore does not
redirect this manual action.

Absolute HTTP(S) links in chat Markdown always go to the user's external
browser. They never open the built-in window merely because the Tauri bridge is
browser. They never open the built-in panel merely because the Tauri bridge is
present, and they do not follow the selected agent backend. In the desktop app
the server hands these links to the operating system opener; in a regular web
browser they use a new browser tab.

## Surface and ownership

One `builtin-browser` window has one owner. An attempt by another session while
it remains open fails as `builtin_browser_in_use`; closing the window releases
ownership. The trusted 56 logical-pixel toolbar (`builtin-controls`) and the
unprivileged remote page child share that one native window through Tauri 2.11.5
with the exact-pinned `unstable` feature that exposes `add_child`.
One browser panel on the right of the main app window has one owner. An
attempt by another session while it remains open fails as
`builtin_browser_in_use`; closing the panel releases ownership. The app reflows
into the space to its left. Drag the vertical divider, or focus it and use the
arrow keys, to adjust the width. Closing the panel restores the full app area;
it never closes the main window. At narrower sizes the app uses its existing
responsive navigation. The expand button temporarily gives the browser the full
window, preserving the hidden app viewport; restore returns to the same split
width and draft. Closing an expanded browser also restores the app.

The 92-point chrome has a page-title row and a separate navigation/address row,
with SVG icons, a focusable rounded address field, loading feedback and inline
errors. Page titles are rendered as text. The local toolbar alone can read a
bounded snapshot of the app's computed semantic colors, font family and UI
language; it follows `src/index.css` and the selected theme instead of keeping a
second palette. The snapshot refreshes while the panel is visible. Expansion
and appearance remain presentation data on the toolbar channel, outside the
agent browser state/command protocol.

The trusted toolbar/divider (`builtin-controls`) and the unprivileged remote
page (`builtin-page`) are native sibling WebViews in `main`, using Tauri 2.11.5
with its exact-pinned `unstable` feature. AppKit content-layout coordinates keep
the panel below the titlebar through live resize. Capabilities target individual
WebView labels, never the containing `main` window, so the remote page cannot
inherit app privileges. The original main window/view handle is retained before
adding children: Tauri's single-WebView lookup intentionally stops matching a
window with siblings, while deep links, recovery, Dock reopen and updater
messages still need the original app view.

The supported actions are open/close, navigate, back, forward, reload, observe,
extract, selector-based click and fill. Arbitrary page `run`, screenshots,
Expand All @@ -63,8 +86,9 @@ The worker reaches `AutomationService` through the existing bridge. The server
then verifies the native endpoint's HMAC proof; native separately checks the
server's kernel `LOCAL_PEERPID`. Desktop bootstrap uses one private stdin
`GJC_DESKTOP_INIT` v2 envelope, with an optional updater binding. A retained open
window blocks restart preparation. Owner and pending work are retired only
after the window is confirmed destroyed.
panel blocks restart preparation. Owner and pending work are retired only
after both child WebViews have closed and native detachment is confirmed on the
main thread. Failed teardown retains ownership and the restart blocker.
Fences, cancellation and physical pending work remain owned until confirmed
settled.

Expand All @@ -75,6 +99,55 @@ Root Puppeteer dependencies are removed, but the pinned SDK still brings
runtime manifest checks and payload security graph until that dependency graph
changes; this contract does not claim that packaged Puppeteer bytes are gone.

## Browser chrome refinement — 2026-09-14

The provided Codex browser reference informed the title pill, separate rounded
address row, restrained borders and matching line icons. The supported controls
now include native expand/restore in addition to the original browser actions.

- `npm run verify` passed. The final native suite passed 381 tests (6 existing
opt-in cases ignored), plus 11 build-binding tests. Ten toolbar DOM tests cover
title-as-text handling, retained SVGs, loading state, app appearance, address
editing, expand/restore and divider input.
- The ad-hoc macOS app built successfully using the existing verified server
payload. Seventeen authenticated REST/native checks passed, including denial
of remote access to the new appearance reader and the unchanged agent state
shape.
- GUI QA confirmed matching light/dark chrome, expansion and restoration with
the original split width and draft, closing while expanded, and the final
copied app opening the redesigned panel outside the checkout.

![Updated browser chrome in dark mode](images/builtin-browser-docked.png)
![Updated browser chrome in light mode](images/builtin-browser-docked-light.png)

## Docked panel validation — 2026-09-14

![Built-in browser beside the conversation](images/builtin-browser-docked.png)

Checked on macOS 26.6.2 / Apple Silicon with an isolated `--qa-profile`:

- `npm run verify` passed. The final native suite passed 380 tests (6 existing
opt-in cases ignored), plus 11 build-binding tests. Geometry coverage checks
both AppKit coordinate systems, minimum widths and transient small sizes;
toolbar DOM tests cover keyboard resizing, drag coalescing and cancellation.
- The macOS payload and its out-of-tree smoke passed. An ad-hoc app with
`GJC_UPDATE_MODE=disabled` built successfully. The final app was copied outside
the repository and launched by its canonical path, retaining Tauri's macOS
symlink rejection. Updated Settings text and native panel opening passed in
this copied app.
- 15 authenticated REST → native checks passed: close/reopen, profile mode,
owner collision, denial of page attempts to call both app and toolbar IPC,
observation, fill, click, extraction, navigation, stale binding rejection,
back/forward, reload invalidation and confirmed native close.
- GUI checks passed for Settings launch, a single main window with conversation
and browser side by side, mouse/keyboard divider resizing, main-window resize,
address navigation, direct page input/click, and panel close restoring the full
conversation width without losing its unsent draft. Quit with an open panel
settled, and the draft survived the copied-app restart.

This is local source and ad-hoc acceptance, not a signed production release or
an installation over the user's existing app.

## Validation — 2026-09-12

The integrated change was checked on **macOS 26.6.2, arm64**, using an isolated
Expand Down
Binary file added docs/images/builtin-browser-docked-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/builtin-browser-docked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ fn main() {
"retry_desktop_server",
"ack_updater_screen",
"builtin_browser_control",
"builtin_browser_appearance",
]),
))
.expect("failed to run Gajae Code App desktop build script");
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/capabilities/builtin-browser.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"allow-builtin-browser-control"
"allow-builtin-browser-control",
"allow-builtin-browser-appearance"
]
}
2 changes: 1 addition & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Base capability for the Rust-supervised desktop shell.",
"windows": [
"webviews": [
"main"
],
"permissions": [
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/recovery/builtin-browser-appearance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
(() => {
const root = document.documentElement;
const style = getComputedStyle(root);
const names = ['background', 'foreground', 'card', 'muted', 'muted-foreground', 'border', 'input', 'accent', 'ring', 'destructive'];
let language = root.lang || navigator.language;
try { language = localStorage.getItem('i18nextLng') || language; } catch { /* Recovery pages may not have app storage. */ }
return JSON.stringify({
colors: Object.fromEntries(names.map((name) => [name, style.getPropertyValue(`--${name}`).trim()])),
dark: root.classList.contains('dark'),
language,
fontFamily: getComputedStyle(document.body).fontFamily,
});
})()
65 changes: 59 additions & 6 deletions src-tauri/recovery/builtin-browser.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,61 @@
:root { color-scheme: light dark; }
:root {
color-scheme: light dark;
/* Native reads the app's computed semantic tokens from src/index.css.
System colors cover the brief interval before that snapshot arrives. */
--browser-background: Canvas;
--browser-foreground: CanvasText;
--browser-card: Canvas;
--browser-muted: ButtonFace;
--browser-muted-foreground: GrayText;
--browser-border: ButtonBorder;
--browser-input: ButtonBorder;
--browser-accent: ButtonFace;
--browser-ring: Highlight;
--browser-destructive: CanvasText;
--browser-font: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; font: 13px -apple-system, BlinkMacSystemFont, sans-serif; background: Canvas; color: CanvasText; }
.toolbar { height: 56px; display: grid; grid-template-columns: auto auto auto minmax(120px,1fr) auto auto; grid-template-rows: 32px 12px; gap: 2px 6px; align-items: center; padding: 4px 8px; border-bottom: 1px solid ButtonBorder; }
button, input { min-height: 32px; border: 1px solid ButtonBorder; border-radius: 6px; background: Field; color: FieldText; font: inherit; }
button { padding: 0 10px; } button:disabled { opacity: .5; } input { width: 100%; padding: 0 9px; }
#profile, #message { font-size: 11px; color: GrayText; white-space: nowrap; } #message { grid-column: 1 / -1; margin: 0; line-height: 12px; overflow: hidden; text-overflow: ellipsis; }
html, body { margin: 0; height: 100%; overflow: hidden; }
body { font: 13px var(--browser-font); color: var(--browser-foreground); background: var(--browser-background); -webkit-font-smoothing: antialiased; }
button, input { font: inherit; appearance: none; -webkit-appearance: none; }
button { cursor: pointer; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; pointer-events: none; }
.browser-chrome { position: relative; height: 92px; margin-left: 6px; border-bottom: 1px solid var(--browser-border); }
.panel-header { display: flex; align-items: center; gap: 12px; height: 44px; padding: 6px 10px 2px; }
.page-tab { display: flex; align-items: center; gap: 9px; min-width: 0; max-width: 280px; height: 34px; padding: 0 5px 0 10px; border-radius: 11px; background: var(--browser-muted); }
.page-symbol { width: 16px; height: 16px; color: var(--browser-muted-foreground); }
#page-title { min-width: 0; max-width: 210px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 500; line-height: 20px; }
.icon-button { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px; color: var(--browser-muted-foreground); background: transparent; transition: background-color 120ms, color 120ms; }
.icon-button:hover:not(:disabled) { background: var(--browser-accent); color: var(--browser-foreground); }
.icon-button:active:not(:disabled) { background: var(--browser-input); }
.icon-button:disabled { opacity: .32; cursor: default; }
.close-button { width: 24px; height: 24px; margin-left: 3px; border-radius: 6px; }
.close-button svg { width: 14px; height: 14px; }
.expand-button { margin-left: auto; }
.restore-icon { display: none; }
[data-expanded="true"] .expand-icon { display: none; }
[data-expanded="true"] .restore-icon { display: block; }
.navigation { display: flex; align-items: center; gap: 3px; height: 48px; padding: 5px 10px 9px; }
.address-bar { display: flex; align-items: center; flex: 1; min-width: 0; height: 34px; margin-left: 5px; padding: 0 4px 0 11px; border: 1px solid var(--browser-border); border-radius: 11px; background: var(--browser-muted); transition: border-color 120ms, background-color 120ms; }
.address-bar:focus-within { border-color: var(--browser-ring); background: var(--browser-card); }
#address { width: 100%; min-width: 0; padding: 0; border: 0; outline: none; border-radius: 0; background: transparent; color: var(--browser-foreground); font-size: 12px; line-height: 30px; text-overflow: ellipsis; }
#address::selection { background: var(--browser-ring); color: var(--browser-background); }
.go-button { width: 26px; height: 26px; border-radius: 7px; }
.go-button svg { width: 16px; height: 16px; }
#message { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; color: var(--browser-destructive); }
.page-tab:has(#message:not([hidden])) { flex: 1; max-width: none; }
.loading-track { display: none; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; overflow: hidden; }
[data-loading="true"] .loading-track { display: block; }
.loading-track::after { content: ""; display: block; width: 35%; height: 100%; background: var(--browser-ring); animation: page-loading 1.3s ease-in-out infinite; }
[data-loading="true"] [data-action="reload"] svg { animation: page-spinner 1.3s linear infinite; }
#divider { position: fixed; inset: 0 auto 0 0; width: 6px; cursor: col-resize; touch-action: none; }
#divider::after { content: ""; position: absolute; inset: 0 auto 0 0; width: 1px; background: var(--browser-border); transition: width 120ms, background-color 120ms; }
#divider:hover::after, #divider:focus-visible::after, #divider[data-dragging]::after { width: 2px; background: var(--browser-ring); }
[data-expanded="true"] #divider { display: none; }
[data-expanded="true"] .browser-chrome { margin-left: 0; }
:focus-visible { outline: 2px solid var(--browser-ring); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; clip: rect(0 0 0 0); overflow: hidden; }
@keyframes page-loading { from { transform: translateX(-100%); } to { transform: translateX(390%); } }
@keyframes page-spinner { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { *, *::after { animation: none !important; transition: none !important; } .loading-track::after { width: 100%; opacity: .5; } }
@media (max-width: 360px) { .panel-header { gap: 6px; padding-inline: 8px; } .navigation { gap: 1px; padding-inline: 6px; } .address-bar { margin-left: 3px; padding-left: 8px; } .navigation > .icon-button { width: 27px; } }
Loading