diff --git a/docs/features/agent-browser-pip/plan.md b/docs/features/agent-browser-pip/plan.md new file mode 100644 index 0000000000..571039204d --- /dev/null +++ b/docs/features/agent-browser-pip/plan.md @@ -0,0 +1,421 @@ +# Agent Browser Surfaces and Picture-in-Picture Implementation Plan + +## Status + +The read-only mirror revision is implemented. The workspace/tab and Fit-desktop sections below +remain future architecture work if visible multi-tab automation becomes necessary. Packaged +Windows and Linux validation remains open. + +## Delivery Strategy + +Implement lifecycle identity and the single-parent placement controller before rendering PiP or +adaptive page modes. A floating UI built on the current one-page/session model would encode the +wrong ownership and would race the existing panel attach/detach logic. + +```text +LoopRun.runId + | + v +Tool execution context -> YoBrowserToolHandler + | | + | begin/end run | touches Agent tab + v v +Browser workspace ------> placement controller + | | | + v v v + panel PiP detached +``` + +## Architecture Changes + +### 1. Propagate the Agent run identity + +Extend the internal `ToolCallOptions`/execution context with `runId` and pass it from each +`LoopRun` through the tool batch/deferred tool paths, `ToolService`, `AgentToolManager`, and +`YoBrowserToolHandler`. + +Add an explicit browser lifecycle port with idempotent methods equivalent to: + +```ts +beginAgentBrowserActivity(sessionId, runId, toolCallId) +finishAgentRun(sessionId, runId, outcome) +``` + +The first method marks/touches the Agent tab immediately before a browser command. The second is +called from the central terminal path for completed, failed, aborted, and superseded runs. It must +also run during session teardown. + +Do not subscribe to coarse renderer session status as a substitute. The runtime already owns the +exact identity and terminal boundary. + +### 2. Migrate one-page state to a browser workspace + +Refactor `YoBrowserPresenter` from `Map` to a workspace that owns: + +- a tab map and order; +- selected panel tab ID; +- primary Agent tab ID; +- active Agent run ID and last touched Agent tab ID; +- current host window/session activation state; +- panel and conversation bounds; +- Browser-surface visibility; +- PiP container/chrome state and current position; +- dismissed PiP run ID; +- a serialized placement operation queue. + +Keep `BrowserTab` as the page/CDP abstraction. Add owner/run/placement metadata around it rather +than teaching it UI policy. + +Preserve existing callers with an internal compatibility step: session-level navigation methods +resolve the relevant user or Agent active tab, while new typed tab methods are added for panel UI. +Remove the compatibility layer after all call sites and tests migrate; do not maintain duplicate +long-term APIs. + +### 3. Create the single placement controller + +Add one main-process function that computes desired placement from workspace state and performs all +native parent changes. No renderer component may call low-level attach/detach independently after +migration. + +Responsibilities: + +1. Reject stale layout/activation versions. +2. Evaluate the eligibility predicate. +3. Select the latest current-run Agent tab for PiP. +4. Detach its page from the old parent. +5. Attach it to the panel root or PiP container, or leave it detached. +6. Set the page and chrome bounds. +7. Move/rebound the activity overlay. +8. Publish final placement/status. + +Use a per-workspace promise queue or equivalent narrow serialization. Avoid a general state-machine +library. + +### 4. Build the read-only mirror pipeline + +Create one focusless render-host `BaseWindow` lazily for each active Agent page that needs +background rendering. Reparent the existing page `WebContentsView` into it at 1280 x 800. The host +must be transparent, offscreen, non-focusable, excluded from taskbar/Mission Control, and destroyed +after the page returns to panel or no longer needs background rendering. + +Use `webContents.capturePage` because the Electron 40.10.5 macOS feasibility spike showed that +`beginFrameSubscription` produced no frames for this WebContentsView while capture succeeded from a +technically visible transparent host. Limit capture to one in-flight request, resize in main to the +PiP output size, encode in memory, and publish a typed binary frame event. Never write frames to +disk. + +The existing Vue PiP component owns a Canvas, read-only activity halo, title, and controls. Decode a +new frame completely before drawing it so the previous Canvas pixels remain visible during capture, +handoff, and loading. The remote page never receives PiP input. + +### 5. Centralize renderer layout reporting + +Move browser placement facts out of the current panel-only attach/detach watcher. A renderer +controller near `ChatTabView.vue` reports: + +- active session ID and sender webContents identity; +- current conversation content rectangle; +- panel rectangle; +- side-panel open state and active surface; +- monotonically increasing layout version. + +Use VueUse `useResizeObserver` and `useEventListener` for mechanical observation. Keep the existing +stable-rectangle wait for panel transitions, but make it a layout readiness signal rather than +direct native attachment ownership. + +Validate every report in main against the sender's BrowserWindow and current activated session. + +### 6. Add panel tabs + +Migrate `YoBrowserStatus` from a single `page` to `tabs`, `activeTabId`, and placement data. Add +typed operations to create/select/close tabs and navigate the selected user tab. + +Refactor `BrowserPanel.vue` into: + +- a compact tab strip using existing shadcn-vue Button/Tooltip patterns; +- navigation controls bound to the selected tab; +- the native page placeholder/bounds container; +- no direct destroy-on-session-switch policy. + +User link navigation explicitly creates/selects a user tab and may keep opening the Browser panel. +Agent tools resolve the primary Agent tab. A popup inherits its opener's ownership. + +Retain inactive workspace pages after loop completion for inspection. Add no speculative persistent +tab database in V1; state remains process-lifetime only. If memory becomes a measured problem, add a +separate idle eviction policy after profiling. + +### 7. Make Browser chrome container-responsive + +Refactor the Browser panel's fixed toolbar into width-aware chrome owned by `BrowserPanel.vue` or a +focused child component: + +- wide: full tab strip and navigation controls; +- compact: scrollable tabs, icon controls, flexible address field, overflow menu; +- narrow: active title/host, essential navigation, and overflow commands; +- expanded: full controls with a restore-chat action. + +Prefer CSS/container queries and normal flex/grid layout. Keep one `ResizeObserver` at the surface +owner only for native bounds/reporting; do not create breakpoint watchers per tab. Reuse shadcn-vue +Button, Tooltip, DropdownMenu, and Tabs-compatible semantics where they fit. + +Generalize the existing Workspace fullscreen shell state into a narrow side-panel surface mode that +can be `normal` or `expanded` for either Workspace or Browser. Keep this local to the +`ChatSidePanel`/route shell rather than adding global persisted state. + +Fix target-session compatibility by deriving the user-agent Chromium version from the runtime and +keeping desktop semantics at every surface width. + +### 8. Add page presentation policy and feasibility proof + +Add per-tab `presentationMode: 'responsive' | 'fit-desktop'` to main-process workspace state. +Responsive mode uses real content bounds and 100% zoom. + +Before implementing Fit desktop, build a throwaway proof comparing: + +- `webContents.enableDeviceEmulation` with `viewSize` and `scale`; +- `webContents.setZoomFactor`/zoom level with its documented same-origin propagation. + +Test a fixture matrix containing responsive layouts, fixed 1024/1280 px layouts, sticky/fixed +elements, iframes, canvas, editors, forms, context menus, and long pages. Verify renderer mouse/wheel +input, CDP input, screenshot dimensions, DOM coordinates, selection, navigation, panel/PiP movement, +and reset behavior. + +Select no fit API until the proof produces one coordinate model for both the user and Agent. If +neither path is reliable, ship Responsive plus Expand and defer Fit desktop. + +### 9. Implement PiP commands + +#### Dismiss + +Validate that the command's workspace/tab/run still matches, set `dismissedPipRunId`, and recompute. +Do not navigate, close, or interrupt tools. + +#### Open in panel + +Publish a renderer intent to open/activate Browser and select the Agent tab. Keep PiP visible until +the renderer returns stable visible panel bounds, then reparent atomically. If readiness times out, +leave PiP in place. + +#### Drag + +Use pointer capture on the entire PiP surface except buttons. Treat movement beyond a small +threshold as drag; otherwise toggle the controls. Keep drag and clamping renderer-local because the +PiP is now ordinary DOM inside the already-clipped conversation region. Avoid writing settings in +V1. + +### 10. Integrate foreground/session lifecycle + +Combine existing BrowserWindow focus/show/hide/resize/close listeners with typed session activation +events. Also handle minimize/restore explicitly. + +On deactivation or loss of foreground: + +- detach PiP synchronously; +- hide activity overlay; +- retain run/tab state. + +On valid reactivation, recompute from current facts. On terminal run, clear active run state and +detach. On session destruction, destroy every page and the local chrome. + +### 11. Adapt the activity overlay + +First, make `YoBrowserOverlayWindow.updateBounds` accept the page-content bounds produced by the +placement controller for both panel and PiP. Cross-platform test z-order, pointer pass-through, +focus, movement, and rounded clipping. + +If it cannot reliably follow nested PiP, replace only the activity drawing surface with a trusted +local child view in the PiP container. Do not replace it preemptively and do not create a second +remote page. + +## Implementation Phases + +### Phase 0: interaction decisions + +- Confirm PiP close suppresses only the current run. +- Confirm one retained Agent tab per session. +- Confirm behavior while a run waits for permission/question. +- Confirm Responsive + explicit Fit desktop + Expand as the adaptation model. +- Obtain the reference screenshot or approve a visual baseline. + +Resolved requirements: + +- when any right-side panel surface is open, each new Agent browser action activates Browser and + selects the Agent tab; +- if the user returns to Workspace, do not force another switch until the next Agent browser action; +- use a compact activity strip when the conversation cannot fit a usable page card. + +Exit criterion: no unresolved product branch changes placement semantics. + +### Phase 1: lifecycle and workspace foundation + +- Thread `runId` through every immediate and deferred tool path. +- Add explicit browser run finalization. +- Introduce workspace/tab ownership and status contracts. +- Add unit tests for loop/tab ownership and terminal cleanup. + +Exit criterion: tests can distinguish user and Agent tabs and hide current-run presentation exactly +at terminal state without any PiP UI. + +### Phase 2: placement controller, panel tabs, and responsive chrome + +- Centralize layout reports and remove renderer-owned native attachment races. +- Add the single-parent placement controller. +- Add panel tab UI and migrate navigation commands. +- Add container-responsive navigation/tab chrome and Browser expanded mode. +- Replace the stale hard-coded Chromium user-agent version with runtime-derived data. +- Preserve the same page across detach/panel transitions. + +Exit criterion: multiple tabs work in the Browser panel and every view satisfies the placement +invariant under stress tests. + +### Phase 3: page-presentation proof and PiP surface + +- Prove input/screenshot coordinate fidelity for Fit desktop candidates. +- Ship Fit desktop only if one candidate passes; otherwise keep Responsive + Expand. +- Add trusted local chrome and in-window View container. +- Add eligibility, show/hide, drag, dismiss, and open-panel handoff. +- Add size/clamp behavior, accessibility, i18n, and activity indicator. +- Add the compact activity-strip fallback for undersized chat regions. +- Integrate the existing activity overlay. + +Exit criterion: the complete acceptance matrix passes in development builds on macOS, Windows, and +Linux. + +### Phase 4: resilience and packaged QA + +- Test focus, minimize, multiple windows, session switches, route changes, panel animation, window + resize, display scale, tab/page/chrome crashes, and app shutdown. +- Test simultaneous Agent tool completion and user PiP/panel commands. +- Validate packaged local chrome routing and security settings. +- Profile page retention and native view churn. + +Exit criterion: no cross-session frame leak, duplicate attachment, unexpected panel opening, or +tool interruption is observed in packaged builds. + +## Expected File Areas + +Likely implementation areas, subject to final naming: + +- `src/main/agent/deepchat/**` for `runId` propagation and terminal lifecycle calls; +- `src/main/tool/**` and `src/shared/types/tool.d.ts` for internal execution context; +- `src/main/desktop/browser/YoBrowserPresenter.ts` for workspace orchestration; +- focused new files under `src/main/desktop/browser/` for workspace placement and PiP chrome; +- `src/shared/types/browser.ts`, browser routes, and browser events for tab/layout/placement state; +- `src/preload/` for typed commands and a narrow PiP chrome bridge; +- `src/renderer/src/components/sidepanel/BrowserPanel.vue` for tab UI; +- `src/renderer/src/stores/ui/sidepanel.ts` and `ChatSidePanel.vue` for generalized expanded mode; +- a renderer controller near `ChatTabView.vue` for layout facts; +- `src/renderer/src/components/sidepanel/ChatSidePanel.vue` to remove unconditional Agent open; +- `src/renderer/src/i18n/` for visible strings; +- mirrored main and renderer tests. + +Avoid coupling this to `src/main/desktop/tab.ts`; standalone browser windows have a different +window/tab lifecycle and do not need Agent PiP policy. + +## Contract Migration + +Current session-level routes can be migrated in two steps: + +1. Expand status with tabs while preserving derived `page` fields temporarily for existing + renderer callers. +2. Move panel/client callers to explicit `tabId`, then remove the derived single-page fields and old + attach/detach routes in the same feature branch. + +The final architecture must not retain two attachment owners. Route schemas should reject stale tab +IDs and return stable `tab_not_found`, `tab_closed`, `stale_layout`, or `placement_failed` results. + +## Test Strategy + +### Pure state tests + +Use a table-driven reducer/decision function for desired placement. Cover every boolean in the +predicate and every transition in the spec, including dismiss-current-run versus next-run behavior. + +### Main-process tests + +- single parent across panel/PiP/detached transitions; +- exact page WebContents identity across moves; +- serialized races between bounds, focus, loop finish, and tab close; +- stale sender/layout rejection; +- multiple sessions and windows without cross-session display; +- popup ownership inheritance; +- Agent tab recreation after explicit close; +- chrome/page/host destruction cleanup; +- overlay bounds for both placements. + +Wrap native `View` operations behind a narrow testable adapter only where Electron objects cannot be +constructed in Vitest. Do not build a general UI framework. + +### Runtime tests + +- browser tool receives the exact `LoopRun.runId`; +- immediate and deferred tool execution use the same identity; +- completed, failed, aborted, and superseded runs all finalize once; +- a later run does not inherit dismissal or activity from the prior run; +- permission/question pauses do not appear terminal. + +### Renderer tests + +- Agent open events do not open a closed panel; +- user navigation still opens the panel intentionally; +- panel tab selection, close, Agent marker, and navigation state; +- layout versioning and stable bounds readiness; +- open-panel handoff timeout keeps PiP rather than losing the page; +- all visible controls have i18n labels and keyboard focus; +- container-width breakpoints preserve the address bar and all commands at minimum panel width; +- expanded/restore focus behavior and page identity; + +### Packaged cross-platform tests + +- click/focus/input routing between chat, PiP chrome, and remote page; +- responsive, fit-desktop, and expanded rendering across representative site fixtures; +- CDP input and screenshot coordinates at every approved presentation scale; +- drag and clamping at multiple DPI/display scales; +- z-order during panel animation and other overlays; +- focus/blur, minimize/restore, hide/show, and full-screen behavior; +- multiple app windows with different active sessions; +- no PiP outside chat bounds and no flash from an inactive session. + +## Performance and Retention + +- One retained Agent page per active session is the initial memory budget. +- PiP chrome is lazy and destroyed with its host/workspace. +- Reparent and bound updates do not recreate CDP sessions or reload pages. +- Drag updates are coalesced to one per animation frame. +- Layout reports use equality checks and versions to avoid redundant native calls. +- Browser chrome uses CSS/container layout rather than reactive resize fan-out. +- Presentation scaling is applied only after stable bounds, never on every resize sample. +- Measure retained page memory before adding an eviction abstraction. If needed, prefer a simple + idle timeout tied to session state over a general cache. + +## Rejected Approaches + +- **Transparent child BrowserWindow as the user-facing PiP**: platform-dependent movement/focus and + poor in-chat clipping. +- **Live native WebContentsView inside PiP**: cannot be made reliably read-only and creates focus, + z-order, and renderer blur/attach feedback loops. +- **Offscreen shared texture**: requires a native graphics module and complicates moving the same + WebContents back into the visible panel; bounded capture is sufficient for a low-frame-rate + preview. +- **Second WebContentsView pointing at the same URL**: duplicates navigation, page state, CDP, and + potentially side effects. +- **Inject controls into the remote page**: unsafe, spoofable, and breaks arbitrary sites. +- **Renderer CSS iframe/webview**: changes the security model and cannot adopt the existing page + WebContents. +- **Use session `working` status as loop identity**: cannot distinguish runs or terminal races. +- **Always scale narrow pages automatically**: horizontal overflow is not a reliable signal of site + intent and silent scaling can break readability and Agent coordinates. +- **Use only `setZoomFactor` for per-tab fit**: Chromium zoom is same-origin and may propagate beyond + the intended tab. +- **Switch to a mobile user-agent in narrow surfaces**: changes site behavior and session/device + assumptions instead of solving layout. +- **Create one tab per Agent URL load**: unbounded tab growth without a user need. +- **Persist full browser workspace/PiP geometry in V1**: adds restore/migration work before the + lifecycle is proven. + +## Complexity Budget + +No new runtime dependency is expected. Use Electron View/WebContentsView/webContents APIs, existing +VueUse helpers, shadcn-vue controls, typed routes/events, and a small placement decision function. +The feature needs one workspace state owner, one surface layout owner, and one placement owner; +additional window managers or state-machine libraries would make correctness harder, not easier. diff --git a/docs/features/agent-browser-pip/spec.md b/docs/features/agent-browser-pip/spec.md new file mode 100644 index 0000000000..601e6494e3 --- /dev/null +++ b/docs/features/agent-browser-pip/spec.md @@ -0,0 +1,641 @@ +# Agent Browser Surfaces and Picture-in-Picture Spec + +## Status + +V1 mirror revision implemented on 2026-07-17. The first implementation placed the live +`WebContentsView` inside the PiP, which made the preview interactive and introduced a renderer +focus/blur feedback loop. The revised contract keeps the Agent page at a fixed 1280 x 800 render +size and shows a low-frame-rate, read-only Canvas mirror in PiP. A visible multi-tab strip and +Fit-desktop emulation remain deferred. Packaged Windows and Linux validation remains open. + +The referenced screenshot was not available in the current task context, so the interaction and +layout are specified here while exact visual styling remains provisional. + +## Problem + +Today, any YoBrowser `loadUrl` call publishes `browser.open.requested`, and +`ChatSidePanel.vue` responds by opening and activating the right-side Browser panel. This lets a +background Agent tool unexpectedly take over the user's layout. + +YoBrowser also has only one page per chat session. It cannot distinguish a user-opened page from an +Agent automation page or expose multiple browser tabs in the panel. + +The current Browser panel is physically constrained to a default width of 520 px and a minimum of +420 px. `BrowserPanel.vue` keeps all navigation controls in one fixed row, while the remote page +receives the exact narrow native view bounds with no presentation-mode choice. Responsive sites may +adapt, but desktop-oriented sites can become horizontally cramped or unusable. + +The target session also hard-codes a Chrome/142 user-agent string while the pinned Electron 40.10.5 +runtime embeds Chromium 144.0.7559.236. That mismatch is not the main cause of narrow layout, but it +is a compatibility defect and must not become part of a new adaptive-surface design. + +The requested behavior is: + +- never open a closed right-side panel merely because an Agent uses YoBrowser; +- show the Agent-operated page in the existing Browser panel when that surface is already visible; +- otherwise show it as a draggable in-chat floating preview; +- make the floating preview read-only while the Agent operates the same live page in a normal-size + background render host; +- move the same live page between the background render host and panel without reload or state + loss; +- never float user-opened browser pages; +- hide the floating preview when its Agent loop ends, the chat is no longer active/foreground, the + page is closed, or the Browser panel becomes visible. +- make the Browser panel chrome responsive at its current supported widths; +- give desktop-oriented pages a deliberate wide/fit escape hatch instead of leaving them trapped in + an unusable narrow viewport. + +## Critical Architecture Correction + +The user-facing PiP is not another operating-system window and never contains the remote page's +native View. Electron `View` does not expose a reliable view-level ignore-mouse-input contract, and +native child views sit outside normal Vue DOM hit testing. The trusted chat renderer therefore owns +the complete PiP surface and draws captured frames into a Canvas. + +The live Agent `WebContentsView` is temporarily reparented into a focusless render-host +`BaseWindow`. The render host is technically visible so Chromium owns a valid display surface, but +it is transparent, moved offscreen, excluded from taskbar/Mission Control, and never accepts input. +Its page bounds remain 1280 x 800. Opening Browser reparents that exact View into the visible panel. + +```text +focusless render-host BaseWindow ++-- Agent page WebContentsView at 1280 x 800 + +-- capturePage -> resize/encode -> typed frame event + +host BrowserWindow.contentView ++-- chat renderer WebContentsView + +-- trusted PiP Canvas mirror and controls ++-- panel placement: the same Agent page WebContentsView when Browser is visible +``` + +There is still one page `WebContents`, one page `WebContentsView`, and one session. The mirror is +only a transient compressed frame, never a second browser. Moving the page changes only its native +parent and bounds. It does not navigate, clone cookies, recreate CDP, or copy page state. + +## Goals + +- Preserve the user's panel open/closed choice when an Agent opens a URL. +- Add browser tabs so user and Agent pages have explicit identity and lifecycle. +- Reuse one Agent automation tab per chat session in the first release. +- Track which Agent loop owns the current automation activity by `runId`. +- Put each page view in exactly one native placement: Browser panel, render host, or detached. +- Show at most one PiP for the foreground chat window. +- Keep PiP fully inside the active conversation region and let the user drag it. +- Keep the background Agent viewport at 1280 x 800 while PiP scales only its visual mirror. +- Keep responsive page rendering as the safe default and offer explicit desktop-fit and expanded + modes for pages that do not work well at narrow widths. +- Let the user dismiss PiP for the current loop without closing or disrupting the automation tab. +- Let the user open the Browser panel and transfer the same page into it. +- Hide PiP deterministically on every loop/session/window/tab lifecycle edge. +- Preserve the existing Agent activity visualization over whichever placement contains the page. + +## Non-Goals + +- Native video picture-in-picture or `documentPictureInPicture`. +- A free-floating OS window outside the DeepChat window. +- Multiple simultaneous PiP cards. +- Floating pages created and navigated only by the user. +- Persisting PiP position or size across application restarts in V1. +- Guessing that every horizontally overflowing page should be silently scaled down. +- Pretending to be a mobile browser or changing touch/user-agent semantics merely because the + surface is narrow. +- Letting the renderer own or directly manipulate remote-page `webContents`. +- Creating a new Agent tab for every `load_url` call. +- A general browser-window/tab architecture shared with standalone DeepChat browser windows. +- Closing the underlying Agent tab when the PiP close button is pressed. +- Forwarding PiP pointer, wheel, keyboard, selection, or focus events to the remote page. +- Full-frame-rate video streaming or a GPU shared-texture/native-module pipeline. + +## Current Repository Contract + +- `YoBrowserPresenter` owns `Map` with exactly one + `WebContentsView`, one `BrowserTab`, and one `YoBrowserOverlayWindow` per chat session. +- `YoBrowserToolHandler` can identify the chat session but does not receive the active Agent + `runId`. +- `BrowserPanel.vue` attaches the single view to bounds reported by the Browser panel and destroys + inactive-session pages once work stops. +- `sidepanel.ts` clamps the panel to 420-960 px, defaults to 520 px, and caps it at 62% of the + renderer width. +- `BrowserPanel.vue` uses one fixed navigation row and has no compact toolbar, expanded-browser + mode, or page-presentation mode. +- `yoBrowserSession.ts` hard-codes Chrome/142 rather than deriving the user-agent version from the + bundled Chromium runtime. +- `ChatSidePanel.vue` automatically calls `sidepanelStore.openBrowser()` for every + `browser.open.requested` event. +- `useMarkdownLinkNavigation.ts` intentionally opens the Browser panel for user navigation. +- `LoopRun` and the DeepChat runtime already own a stable `runId`; it is not currently threaded + through `ToolCallOptions` to YoBrowser. +- `YoBrowserOverlayWindow` is a transparent transient activity effect, not a PiP surface. + +## Terminology + +- **Browser workspace**: all YoBrowser tabs for one chat session. +- **User tab**: a page explicitly created from user UI. It is never PiP-eligible. +- **Agent tab**: the session's automation page, created or reused by an Agent browser tool. +- **Touched run**: the Agent `runId` that most recently operated an Agent tab. +- **Browser surface visible**: the right-side panel is open and its Browser surface is the active + panel content. +- **Native placement**: `panel`, `render-host`, or `detached`. +- **Mirror state**: `capturing`, `rendering`, or `stopped`; only `capturing` publishes PiP frames. +- **Presentation mode**: `responsive` or `fit-desktop` for how a page uses the available content + rectangle. +- **Active session**: the chat route currently activated in the host renderer/webContents. +- **Foreground host**: the containing DeepChat window is shown, not minimized, and focused. + +## Browser Tab Model + +The current `SessionBrowserState` becomes a per-session workspace: + +```ts +type BrowserTabOwner = 'user' | 'agent' +type BrowserPlacement = 'panel' | 'pip' | 'detached' + +type YoBrowserTabState = { + id: string + owner: BrowserTabOwner + page: BrowserPage + view: WebContentsView + placement: BrowserPlacement + lastAgentRunId: string | null + closed: boolean + createdAt: number + updatedAt: number +} +``` + +The public status becomes a tab collection plus `activeTabId`. User commands act on the selected +user-facing tab. Agent tools act on the dedicated Agent tab unless a future tool explicitly accepts +a tab ID. + +V1 rules: + +- create at most one primary Agent tab per session; +- reuse it across URL loads and across loops until the user explicitly closes it or the workspace + is destroyed; +- set `lastAgentRunId` before executing every Agent browser tool, not only `load_url`; +- pages opened by an Agent-owned popup inherit Agent ownership and the same touched run, but the + panel may list them as additional tabs; +- never change a user tab to Agent ownership implicitly; +- if a closed Agent tab is needed by a later browser tool, create a new Agent tab; +- choose only one current Agent tab for PiP, preferring the page touched by the latest tool. + +## Responsive Surface Design + +There are two different adaptation problems and they must not be conflated: + +1. **Browser chrome adaptation** controls DeepChat-owned tabs, buttons, address bar, and PiP header. +2. **Page presentation** controls how the untrusted website interprets and fills its native content + rectangle. + +### Browser chrome breakpoints + +Use container width, not the application viewport, because the Browser panel and PiP can have +different widths in the same window. + +| Content width | DeepChat chrome behavior | +| --- | --- | +| `>= 640 px` | Full tab strip, back, forward, reload, address bar, presentation, and expand controls | +| `480-639 px` | Scrollable compact tabs, icon navigation, flexible address bar, presentation/expand in overflow | +| `< 480 px` | Active-tab title/host, back, reload, address bar, and one overflow menu; hide nonessential labels | + +The address bar remains the flexible element with `min-width: 0`. Tab titles truncate, the tab strip +scrolls horizontally instead of squeezing every tab, and all hidden commands remain keyboard +reachable through the overflow menu. + +```text +Wide panel ++----------------------------------------------------------------+ +| [User tab] [Agent tab *] [+] | +| [<-] [->] [reload] [ URL ] [Fit] [Expand]| ++----------------------------------------------------------------+ + +Compact panel ++----------------------------------------------+ +| [User] [Agent *] ... | +| [<-] [reload] [ URL ] [...]| ++----------------------------------------------+ + +Narrow PiP ++--------------------------------------+ +| agent · site.example [Panel] [...] [x]| ++--------------------------------------+ +``` + +Use CSS/container-query layout for renderer-owned chrome where possible. Native page bounds still +come from the measured content rectangle. + +### Page presentation modes + +#### Responsive, default + +- The website viewport equals the actual native content bounds. +- Page zoom is 100%. +- CSS media queries, viewport units, and normal responsive behavior work as designed by the site. +- PiP/panel movement changes only bounds after the destination layout is stable. + +This is the only mode that can be selected automatically without guessing site intent. + +#### Fit desktop, explicit per tab + +For desktop-oriented sites, the user can choose **Fit desktop**. YoBrowser presents a wider virtual +desktop viewport and scales it into the available surface. A target virtual width near 1024 CSS px +is a starting hypothesis, not a committed constant. + +Implementation must choose between Electron `webContents.enableDeviceEmulation` and a controlled +zoom strategy only after a feasibility proof verifies: + +- layout/media-query behavior in panel and PiP; +- text readability and minimum scale; +- mouse, wheel, keyboard, focus, selection, and context-menu coordinates; +- CDP `Input.dispatchMouseEvent` coordinates; +- visible and full-page screenshot dimensions used by Agent tools; +- no per-origin zoom leakage into another tab; +- deterministic reset to Responsive mode after navigation, tab reuse, and view reparenting. + +Electron's Chromium zoom policy is same-origin, so `setZoomFactor` must not be assumed to provide +isolated per-tab fit behavior. CDP/device emulation changes CSS viewport coordinates and may affect +Agent screenshots/input. Until the proof passes, the product contract is Responsive plus **Expand**, +not unverified automatic scaling. + +Presentation mode belongs to the tab. It survives panel/PiP movement and the current process +lifetime, but is not persisted across app restarts in V1. + +### Expanded Browser mode + +Add an explicit **Expand** action that temporarily lets the Browser surface occupy the full +`ChatTabView` content area, reusing the existing Workspace fullscreen shell behavior. This is the +reliable escape hatch for complex desktop sites, wide tables, editors, and login flows where a +scaled page would become unreadable. + +Expanded mode does not create a new window or page. It changes the side-panel shell geometry, keeps +the same selected tab/view, provides a clear **Restore chat** action, and returns focus to the +originating control. + +### Safe adaptation policy + +- DeepChat chrome adapts automatically. +- Website viewport stays Responsive automatically. +- DeepChat may detect persistent document-level horizontal overflow and offer **Fit desktop** or + **Expand**, but it does not silently switch modes based on `scrollWidth` heuristics. +- Recompute native bounds during drag/resize at animation-frame cadence, but apply presentation-mode + changes only after bounds stabilize or resizing ends. +- Derive the desktop user-agent Chromium version from the runtime; do not switch to a mobile + user-agent for narrow surfaces. + +## Visibility Predicate + +PiP is visible only when all of these are true: + +```text +tab.owner == agent +AND tab.lastAgentRunId == workspace.activeAgentRunId +AND active Agent run is not terminal +AND tab is not closed +AND right-side panel is closed +AND workspace session is the active chat in its host renderer +AND host window is foreground +AND workspace.dismissedPipRunId != workspace.activeAgentRunId +``` + +Every input is main-process-authoritative or validated against the sender's host window. The +renderer reports layout facts; it does not decide whether an arbitrary tab may float. + +### Confirmed panel-open behavior + +If the right-side panel is already open, an Agent browser action activates the Browser surface, +selects the current Agent tab, waits for stable Browser bounds, and places the page in the panel. It +does this even when Workspace was the active surface. No PiP is shown during that handoff. + +The Browser surface is not pinned. The user may switch back to Workspace afterward. That switch +detaches the Agent page without showing PiP because the panel remains open; the next Agent browser +tool action activates Browser again. This prevents a continuous tab-switch fight while still making +each new Agent operation visible. + +## Loop Lifecycle + +An Agent run becomes relevant to YoBrowser only after its first browser tool starts. There is no +empty PiP at run start. + +```text +run starts + -> no browser action: no PiP state + -> first browser tool: mark Agent tab with runId + -> right-side panel open: activate Browser, then place in panel + -> panel closed: place page in render host + -> session/host foreground and not dismissed: capture mirror into PiP + -> more browser tools: reuse tab and recompute placement + -> run completes / fails / aborts: stop capture and hide PiP immediately +``` + +A permission or question pause is not a terminal loop state. PiP remains eligible unless the user +dismisses it or another visibility condition becomes false. Terminal means completed, failed, +cancelled, superseded, or session teardown. + +Loop completion hides PiP but does not close the tab. This preserves the result for later inspection +in the Browser panel. A separate retention policy handles eventual destruction. + +## Placement Invariant and Transitions + +For each page view: + +```text +number of native parents <= 1 +placement == panel => parent is host.contentView +placement == render-host => parent is focusless renderHost.contentView at 1280 x 800 +placement == detached => no native parent +``` + +All transitions go through one main-process placement controller. It removes the old parent before +adding the new parent, applies bounds, and starts or stops frame capture. The PiP Canvas is not a +native parent and never owns the page. Concurrent renderer mode requests, panel bounds, captures, +and Agent tool events are serialized per workspace. + +| Event | From | Result | +| --- | --- | --- | +| First Agent browser action, panel open | Detached | Activate Browser, then Panel | +| First Agent browser action, panel closed | Detached | Render host; mirror if eligible | +| User clicks **Open in panel** | Render host | Open/activate Browser, then Panel | +| Browser surface becomes visible | Render host | Panel | +| User switches Browser to Workspace while panel stays open | Panel | Detached until next Agent browser action | +| User closes panel during active eligible run | Panel | Render host and mirror | +| User clicks PiP **Close** | Render host | Keep rendering; stop frames and suppress mirror for this run | +| Chat session deactivates | Render host or Panel | Stop frames; retain render host only while Agent work requires it | +| Host blurs/hides/minimizes | Render host | Stop frames; keep Agent rendering | +| Same host returns foreground with eligible run | Render host | Resume mirror capture | +| Agent run becomes terminal | Render host | Stop capture, release host, retain page detached | +| Agent tab closes | Any | Destroy and no PiP | + +The panel transition waits for a stable content rectangle before reparenting. During the short +handoff, show the existing Browser placeholder; never display the remote page twice. + +## User Experience + +### Before: Agent navigation forces the panel open + +```text ++--------------------------------------+-----------------------+ +| Active conversation | Browser | +| | [Back] [ URL ] | +| Agent is working... | | +| | agent-operated page | +| | | ++--------------------------------------+-----------------------+ +``` + +### After: closed panel stays closed and Agent page floats inside the chat + +```text ++----------------------------------------------------------------+ +| Active conversation | +| | +| Agent is working... +-------------------------------+ | +| | | | +| | read-only Agent page mirror | | +| | | | +| | [Open panel] [x] | | +| +-------------------------------+ | +| | ++----------------------------------------------------------------+ +``` + +### After: Browser panel was already visible + +```text ++--------------------------------------+-------------------------+ +| Active conversation | Browser | +| | [User tab] [Agent tab] | +| Agent is working... | | +| | same live Agent page | +| | | ++--------------------------------------+-------------------------+ +``` + +### PiP controls + +- Controls are hidden initially. A click without drag on any non-button point toggles a toolbar with + a truncated title, **Open in panel**, and **Close**. +- Every point in the PiP except buttons is a drag handle. Movement beyond the drag threshold must not + also toggle the toolbar. +- The Canvas and decorative overlays use no remote-page input forwarding. Pointer, wheel, keyboard, + selection, and focus stay in the trusted chat renderer. +- **Close** sets `dismissedPipRunId` to the current run and stops mirror capture. It does not close + the tab, stop the 1280 x 800 render host, cancel CDP, or abort the Agent loop. +- **Open in panel** opens and activates the Browser panel, selects the Agent tab, and reparents the + same page after the panel bounds stabilize. +- A keyboard user can focus both buttons. Dragging is pointer-based; keyboard movement is a later + enhancement unless accessibility review requires it for V1. + +### Geometry + +- Default to the bottom-right of the measured conversation content with a 12 px inset. +- Prefer a 400 x 250 px 16:10 mirror when the conversation has room. Keep the captured frame at + 480 x 300 so the smaller display remains legible without changing the background page's + 1280 x 800 CSS viewport. +- Reveal the top actions and a centered drag affordance on hover or keyboard focus. A non-drag tap + keeps those controls visible for pointer devices without hover. +- Clamp height to at most 58% of the conversation region and keep the full header reachable. +- On smaller regions, shrink to the available bounds. If usable page content would fall below + 360 x 240, replace the page card with a compact Agent-browser activity strip containing + **Open in panel** and **Dismiss** rather than obscuring the entire conversation with an unusable + webpage. +- Drag updates are renderer-local, throttled to animation-frame cadence, and clamped against the + measured conversation bounds. +- Remember the last position only for the current host window lifetime. Re-clamp on resize, panel + animation, route changes, and display scale changes. + +Exact radius, shadow, header height, and icon treatment should be matched to the missing reference +screenshot during implementation review. + +## Panel Tab Interaction + +The Browser panel adds a compact tab strip below its existing Workspace/Browser selector and above +navigation controls. This is browser-tab state, not a second copy of the global side-panel tabs. + +```text ++--------------------------------------------------+ +| [Workspace] [Browser] [x] | +| [User page] [Agent page *] [+] | +| [<-] [->] [reload] [ URL ] | +| | +| active page | ++--------------------------------------------------+ +``` + +- User navigation from a markdown link may keep its current explicit behavior of opening the + Browser panel and creates/selects a user tab. +- Agent navigation never selects a user tab and never creates one tab per URL. +- An Agent marker distinguishes automation tabs. +- Closing an Agent tab destroys that page and removes PiP eligibility. The Agent receives a stable, + recoverable page-closed result if a concurrent command loses the tab. +- Closing the panel detaches user pages. During an eligible Agent run, its active Agent page moves + to PiP; otherwise it also detaches. +- **Expand** is available for user and Agent tabs. Presentation mode is stored per tab and follows + the same live page into panel, PiP, and expanded placement. + +## Active Session and Foreground Semantics + +- Bind workspace visibility to the renderer `webContentsId` that reports the session as activated. +- Reject layout reports whose sender does not own the target host window/session route. +- A session visible in another window wins only when it is the currently activated copy; do not + mirror one page view into two windows. +- Window blur, hide, minimize, close, route replacement, or session deactivation hides the Canvas + mirror and stops frame delivery. These events are derived from BrowserWindow state, not + `document.hasFocus()`, because focus can legitimately move between WebContents in one window. +- Window refocus may restore PiP only if the same non-terminal Agent run remains active and was not + dismissed. +- Switching to another chat cannot expose the previous chat's page even for one frame. + +## Activity Overlay + +The existing `YoBrowserOverlayWindow` visualizes pointer, keyboard, navigation, and vision activity. +It should remain an activity layer, not become the PiP implementation. + +Its native bounds follow the page only in the visible Browser panel. PiP reuses the typed activity +event and draws a trusted renderer halo over the Canvas, avoiding another native overlay window, +z-order interaction, or focus change. + +## Events and Public State + +Replace the ambiguous open request with intent-rich state. Proposed public concepts: + +- workspace/tabs status: IDs, owner, URL/title/favicon, page status, active tab, placement; +- per-tab presentation mode and current effective page viewport/scale metadata; +- Agent browser activity: `sessionId`, `runId`, `tabId`, tool activity phase; +- layout report: host window, active session, conversation bounds, panel bounds, Browser surface + visibility; +- placement status: `panel`, `pip`, `detached`, plus a redacted reason; +- preview-mode command: `capturing`, `rendering`, or `stopped`; +- typed preview-frame event: session/run/sequence, dimensions, MIME type, and bounded binary data; +- PiP commands: dismiss current run and open the current page in panel. Drag position remains local. + +`runId` must be added to the internal tool execution context and passed through the Agent tool +manager into `YoBrowserToolHandler`. Do not derive loop ownership from session `working` status or +from a timeout: a session can wait for permission, queue another turn, or be superseded. + +## Security and Isolation + +- Remote page execution remains in its existing sandbox/session. PiP receives only an inert, + downscaled image frame and never remote DOM or script. +- PiP chrome loads only a packaged local route with no remote navigation, no Node integration, and a + narrow validated IPC surface. +- Remote pages cannot receive PiP input, drag the PiP, close it, activate the panel, spoof Agent + ownership, or send PiP commands. +- Preview frames remain memory-only, are bounded in dimensions and rate, and are never logged, + cached, or written to disk. +- Device-emulation or zoom state is applied only by main-process tab policy; remote pages cannot + change the stored presentation mode. +- Bounds and IDs received from renderers are validated and clamped in main. +- The page's DevTools/CDP attachment remains unchanged during reparenting. +- No page URL, title, or favicon from an inactive session is shown after a session switch. +- The trusted header truncates untrusted title/host text and never interprets it as HTML. + +## Failure Semantics + +- A failed render-host creation or reparent falls back to `detached`, publishes `placement_failed`, + and never leaves the page registered under two parents. +- A capture or decode failure retains the last Canvas frame and retries on the next bounded tick; + it never clears the PiP to a blank frame. +- At most one capture is in flight per page. New ticks are dropped rather than queued. +- A destroyed host window clears placement synchronously and destroys its PiP chrome. +- A stale bounds or session-activation update is ignored by monotonically increasing layout + versions. +- If panel bounds never stabilize, keep the PiP visible and report the handoff failure; do not reload + the page. +- If PiP chrome crashes, detach PiP and keep the Agent page alive for tools. +- If the remote page crashes, show the existing browser error state in the panel/placeholder and let + tool calls return the current page error. +- Loop-finalization cleanup is idempotent and safe when the page or window has already closed. + +## Feasibility Assessment + +| Requirement | Feasibility | Evidence / risk | +| --- | --- | --- | +| Same page in render host and panel by movement | High | Electron View supports child-view reparenting; serialize parent changes | +| Read-only in-chat mirror and dragging | High | Canvas stays in the trusted renderer and cannot target remote content | +| Preserve page/CDP/session state | High | Reuse the same `WebContentsView`; no navigation or recreation | +| macOS transparent render host capture | High | Proven locally against Electron 40.10.5 with 1280 x 800 animated content | +| Windows/Linux transparent render host capture | Medium | Requires packaged compositor/window-manager validation | +| Bounded frame cost | Medium-high | 480 x 300 output, adaptive 1-4 FPS, one in-flight capture, stop when ineligible | +| Agent-only eligibility | High | Requires `runId` propagation and explicit tab ownership | +| Hide exactly at loop terminal state | High | Runtime owns `LoopRun.runId`; add an explicit finalization port | +| Multiple browser tabs | Medium-high | Current single-page status/contracts and panel must be migrated | +| Responsive DeepChat chrome | High | Container-width layout can reuse current panel ownership and existing UI primitives | +| Responsive website rendering | High for native Responsive mode | Current native bounds already drive the page viewport | +| Fit desktop mode | Medium | Must prove emulation/zoom input and Agent screenshot coordinate fidelity | +| Expanded Browser surface | High | Reuse the existing side-panel fullscreen shell behavior | +| Existing activity overlay in nested PiP | Medium | Separate transparent window may need a local-view fallback after cross-platform QA | +| Identical cross-platform window behavior | High with in-window View | Avoids child `BrowserWindow` platform differences | + +The largest implementation risk is not rendering the card. It is lifecycle correctness across +Agent finalization, session switching, panel animation, window focus, tab closing, and concurrent +bounds updates. The placement invariant and run ID are mandatory, not optional polish. + +## Acceptance Criteria + +- An Agent browser tool never opens a closed right-side panel. +- A user-opened page never appears in PiP. +- The first browser tool in an active loop creates/reuses an Agent tab and associates the exact + `runId`. +- If the Browser surface is visible, the Agent tab is shown there with no PiP. +- If any right-side panel surface is open when an Agent browser action starts, DeepChat activates + Browser and shows the Agent tab there instead of opening PiP. +- Switching back to Workspace is respected until the next Agent browser action; DeepChat does not + continuously force Browser active. +- If all PiP predicate conditions hold, exactly one PiP appears inside the active conversation. +- PiP is read-only: clicking, dragging, scrolling, typing, or focusing it never affects the page. +- The live remote page exists in only one native placement; the Canvas contains only its last + completed image frame. +- While mirrored, page layout reports a stable 1280 x 800 CSS viewport independent of PiP size. +- Moving PiP to the panel preserves URL, DOM state, scroll, focusable page state, cookies, and CDP + target identity without reload. +- Closing PiP suppresses it for the rest of that run without closing the page or interrupting the + Agent. +- A later Agent run may show the retained Agent tab again after its first browser action. +- Completing, failing, cancelling, or superseding the loop hides PiP immediately. +- Session deactivation and real BrowserWindow blur/hide/minimize hide PiP; internal focus movement + between chat and page WebContents does not hide or flash it. +- New frames replace Canvas pixels only after decode, so capture and handoff never create a blank + intermediate frame. +- Dragging stays inside current chat bounds through resize and scale changes. +- Browser tabs and controls remain usable at 420 px panel width without clipping the address bar or + hiding commands from keyboard users. +- Responsive mode exposes the real content bounds at 100% page zoom. +- Fit desktop is never selected silently and passes user-input/CDP/screenshot coordinate tests + before release. +- Expand/restore preserves the exact tab and page state without reload. +- The YoBrowser user-agent Chromium version matches the bundled runtime rather than a stale literal. +- Panel tabs distinguish user and Agent pages and closing a tab destroys only that page. +- Tool operations remain correct while the page moves between placements. +- Cross-platform focus, input, z-order, overlay, and accessibility tests pass. +- Format, i18n, lint, typecheck, and focused tests pass after implementation. + +## Decision Gates + +1. **What does PiP close mean?** Recommendation: dismiss presentation for the current run only; it + must not close the tab or disrupt automation. +2. **Agent tab retention**: recommendation is one retained Agent tab per session, destroyed when the + session/browser workspace is explicitly destroyed or by a later memory-pressure policy. +3. **Run pause behavior**: recommendation is to keep PiP eligible while waiting for permission or a + question, because the loop has not ended; the user can dismiss it. +4. **Page adaptation**: recommendation is automatic responsive chrome plus Responsive page mode by + default, with explicit Fit desktop and Expand actions. Silent overflow-based scaling is too + unpredictable for arbitrary sites and Agent coordinates. +5. **Fit implementation**: choose device emulation versus controlled zoom only after the coordinate + fidelity proof; no API is approved by this spec yet. +6. **Exact styling**: requires the referenced screenshot or a new visual decision before UI polish. + +Confirmed product decisions: + +- an already open right-side panel automatically switches from Workspace to Browser for each new + Agent browser action; +- an undersized conversation shows a compact Agent-browser activity strip instead of forcing an + unusably small webpage. + +## Verified References + +- [Electron View API](https://www.electronjs.org/docs/latest/api/view) +- [Electron WebContentsView API](https://www.electronjs.org/docs/latest/api/web-contents-view) +- [Electron BaseWindow child-window behavior](https://www.electronjs.org/docs/latest/api/base-window) +- [Electron webContents zoom and device-emulation APIs](https://www.electronjs.org/docs/latest/api/web-contents) +- [Electron 40.10.5 runtime versions](https://releases.electronjs.org/release/v40.10.5) +- [Chrome DevTools Protocol Input coordinates](https://chromedevtools.github.io/devtools-protocol/tot/Input/) diff --git a/docs/features/agent-browser-pip/tasks.md b/docs/features/agent-browser-pip/tasks.md new file mode 100644 index 0000000000..29fa302343 --- /dev/null +++ b/docs/features/agent-browser-pip/tasks.md @@ -0,0 +1,121 @@ +# Agent Browser Surfaces and Picture-in-Picture Tasks + +## Status + +V1 read-only mirror revision implemented. Visible browser multi-tabs, Fit-desktop emulation, and +packaged cross-platform validation remain open. + +## Completed Discovery + +- [x] Trace current YoBrowser page, native view, CDP, overlay, panel, and session lifecycles. +- [x] Identify the unconditional Agent `browser.open.requested` panel-opening behavior. +- [x] Confirm the current model is one page per session with no user/Agent ownership. +- [x] Locate stable `LoopRun.runId` ownership and the missing tool-context propagation. +- [x] Verify Electron View/WebContentsView reparenting and child-window platform constraints. +- [x] Define the single-parent invariant, eligibility predicate, interaction matrix, and failure + behavior. +- [x] Diagnose the current 420-520 px panel constraints, fixed Browser toolbar, native viewport + behavior, and stale hard-coded Chromium user-agent version. +- [x] Write the proposal, implementation plan, feasibility assessment, and acceptance criteria. + +## Product Decisions Required Before Implementation + +- [x] Automatically switch an already open Workspace surface to Browser for each new Agent browser + action, without pinning Browser afterward. +- [x] Confirm PiP **Close** dismisses only the current run and never closes the Agent page. +- [x] Confirm one primary Agent page is retained per session and reused across loops. +- [x] Confirm PiP remains eligible while the renderer session remains in the working state. +- [x] Use a compact Agent-browser activity strip instead of a webpage below the usable PiP size. +- [ ] Confirm Responsive default, explicit Fit desktop, and Browser Expand as the page-adaptation + model. +- [x] Confirm undersized chats use a compact Agent activity strip instead of an unusable page card. +- [x] Confirm PiP is a low-frame-rate read-only mirror with a 1280 x 800 background viewport. +- [ ] Supply the referenced screenshot or approve a visual baseline for radius, shadow, toolbar, + sizing, and placement. + +## Phase 1: Run Identity and Workspace Foundation + +- [x] Add `runId` to internal tool execution options. +- [ ] Thread `runId` through immediate, batch, resumed, and deferred tool paths. +- [x] Pass `runId` through `AgentToolManager` into `YoBrowserToolHandler`. +- [ ] Add an idempotent browser run-finalization port for every terminal outcome. +- [ ] Replace per-session single-page state with workspace/tab state. +- [x] Add explicit user/Agent page ownership and last-touched run identity. +- [ ] Reuse one primary Agent tab per session and define popup inheritance. +- [x] Expand shared browser status/events for ownership and run identity. +- [x] Add runtime tests for ownership and run-sensitive placement. + +## Phase 2: Placement Ownership and Panel Tabs + +- [ ] Add a versioned renderer layout report for conversation and panel bounds. +- [ ] Validate layout sender, host window, and active session in main. +- [ ] Add one serialized placement controller per workspace. +- [ ] Enforce panel/PiP/detached single-parent invariants. +- [ ] Remove direct competing attach/detach ownership from `BrowserPanel.vue`. +- [ ] Add browser tab create/select/close operations. +- [ ] Add a compact panel tab strip and Agent marker using existing UI primitives. +- [ ] Add container-responsive wide, compact, and narrow Browser chrome. +- [ ] Keep the address bar usable and every hidden command keyboard-accessible at 420 px. +- [x] Generalize the existing Workspace fullscreen shell behavior into Browser Expand/Restore. +- [x] Derive the YoBrowser desktop user-agent Chromium version from the runtime. +- [x] Route user navigation and Agent tools through explicit page ownership. +- [x] Stop destroying a completed Agent page merely because the session becomes inactive. +- [ ] Remove the compatibility single-page status/routes after all callers migrate. +- [ ] Add main/renderer tests for tab and placement races. + +## Phase 3: Page Presentation, PiP Surface, and Interaction + +- [ ] Build representative responsive/fixed-width website fixtures. +- [ ] Compare device emulation and controlled zoom for per-tab Fit desktop. +- [ ] Verify user input, CDP input, DOM, screenshot, iframe, fixed/sticky, and reset coordinates. +- [ ] Ship Fit desktop only if one path passes the complete coordinate proof. +- [ ] Add per-tab Responsive/Fit presentation state without restart persistence. +- [x] Replace the native-page PiP with a lazy Canvas mirror. +- [x] Add the focusless render host and explicit `capturing`/`rendering`/`stopped` modes. +- [x] Add bounded capture, resize, encoding, and typed in-memory frame delivery. +- [x] Keep preview chrome in the trusted chat renderer and expose only typed mode/frame contracts. +- [x] Render sanitized title, Agent activity, **Open in panel**, and **Close**. +- [x] Implement the V1 eligibility predicate. +- [x] Remove the unconditional Agent-triggered `sidepanelStore.openBrowser()` behavior. +- [x] Implement default responsive bounds and small-region fallback. +- [x] Implement compact Agent activity-strip fallback below usable page size. +- [x] Make every non-button PiP point draggable and distinguish click from drag by threshold. +- [x] Toggle the close/expand toolbar on a non-drag click without forwarding page input. +- [x] Implement current-run dismissal without page/tool interruption. +- [x] Implement panel handoff with no reload or duplicate display. +- [x] Move the Agent page to the 1280 x 800 render host when Browser hides during an active run. +- [x] Keep panel activity overlay behavior and render the PiP activity halo in trusted Canvas chrome. +- [x] Add accessible labels, focus behavior, and i18n strings. +- [x] Add focused tests for mirror eligibility, frame retention, input isolation, dismissal, drag, + focus, and panel handoff. + +## Phase 4: Lifecycle and Cross-Platform Validation + +- [ ] Test loop fail, abort, supersede, and teardown cleanup. +- [x] Test loop completion cleanup. +- [ ] Test permission/question pause behavior. +- [ ] Test session switch, route change, multi-window activation, and stale events. +- [x] Test focus/blur mode transitions and host-focus isolation. +- [ ] Test show/hide, minimize/restore, resize, maximize, and display scale. +- [ ] Test tab close and page/chrome/host crash recovery. +- [ ] Test concurrent user commands and Agent tool activity. +- [ ] Test page input, chat focus, native z-order, and activity overlay on macOS. +- [ ] Test Browser chrome at every container breakpoint and at non-integer display scales. +- [ ] Test Responsive, Fit desktop if approved, Expand, and Restore without page reload/state loss. +- [ ] Repeat packaged focus/z-order/drag tests on Windows and Linux. +- [ ] Verify the exact page WebContents/CDP identity survives every placement move. +- [ ] Verify no inactive-session content appears, including transient frames. +- [ ] Profile retained Agent-tab memory before considering eviction. +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. +- [x] Run typecheck, build, focused suites, and the full main/renderer test suite. +- [ ] Capture BEFORE/AFTER screenshots or GIFs and include the ASCII layouts in the PR. + +## Deferred Work + +- [ ] Persist PiP position/size only if users demonstrate a need. +- [ ] Add a measured idle Agent-tab eviction policy only if memory profiling requires it. +- [ ] Add keyboard PiP movement if accessibility review requires it. +- [ ] Generalize Agent tools to explicit tab IDs only when multi-tab automation is requested. +- [ ] Share architecture with standalone browser windows only after a separate convergence design. diff --git a/docs/features/browser-profile-import/plan.md b/docs/features/browser-profile-import/plan.md new file mode 100644 index 0000000000..7a83753990 --- /dev/null +++ b/docs/features/browser-profile-import/plan.md @@ -0,0 +1,362 @@ +# Browser Website Data and Session Import Implementation Plan + +## Status + +V1 implemented. This document remains the design record; unchecked tasks are validation or later +data-category work rather than claims about the shipped cookie importer. + +The smallest credible first release is explicit, one-way Chrome website-session import backed by +cookies on one platform whose supported decryption path passes packaged fixtures. Passwords, +tab-bound `sessionStorage`, Windows App-Bound data, and generic profile cloning are separate from +that milestone. + +## Recommended Delivery Shape + +```text +Settings renderer + | + | typed routes: scan -> preview -> apply -> status + v +BrowserProfileImportPresenter (main process) + | + +-- source discovery + +-- Chromium-profile cookie reader + +-- platform key access + +-- staging and validation + +-- target mutation lock + +-- CDP target writer and verifier + +-- redacted progress/events + | + v +persist:yo-browser Electron session +``` + +Keep this in the main process. Source paths, encryption keys, decrypted values, rollback data, and +CDP cookie parameters must never cross into a renderer. + +## Architecture + +### 1. One presenter, explicit source readers + +Add a `BrowserProfileImportPresenter` owned by the desktop presenter layer. It coordinates a single +import operation and exposes only discovery, preview, apply, cancel-before-mutation, and last-result +operations. + +Do not start with a plugin framework. Use a small internal reader contract and concrete readers for +only the combinations that have passed fixtures, for example: + +```ts +type BrowserProfileSourceReader = { + discover(): Promise + previewCookies(profile: DetectedBrowserProfile): Promise +} +``` + +The reader returns normalized in-memory records, not source-specific database rows. Chrome and Arc +must not share a reader merely because both are Chromium-derived; shared decoding helpers are fine +after their fixtures prove identical behavior. + +### 2. Typed renderer boundary + +Add browser-data import routes and events under the existing shared contract structure. Proposed +operations: + +- `browserDataImport.scan` +- `browserDataImport.preview` +- `browserDataImport.apply` +- `browserDataImport.cancel` +- `browserDataImport.getStatus` + +Renderer-visible payloads contain IDs, display names, capabilities, counts, progress, and stable +redacted error codes. They never contain profile secrets, cookie names, cookie domains beyond an +explicit aggregate preview decision, values, encryption metadata, keychain labels, or raw paths. + +Use an opaque scan token to bind preview/apply to the exact discovered profile. Resolve the source +path again in main and reject a token after the source metadata changes. + +### 3. Source discovery + +Implement deterministic known-location discovery per browser and OS. For every candidate: + +1. Canonicalize the user-data and profile paths. +2. Require the expected metadata and category files. +3. Read profile names from `Local State` when valid. +4. Fall back to validated `Default` / `Profile N` directories. +5. Detect a running source through lock files and process-independent file-open behavior; never kill + the browser. +6. Compute category capability from browser, version, platform, key access, and source state. + +No recursive home-directory scan is needed. + +### 4. Consistent snapshots + +Use a private temporary directory with mode `0700`. + +- For SQLite, use the existing `better-sqlite3-multiple-ciphers` backup support where the source can + be opened consistently, or copy the database plus WAL/SHM only after proving consistency. +- Validate `PRAGMA integrity_check`, schema version, required columns, and row decoding on the + snapshot. +- For a future LevelDB category, require the source browser to be closed and copy the complete + storage directory except transient lock files before opening the snapshot. +- Register startup cleanup for abandoned DeepChat import directories. + +The operation must not mutate YoBrowser until the entire selected source category is staged and +validated. + +### 5. Platform key access + +Build only the platform path selected for V1. + +For a macOS Chrome proof, trigger the system-owned Keychain authorization surface and retrieve the +exact source browser's Safe Storage secret through a main-process-only OS adapter. Derive the +Chromium key according to the source version and decrypt the staged `v10` records. The proof may use +the system `security` executable to avoid a new native dependency, provided arguments and output are +never logged, DeepChat never receives the password typed into the OS prompt, and denial is handled +normally. A production implementation should keep that choice only if authorization behavior, +signing, and sandboxed packaging tests pass. + +Do not implement Windows Chrome/Arc App-Bound decryption in V1. User account or UAC authorization +does not give DeepChat Chrome's application identity. Do not add a browser extension, companion +service, `SYSTEM` process, process injection, memory scan, reverse-engineered +`elevation_service.exe` key, or remote-debugging workaround. Report the source/category as +unsupported before preview mutates anything. + +Use Chromium as a reference implementation, not as a library dependency. The minimal reader needs +only deterministic profile discovery, a consistent SQLite snapshot, explicit encrypted-record +version dispatch, the supported platform key adapter, authenticated record decryption, and cookie +normalization. Do not embed Chromium's `ProfileManager` or copy its profile lifecycle machinery into +DeepChat. + +### 6. Cookie normalization and CDP application + +Normalize source rows into a target-independent cookie record that retains: + +- name and value; +- domain and host-only semantics; +- path; +- expiry or session-only state; +- Secure, HttpOnly, SameSite, and prefix constraints; +- priority, source scheme, and source port; +- partition key and cross-site ancestor state where available. + +Version the mapper by the source schema and target Electron Chromium/CDP major versions. Unknown +enum values or unsupported partition metadata block preview rather than being silently discarded. + +The target operation is transactional at the application level: + +```text +acquire target lock + -> snapshot current target cookies + -> clear target cookies + -> apply staged source cookies in bounded batches + -> read back and normalize + -> compare identities and values + -> success: flush + release lock + reload tabs + -> failure: clear + restore target snapshot + verify + release lock +``` + +Use CDP `Storage`/`Network` cookie commands against `persist:yo-browser`; do not use Electron's +public Cookies API as the sole writer because it cannot express the full partitioned-cookie shape. + +### 7. Mutation ownership + +Add one target-data mutation coordinator shared by import and the existing clear-sandbox action. +While it is held: + +- a second import or clear request is rejected as `target_busy`; +- source preview may continue because it is read-only; +- YoBrowser navigation may remain visible, but the final clear/apply window should be short; +- affected tabs reload only after verified success; +- application shutdown waits for rollback/apply completion within a bounded deadline, then records + recovery-required state if safe completion is impossible. + +### 8. Settings UX + +Extend the existing YoBrowser sandbox card in `DataSettings.vue`. Reuse shadcn-vue Dialog, Button, +Checkbox/Field, Alert, Badge, Progress/Spinner, and existing settings patterns. Use i18n keys for all +visible copy. + +The renderer drives a strict state machine from main-process status. It does not infer readiness +from counts and cannot enable apply before a successful preview token is returned. + +The confirmation screen must say that selected target categories are replaced, that the source is +not modified, and that an OS/source-browser authorization prompt may appear. The result screen +reports imported, skipped, expired, unsupported, and failed counts separately. It reports data +transfer success, not a guarantee that every site server will accept the session. + +## Feasibility Proofs Before Product Work + +### Proof A: macOS Chrome cookie round trip + +Create a sanitized Chrome fixture containing persistent, session, HttpOnly, SameSite variants, +prefix-constrained, and partitioned cookies. Prove: + +- snapshot succeeds with Chrome open and closed; +- system-owned key authorization is understandable and denial is recoverable; +- every supported row decrypts; +- CDP writes and reads back normalized values; +- session cookies remain non-persistent; +- target rollback restores its original cookies byte-for-value where public fields permit. + +This is high feasibility based on the current local Chrome schema, but it is not complete until a +packaged, signed application passes the test. + +### Proof B: Arc source compatibility + +Collect sanitized Arc fixtures on macOS. Verify user-data paths, profile metadata, +cookie schema, keychain identifiers, encryption versions, and running-browser snapshot behavior. +Until this passes, Arc remains experimental rather than inheriting Chrome support. Do not spend +fixture effort on Windows while that platform is outside the release scope. + +### Proof C: partitioned-cookie fidelity + +Test the exact Electron-bundled CDP version with partitioned cookies. If `partitionKey` and +cross-site ancestor state cannot round-trip, block that profile preview or explicitly exclude those +records with user-visible counts; never flatten them into unpartitioned cookies. + +### Proof D: `localStorage` inactive-origin write + +Only if `localStorage` is still desired for the same milestone, prove CDP can clear, write, and read +back multiple inactive origins without navigating real remote pages. Failure moves the category to +a later release. + +## Implementation Phases + +### Phase 0: decisions and fixtures + +- Choose the first directly supported V1 platform and its OS-owned authorization surface. +- Freeze V1 categories. +- Collect legally safe, sanitized fixtures for every supported browser/OS/version combination. +- Record source and target Chromium version support ranges. + +Exit criterion: the selected path has passing feasibility proofs and no unsupported category is +presented as importable. + +### Phase 1: main-process cookie core + +- Add discovery, source snapshot, schema decoder, platform key access, cookie normalization, and + redacted errors. +- Add target lock, CDP snapshot/apply/readback, rollback, and recovery cleanup. +- Add focused unit and fixture tests without renderer work. + +Exit criterion: a headless integration test can repeat source-authoritative cookie sync and can +recover from injected apply failures. + +### Phase 2: typed contracts and settings UI + +- Add routes, events, preload/browser client calls, and status types. +- Add scan, preview, confirmation, progress, result, and clear-conflict UX. +- Add accessibility, keyboard, i18n, and settings tests. + +Exit criterion: a user can understand platform/category limitations before any target mutation. + +### Phase 3: packaged platform validation + +- Validate notarized macOS builds and Windows/Linux packages selected for support. +- Test OS key prompts, source-browser open/closed states, multi-profile discovery, rollback on crash + injection, and app restart cleanup. +- Document the support matrix and known non-transferable login systems. + +Exit criterion: release checks pass on every advertised platform. + +### Later phases, only if separately approved + +- `localStorage` import after Proof D. +- Live-tab `sessionStorage` handoff. +- A separate credential-vault SDD for passwords. + +## Expected File Areas + +Exact names may change after the feasibility proof, but implementation should remain localized to: + +- `src/main/desktop/browser/import/` for discovery, readers, crypto, normalization, and coordinator; +- `src/main/desktop/browser/yoBrowserSession.ts` for target session access/flush hooks; +- `src/main/desktop/browser/YoBrowserPresenter.ts` for tab reload coordination only; +- `src/shared/contracts/routes/` and `src/shared/contracts/events/` for typed boundaries; +- `src/shared/types/` for redacted public state; +- `src/preload/` for the existing safe route exposure pattern; +- `src/renderer/settings/components/DataSettings.vue` and a focused import dialog component; +- mirrored `test/main/**` and `test/renderer/**` suites plus sanitized fixtures. + +Do not place source-profile parsing in the renderer or generalize the standalone browser tab system +to solve this feature. + +## Test Strategy + +### Unit + +- path discovery and canonicalization; +- schema version/column validation; +- Chromium timestamp and enum mapping; +- cookie identity normalization, prefix rules, and expiry filtering; +- platform decrypt success, denial, corrupt ciphertext, and unknown versions; +- state-machine transitions and redacted error serialization; +- target comparison and rollback planning. + +### Main-process integration + +- repeat import with changed and deleted source cookies; +- source-authoritative clearing of target-only cookies; +- session and persistent cookie behavior across restart; +- CHIPS round trip; +- apply failure at every batch boundary; +- rollback failure and recovery-required state; +- import versus clear lock contention; +- source browser open/closed fixtures; +- no secret values in captured logs/events; +- no authorization password enters a DeepChat renderer, IPC payload, log, or process-owned field. + +### Renderer + +- per-profile/per-category capability rendering; +- preview before confirmation; +- apply disabled for stale/failed previews; +- cancellation only before mutation; +- result and error accessibility; +- no accidental source-path or secret rendering. + +### Packaged smoke tests + +- OS key prompt behavior; +- signing/notarization permissions; +- multiple browser channels and profiles; +- unsupported browser/version messaging; +- removal of temporary snapshots after success, failure, crash, and restart. + +## Rollout and Compatibility + +- Hide the entry point when no supported source exists, but keep an explanation in the settings + card rather than an empty dialog. +- Gate experimental Arc and `localStorage` support independently from Chrome cookies. +- Store only non-secret last-sync metadata: browser ID, opaque profile ID, categories, timestamp, + counts, source version, and importer version. +- Do not automatically re-run old imports after an app upgrade. +- If the target Electron Chromium major changes, rerun CDP fidelity fixtures before keeping support + enabled. + +## Rejected Approaches + +- **Point Electron at Chrome's user-data directory**: risks concurrent profile corruption and mixes + incompatible browser-owned state. +- **Copy the whole profile directory**: brings locks, caches, extensions, keys, versioned formats, + and non-transferable credentials without a safe rollback boundary. +- **Attach remote debugging to the user's normal Chrome profile**: current Chrome restricts this and + it is not a stable import contract. +- **Run a privileged App-Bound decryption proof of concept**: the referenced Chrome 130 script + creates a Windows service, executes under `SYSTEM`, and uses a hard-coded key recovered from + `elevation_service.exe`. This bypasses the protection, depends on browser internals, and is outside + the product's authorization and maintenance contract. +- **Install a companion browser extension**: its implementation, distribution, broad cookie + permissions, and ongoing browser-store policy cost are not justified for a platform fallback. +- **Use only Electron `cookies.set`**: loses modern cookie fields. +- **Make password decryption part of cookie import**: still leaves no target password manager and + creates a much larger secret-handling surface. +- **Add a general background sync daemon in V1**: creates consent, locking, freshness, and failure + semantics before the manual import is proven. + +## Complexity Budget + +V1 should add no new runtime dependency unless the packaged macOS key-access proof shows the system +API cannot be used safely. It should support one browser/platform/category combination completely +before adding adapters. A generic migration framework, scheduler, diff viewer, and bidirectional +sync engine are explicitly out of scope. diff --git a/docs/features/browser-profile-import/spec.md b/docs/features/browser-profile-import/spec.md new file mode 100644 index 0000000000..33c0f1750b --- /dev/null +++ b/docs/features/browser-profile-import/spec.md @@ -0,0 +1,495 @@ +# Browser Website Data and Session Import Spec + +## Status + +V1 implemented on 2026-07-17. The shipped scope is explicit, one-way, source-authoritative +non-partitioned cookie import from macOS Chrome, with Arc exposed as experimental. Windows and +Linux report unsupported. Passwords, `localStorage`, `sessionStorage`, and partitioned cookies +remain deferred. + +Last verified against the repository and upstream documentation on 2026-07-17. + +## Problem + +YoBrowser currently uses one independent persistent Electron session, +`persist:yo-browser`. The right-side browser panel and standalone DeepChat browser windows share +that session, but it does not share authentication state with Chrome, Arc, or another installed +Chromium browser. + +Users who are already signed in elsewhere therefore have to sign in again. Clearing the YoBrowser +sandbox is supported, but importing or refreshing source-browser state is not. + +The requested product behavior is a repeatable, user-initiated, one-way import where a selected +Chromium profile is the source of truth and supported website data replaces the corresponding +YoBrowser data. The intended outcome is practical session portability: after import, YoBrowser +should open supported sites with the same transferable signed-in state and site preferences as the +selected source profile. + +## Product Contract + +This is not a whole-profile clone. It is a website-data import whose success is measured by useful +session continuity. + +- Cookies can be imported with useful fidelity, subject to operating-system encryption and newer + device-bound session protections. +- `localStorage` can potentially be imported, but Chromium's on-disk format is internal and must + pass a compatibility proof before it becomes a supported category. +- `sessionStorage` belongs to a specific top-level browsing context. Copying a profile directory + does not attach a Chrome tab's namespace to an Electron tab. +- Saved passwords are not required to carry an already authenticated website session. Electron + exposes encrypted application storage but no public API for importing records into a Chromium + password manager. Making imported passwords useful would require a DeepChat credential vault and + explicit autofill UX. +- Passkeys, Device Bound Session Credentials, client certificates, and hardware-bound tokens are + intentionally non-transferable or may remain unusable after a cookie copy. + +The product may say "website data imported" or "session data synced" when the selected categories +were verified. It must not claim that every site is signed in: expired server sessions, +device-bound credentials, and site-side risk checks can still request authentication. + +## Goals + +- Discover supported installed browsers and their local profiles without modifying the source. +- Let the user select one source browser/profile and preview what can actually be imported. +- Support repeated, explicit, one-way synchronization. +- Make the source authoritative for each selected and supported category. +- Optimize for the user-visible outcome that supported sites retain their transferable signed-in + session after the target tabs reload. +- Preserve modern cookie attributes where the target Chromium/CDP version supports them. +- Stage and validate all source data before mutating YoBrowser. +- Roll back the target category when an apply step fails. +- Report imported, skipped, expired, unsupported, and failed records without exposing secret values. +- Keep all processing local to the device. + +## Non-Goals + +- Real-time or bidirectional synchronization. +- Writing anything back to Chrome, Arc, or another source browser. +- Copying an entire live Chromium profile directory into Electron. +- Circumventing Chrome App-Bound Encryption, operating-system authorization, enterprise policy, or + device-bound authentication. Authorization must use an OS-owned or source-browser-owned prompt. +- Asking the user to type an operating-system, Chrome, Arc, or keychain password into a DeepChat + renderer form. +- Claiming every Chromium-derived browser is supported by one generic adapter. +- Importing browsing history, bookmarks, downloads, the user's installed extensions, payment cards, + passkeys, client certificates, or browser settings in the first release. +- Giving an Agent access to raw saved passwords. +- Installing a browser extension or companion service to extract source data. +- Creating a Windows service, running code as `SYSTEM`, injecting into a source-browser process, + scraping process memory, or using reverse-engineered browser keys to defeat App-Bound Encryption. + +## Terminology + +- **Source browser**: An installed Chrome, Arc, or later explicitly supported Chromium browser. +- **Source profile**: One profile directory selected by the user. +- **Target session**: DeepChat's shared `persist:yo-browser` Electron session. +- **Quick sync**: A cookie-only import that may be possible while the source browser is running. +- **Full storage sync**: Any import that includes Chromium storage directories or LevelDB data and + may require the source browser to be closed. +- **Source authoritative**: Existing target data in the selected category and scope is removed + before validated source data is applied. +- **Session portability**: Transferable client-side website state is present in YoBrowser after + import; it does not imply that the website's server accepts every copied session indefinitely. +- **Direct importer**: A small main-process reader that discovers a supported Chromium profile, + snapshots only the selected data stores, uses the supported OS/browser cryptography path, and + normalizes records before applying them to YoBrowser. It never loads the source profile as a + DeepChat browser profile. + +## Current Repository Contract + +- `src/main/desktop/browser/yoBrowserSession.ts` owns the persistent target session. +- `src/main/desktop/browser/YoBrowserPresenter.ts` creates Agent browser views with that session. +- `src/main/desktop/tab.ts` also assigns that session to standalone DeepChat browser windows. +- `src/renderer/settings/components/DataSettings.vue` currently exposes only a destructive clear + action for the YoBrowser sandbox. +- `better-sqlite3-multiple-ciphers` already provides read-only SQLite access and an online backup + API. +- `level` is already installed and is used by the existing provider-import code for LevelDB + snapshots. + +The import target is therefore global to YoBrowser, not per chat session. + +## Source Discovery + +Discovery must be adapter-driven and read-only. + +Each detected source must include: + +- browser ID, display name, channel, and executable presence; +- user-data directory and profile directory; +- profile display name when safely available; +- last modified time; +- whether the source appears to be running; +- per-category capability and the reason for any unsupported state. + +Initial discovery scope: + +| Browser | macOS | Windows | Linux | Notes | +| --- | --- | --- | --- | --- | +| Google Chrome stable | Required | Unsupported in V1 | Feasibility gate | Direct cookie support varies by OS | +| Chromium | Optional after Chrome | Unsupported in V1 | Optional after Chrome | Separate key-store identity | +| Arc | Experimental in V1 | Unsupported in V1 | Not available | Uses an explicit Arc source identity | +| Edge / Brave | Future | Future | Future | Do not imply support from Chromium ancestry alone | + +Chromium documents that profiles are subdirectories of the user-data directory, commonly +`Default` or `Profile N`. Profile metadata may be read from `Local State`, but discovery must fall +back to validated directory inspection when metadata is missing. + +[GUESS] Arc's storage and keychain identifiers remain compatible enough with Chromium for a shared +reader. This must be proven with sanitized Arc fixtures on every supported OS before Arc is labeled +supported. Arc's public documentation confirms that profiles contain cookies, logins, passwords, +history, and extensions, but it does not define a stable on-disk contract. + +## Capability Matrix + +### Data categories + +| Category | Direct profile read | Target apply | Proposed release status | +| --- | --- | --- | --- | +| Cookies | Platform-dependent | High via CDP | V1 candidate | +| Partitioned cookies (CHIPS) | Platform-dependent | Medium-high via CDP | V1 only after protocol tests | +| `localStorage` | Medium, internal LevelDB schema | Medium | Feasibility gate | +| `sessionStorage` | Low without source tab mapping | Medium per target tab | Later live-tab handoff research | +| IndexedDB | Low-medium, internal schema and blobs | Low-medium | Not V1 | +| Service workers / Cache Storage | Low value and high compatibility risk | Medium | Not V1 | +| Saved passwords | Platform-dependent decryption | No Electron password-manager import API | Separate product decision | +| Passkeys / DBSC / client keys | Intentionally restricted | Not transferable | Unsupported | + +### Operating-system constraints + +| Platform | Direct cookie feasibility | Main constraint | Recommended path | +| --- | --- | --- | --- | +| macOS | High for Chrome after proof | Source Keychain item and system-owned user authorization | Direct reader first | +| Windows | Unsupported in V1 | Chrome App-Bound Encryption binds cookies to Chrome identity | No extension and no bypass; show unsupported state | +| Linux | Medium | Secret Service/KWallet availability and browser-specific key identity | Direct reader only after desktop-matrix proof | + +Chrome introduced App-Bound Encryption for Windows cookies in Chrome 127. Entering the Windows +account password does not grant an unrelated application Chrome's app identity. DeepChat must not +use process injection, memory scraping, `SYSTEM` elevation, a reverse-engineered browser key, or +another bypass. The product decision is to omit Windows support instead of building a companion +extension or privileged helper. Chrome 136 also rejects remote-debugging switches against the +default user-data directory, so "attach CDP to the user's current Chrome profile" is not an +acceptable general solution. + +The direct importer should reuse Chromium's documented profile layout, source schemas, timestamp +conventions, and OS cryptography behavior where those form a supportable contract. Chromium's +`ProfileManager` is useful as a reference for discovering and selecting profiles owned by Chromium, +but it is not a cross-application migration or decryption API. + +The linked `thewh1teagle` Windows proof of concept is not an acceptable implementation path. It was +tested against Chrome 130, creates a local Windows service through `pypsexec`, executes part of the +unwrap under `SYSTEM`, and uses a hard-coded AES key recovered from `elevation_service.exe`. That is +an App-Bound Encryption bypass built from version-specific internals, not an OS-owned authorization +flow. Its useful input is limited to the neutral importer mechanics: locate `Local State`, recognize +encrypted-record versions, snapshot the SQLite cookie store, authenticate AES-GCM records, and +handle the 32-byte cookie plaintext prefix for schema versions where fixtures prove it. + +## Authorization Contract + +Import is always initiated by the user. Authorization is explicit and may include Touch ID, +Windows Hello, an operating-system account password, or a keychain/keyring prompt. + +The authorization surface must be owned by the operating system or source browser whenever it +protects source secrets. DeepChat may trigger that surface and explain why it appears, but it must +not render a look-alike password prompt, receive the password, store it, or pass it through IPC. + +Authorization does not change the support matrix: + +- on macOS/Linux, successful OS key access may unlock a supported direct reader; +- on Windows, current Chrome App-Bound Encryption remains unsupported in V1 even if the user can + approve a UAC or account prompt, because authorization does not grant Chrome's application + identity; +- denial or cancellation returns to preview without changing YoBrowser; +- the user authorizes each import operation in V1; no reusable background authorization token is + retained. + +## Cookie Import Contract + +Cookies are the recommended first category because they produce most of the requested +signed-in-session benefit without creating a password manager. + +Before target mutation, the importer must: + +1. Create a consistent source SQLite snapshot. +2. Validate the `meta.version` and required columns. +3. Decrypt every non-expired source cookie selected for import. +4. Map Chromium time values and enum fields to CDP cookie parameters. +5. Validate domain, path, prefix, SameSite, Secure, HttpOnly, source scheme, source port, priority, + and partition-key data. +6. Abort before target mutation if any required encrypted record cannot be handled. + +V1 applies only non-partitioned cookies through Electron's public `Session.cookies` API, then reads +them back and verifies normalized identity/value pairs. Partitioned cookies are counted and skipped +because the public shape does not expose the required partition key. A future CHIPS-capable release +must move that category to CDP `Storage.getCookies`, `Storage.clearCookies`, and +`Storage.setCookies` with protocol fixtures. + +Source-authoritative cookie sync means: + +- snapshot all target cookies for rollback; +- clear the target cookie store; +- set the complete validated source set in bounded batches; +- read back the target set and compare normalized cookie identities and values; +- restore the target snapshot if apply or verification fails; +- flush the cookie store; +- reload open YoBrowser tabs after success. + +Cookie creation and last-access timestamps cannot be set through the public CDP cookie parameter +shape. The UI must not call this a byte-identical copy. + +Session cookies remain session cookies. DeepChat must not invent an expiration date to persist them. +They may need to be imported again after DeepChat restarts. + +## Local Storage Contract + +`localStorage` is origin-scoped persistent data, but Chromium stores it in an internal LevelDB +format. It can become supported only if a feasibility proof demonstrates all of the following: + +- consistent source snapshots while the source browser is closed; +- correct decoding of Chromium storage keys and string encodings; +- correct handling of storage keys and partitioning in the target Electron Chromium version; +- authoritative target clearing; +- target writes without loading arbitrary remote pages or triggering site side effects; +- round-trip verification on fixtures generated by the exact Chrome and Electron major versions in + the support matrix. + +The preferred live target path is CDP `DOMStorage.clear` plus `DOMStorage.setDOMStorageItem` against +an importer target. If CDP cannot write an inactive origin reliably, V1 must defer `localStorage` +rather than copy target LevelDB files while Electron owns them. + +## Session Storage Contract + +`sessionStorage` is partitioned by origin and top-level browsing context. A Chrome profile's +`Session Storage` LevelDB contains namespaces that refer to Chrome tabs. A DeepChat tab does not +inherit those namespace identities merely because files are copied. + +Therefore: + +- offline profile-level `sessionStorage` import is not a V1 commitment; +- a later user-initiated open-tab handoff may be considered only if a supported source-browser API + can export the current tab's URL and `sessionStorage` without an extension or encryption bypass; +- the UI must describe this as "open tab handoff", not profile synchronization; +- closing either source or target tab ends the relevant page session as normal. + +## Password Contract + +Saved passwords require a separate implementation track, but they can remain part of the broader +"make YoBrowser useful with existing browser data" roadmap. + +Reading Chrome's `Login Data` does not make Electron autofill those credentials. Electron provides +`safeStorage` for application-owned encrypted strings, but its documented public session APIs do +not provide a password-manager import surface. This is an inference from the Electron 40.10.5 +public API and must be rechecked before implementation. + +The acceptable password direction is an explicit DeepChat credential-vault feature with: + +- user-triggered Chrome/Arc CSV export or another user-mediated export; +- source-browser or operating-system authorization rather than a DeepChat-owned system-password + prompt; +- immediate encrypted ingestion and a prominent plaintext-file warning; +- origin-bound, user-triggered fill; +- no automatic submit; +- no Agent, tool, renderer, log, export, or telemetry access to decrypted credentials; +- OS-backed encryption with a hard failure on insecure Linux `basic_text` storage; +- independent threat modeling, tests, and settings UX. + +That work is not required for session portability and should not be hidden inside cookie-sync V1. + +## User Experience + +### Before + +```text ++--------------------------------------------------------------+ +| YoBrowser Sandbox | +| Independent cookies and local storage. | +| [Clear YoBrowser data] | ++--------------------------------------------------------------+ +``` + +### Proposed settings card + +```text ++--------------------------------------------------------------+ +| YoBrowser data | +| Source: Google Chrome / Personal | +| Last sync: 2026-07-17 14:32 · Cookies: 1,284 | +| [Import or sync] [Clear YoBrowser] | ++--------------------------------------------------------------+ +``` + +### Proposed import dialog + +```text ++------------------------------------------------------------------+ +| Import browser data [x] | +| | +| Browser [Google Chrome v] Profile [Personal v] | +| | +| [x] Cookies Ready | +| [ ] Local storage Experimental · Chrome must be closed | +| [ ] Session storage Not available for profile import | +| [ ] Passwords Requires a separate credential vault | +| | +| Source data replaces the selected YoBrowser categories. | +| Chrome is never modified. | +| System or Chrome authorization may be requested after Preview. | +| [Cancel] [Preview] | ++------------------------------------------------------------------+ +``` + +### Unsupported Windows source + +This state appears when a Windows Chrome or Arc profile uses App-Bound Encryption. + +```text ++------------------------------------------------------------------+ +| Import browser data [x] | +| | +| Windows Chrome protects cookies with Chrome's app identity. | +| This Chrome profile cannot be imported safely by DeepChat. | +| | +| DeepChat will not install an extension, elevate a helper, or | +| bypass Chrome's encryption. | +| | +| [Close] | ++------------------------------------------------------------------+ +``` + +### Interaction flow + +1. The user opens Data & Privacy and selects **Import or sync**. +2. DeepChat scans known locations and shows only validated source profiles. +3. The user selects a source profile. +4. DeepChat displays per-category capability, browser-running requirements, and platform limits. +5. **Preview** performs source snapshot, authorization when required, decryption, validation, and + count calculation without changing YoBrowser. Protected authorization UI is owned by the OS or + source browser, not DeepChat. +6. The confirmation view states exactly which target categories will be replaced. +7. DeepChat pauses conflicting YoBrowser mutations, applies the staged data, verifies, and reloads + affected tabs. +8. The result shows counts and stable error codes, never secret values or source URLs beyond what + the user selected. +9. Running the flow again repeats the same source-authoritative operation. + +There is no schedule or background watcher in V1. + +## Import States + +The renderer may display these stable states: + +- `idle` +- `scanning` +- `previewing` +- `ready` +- `applying` +- `verifying` +- `rolling_back` +- `succeeded` +- `failed` +- `cancelled` + +Cancellation is allowed before target mutation. After mutation starts, the operation must finish or +roll back rather than stop halfway. + +## Security and Privacy Requirements + +- Source paths must be canonicalized and restricted to discovered/explicitly selected profiles. +- Symlinks and path traversal must not escape the selected profile root. +- Source databases and LevelDB files are opened read-only. +- Temporary snapshots use a mode-`0700` directory and are always removed after success, failure, or + startup recovery. +- Decrypted cookie/password values are never logged, serialized to renderer state, included in + exceptions, copied to the clipboard, or written to plaintext staging files. +- Renderer contracts expose counts, capability, progress, and redacted failures only. +- A source Keychain/keyring denial is a normal unsupported/denied result, not a retry loop. +- DeepChat never receives or stores the password entered into an OS/source-browser authorization + surface. +- The source profile is never opened as an Electron `Session`. +- Import is blocked while another import or clear operation owns the target mutation lock. +- Rollback data receives the same secret handling as source data. +- Windows App-Bound sources fail capability detection before any key or cookie value is staged. + +## Failure Semantics + +Stable categories should include: + +- `browser_not_found` +- `profile_not_found` +- `source_browser_busy` +- `source_schema_unsupported` +- `source_snapshot_failed` +- `key_access_denied` +- `encryption_unsupported` +- `record_decryption_failed` +- `target_busy` +- `target_apply_failed` +- `target_verification_failed` +- `target_rollback_failed` +- `category_unsupported` + +If rollback fails, DeepChat must mark the target as requiring an explicit clear or re-import and +must not report success. + +## Acceptance Criteria + +- The settings UI discovers and distinguishes multiple supported source profiles. +- Every source/profile/category combination reports `ready`, `requires_action`, `experimental`, or + `unsupported` with a concrete reason. +- Preview does not mutate the source or target. +- A supported cookie sync can be repeated and produces source-authoritative target cookies. +- After successful import and tab reload, transferable source sessions are available to YoBrowser; + sites with expired or device-bound sessions may still request login without invalidating the + import result. +- Partitioned cookie fields are preserved when the target CDP version supports them; otherwise the + preview blocks rather than silently flattening them. +- No target mutation occurs when source decryption or validation is incomplete. +- A failed target apply restores the previous target cookie set and verifies the rollback. +- Open YoBrowser tabs reload only after a successful apply. +- Chrome/Arc files are never written. +- Windows App-Bound cookies are not decrypted through a bypass. +- Protected source access uses OS/source-browser authorization; DeepChat never collects the + authorization password itself. +- `sessionStorage` and saved-password limitations are visible before confirmation. +- Secret values never cross the main-process contract boundary. +- Focused unit, integration, and fixture tests cover supported schema and platform combinations. +- Format, i18n, lint, typecheck, and relevant tests pass after implementation. + +## Decision Gates + +Implementation should not begin until these product decisions are made: + +1. **Windows Chrome support (decided)**: current Chrome/Arc App-Bound data is unsupported. Do not + build a companion extension, privileged service, process-injection path, or reverse-engineered + decryption path. Revisit only if Windows or the source browser exposes a supported export API. +2. **Delivery sequence**: recommendation is cookie-backed session portability as the first shipping + slice. Add `localStorage` after the CDP arbitrary-origin proof, then current-tab + `sessionStorage` handoff; these remain roadmap capabilities rather than discarded requirements. +3. **Password sequencing**: passwords remain in the roadmap, but recommendation is a separate + credential-vault SDD after transferable session data because they do not contribute to an already + active site session. +4. **Arc support label**: recommendation is experimental until sanitized macOS Arc fixtures prove + profile discovery and key access. Windows Arc remains outside V1 with Windows Chrome. + +Confirmed product requirements: import is user-initiated, source-authoritative, repeatable, and does +not use an encryption bypass. V1 therefore has no startup/background synchronization. + +## Verified References + +- [Chromium user-data directory and profile locations](https://chromium.googlesource.com/chromium/src/+/main/docs/user_data_dir.md) +- [Chromium persistent cookie-store schema and encrypted values](https://chromium.googlesource.com/chromium/src/+/main/net/extras/sqlite/sqlite_persistent_cookie_store.cc) +- [Chromium OS Crypt contract](https://chromium.googlesource.com/chromium/src/+/HEAD/components/os_crypt/sync/README.md) +- [Chromium ProfileManager lifecycle contract](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/chrome/browser/profiles/profile_manager.h) +- [Chrome App-Bound Encryption on Windows](https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html) +- [Chromium App-Bound Encryption path validation](https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/os_crypt/README.md) +- [Chrome 136 remote-debugging restrictions](https://developer.chrome.com/blog/remote-debugging-port) +- [thewh1teagle Chrome 130 App-Bound proof of concept](https://gist.github.com/thewh1teagle/d0bbc6bc678812e39cba74e1d407e5c7) +- [Chrome DevTools Protocol Storage domain](https://chromedevtools.github.io/devtools-protocol/tot/Storage/) +- [Chrome DevTools Protocol Network cookie parameters](https://chromedevtools.github.io/devtools-protocol/tot/Network/) +- [Chrome DevTools Protocol DOMStorage domain](https://chromedevtools.github.io/devtools-protocol/tot/DOMStorage/) +- [Electron Session API](https://www.electronjs.org/docs/latest/api/session) +- [Electron Cookies API](https://www.electronjs.org/docs/latest/api/cookies) +- [Electron safeStorage API](https://www.electronjs.org/docs/latest/api/safe-storage) +- [HTML Web Storage standard](https://html.spec.whatwg.org/multipage/webstorage.html) +- [Arc profile behavior](https://resources.arc.net/hc/en-us/articles/19227964556183-Profiles-Separate-Work-Personal-Browsing) +- [Chrome password export](https://support.google.com/chrome/answer/95606) diff --git a/docs/features/browser-profile-import/tasks.md b/docs/features/browser-profile-import/tasks.md new file mode 100644 index 0000000000..ea2a19d5ac --- /dev/null +++ b/docs/features/browser-profile-import/tasks.md @@ -0,0 +1,98 @@ +# Browser Website Data and Session Import Tasks + +## Status + +V1 cookie-backed session import implemented. Packaged macOS/Arc fixture validation and deferred +data categories remain open. + +## Completed Discovery + +- [x] Trace the current `persist:yo-browser` session ownership and clear-data flow. +- [x] Confirm that Agent and standalone DeepChat browser views share the target session. +- [x] Inspect the current local Chrome Cookies schema without reading or recording secret values. +- [x] Verify current Electron public Session, Cookies, `safeStorage`, View, and CDP constraints. +- [x] Record Chrome App-Bound Encryption and remote-debugging restrictions. +- [x] Evaluate the referenced Chrome 130 `SYSTEM`/hard-coded-key proof of concept and reject its + App-Bound bypass while retaining only neutral schema and snapshot lessons. +- [x] Separate cookies, `localStorage`, `sessionStorage`, passwords, and non-transferable credentials + into honest capability categories. +- [x] Write the proposal, implementation plan, risks, acceptance criteria, and decision gates. + +## Product Decisions Required Before Implementation + +- [x] Keep current Windows Chrome/Arc App-Bound data unsupported; do not build a companion extension + or privileged/reverse-engineered decryption path. +- [x] Confirm V1 session portability is cookie-backed, subject to feasibility proofs. +- [x] Keep passwords in the roadmap through a separate credential-vault proposal. +- [x] Confirm Arc is labeled experimental until fixture validation passes. +- [x] Keep synchronization explicitly user-triggered, not scheduled/background. +- [x] Require OS/source-browser authorization and prohibit encryption bypasses. +- [x] Decide whether target scope is always the entire global YoBrowser session or may be limited to + selected domains in a later release. + +## Feasibility Gates + +- [ ] Build sanitized Chrome cookie fixtures for each supported source schema. +- [ ] Prove consistent SQLite snapshot behavior with Chrome open and closed. +- [ ] Prove packaged macOS Chrome system-owned authorization, key access, decryption, and denial + handling without DeepChat receiving the authorization password. +- [ ] Prove CDP target set/readback for all supported cookie attributes. +- [ ] Prove partitioned-cookie round trip for the bundled Electron Chromium version. +- [ ] Prove full target-cookie rollback after injected failures. +- [ ] Collect and validate sanitized Arc fixtures on every proposed Arc platform. +- [ ] If `localStorage` remains proposed, prove inactive-origin clear/write/readback before adding it + to the release scope. + +## Phase 1: Main-Process Cookie Core + +- [x] Add deterministic known-location browser/profile discovery. +- [x] Add canonical path and source-profile validation. +- [ ] Add a private snapshot workspace with crash/startup cleanup. +- [x] Add the selected Chrome source schema reader. +- [x] Add the selected OS key-access/decryption adapter. +- [x] Ensure protected authorization is rendered by the OS/source browser, never a DeepChat + password form. +- [x] Add versioned cookie normalization and validation. +- [x] Add stable, redacted error codes. +- [x] Add one target mutation coordinator shared with clear-sandbox data. +- [x] Add target snapshot, clear, batch apply, readback, and normalized comparison for + non-partitioned cookies. +- [x] Add rollback handling. +- [x] Flush the target cookie store and reload open YoBrowser tabs only after verified success. +- [x] Add unit and main-process integration tests for decryption, replacement, verification, and + rollback. + +## Phase 2: Contracts and Settings UX + +- [x] Add shared schemas for scan, preview, and apply. +- [x] Add main routes and client exposure through existing patterns. +- [ ] Extend the YoBrowser settings card with last-sync metadata. +- [x] Add the source/profile/category selection dialog using existing shadcn-vue primitives. +- [x] Add preview counts and capability reasons. +- [x] Add source-authoritative replacement confirmation. +- [ ] Add apply/verify/rollback progress and final result UX. +- [ ] Prevent cancellation after target mutation starts. +- [x] Add accessible labels, keyboard behavior, and i18n strings. +- [ ] Add renderer tests for state, stale tokens, failures, and secret redaction. + +## Phase 3: Platform and Release Validation + +- [ ] Test signed/notarized application builds on every advertised platform. +- [ ] Test multiple source profiles and browser-running states. +- [ ] Test source schema/version rejection with actionable UI. +- [ ] Test import/clear contention and app shutdown during apply. +- [ ] Test temporary-data removal after success, failure, crash, and restart. +- [ ] Audit logs, telemetry, renderer state, crash reports, and errors for secret leakage. +- [ ] Verify authorization passwords never enter DeepChat IPC, memory fields, logs, or telemetry. +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. +- [x] Run typecheck, build, focused suites, and the full main/renderer test suite. +- [ ] Update user documentation with the exact support matrix and limitations. + +## Deferred, Separately Approved Work + +- [ ] Add `localStorage` only after its feasibility gate passes. +- [ ] Design live-tab `sessionStorage` handoff as a separate flow. +- [ ] Write a separate SDD for an origin-bound credential vault if password autofill is approved. +- [ ] Evaluate additional browsers only with explicit discovery, crypto, schema, and fixture support. diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index 3ccb406df5..ac60011f61 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -122,7 +122,7 @@ { "id": "cline", "name": "Cline", - "version": "3.0.42", + "version": "3.0.44", "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", "repository": "https://github.com/cline/cline", "website": "https://cline.bot/cli", @@ -133,7 +133,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg", "distribution": { "npx": { - "package": "cline@3.0.42", + "package": "cline@3.0.44", "args": [ "--acp" ] @@ -508,7 +508,7 @@ { "id": "factory-droid", "name": "Factory Droid", - "version": "0.173.0", + "version": "0.174.0", "description": "Factory Droid - AI coding agent powered by Factory AI", "website": "https://factory.ai/product/cli", "authors": [ @@ -517,7 +517,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "droid@0.173.0", + "package": "droid@0.174.0", "args": [ "exec", "--output-format", @@ -534,7 +534,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.9.10", + "version": "0.9.14", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -544,7 +544,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.9.10", + "package": "fast-agent-acp==0.9.14", "args": [ "-x" ] @@ -555,7 +555,7 @@ { "id": "gemini", "name": "Gemini CLI", - "version": "0.50.0", + "version": "0.51.0", "description": "Google's official CLI for Gemini", "repository": "https://github.com/google-gemini/gemini-cli", "website": "https://geminicli.com", @@ -565,7 +565,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@google/gemini-cli@0.50.0", + "package": "@google/gemini-cli@0.51.0", "args": [ "--acp" ] @@ -671,7 +671,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.101", + "version": "0.2.102", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -680,7 +680,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@0.2.101", + "package": "@xai-official/grok@0.2.102", "args": [ "agent", "stdio" @@ -692,7 +692,7 @@ { "id": "harn", "name": "Harn", - "version": "0.10.19", + "version": "0.10.22", "description": "Harn runs .harn agent pipelines as a native ACP coding agent over stdio.", "repository": "https://github.com/burin-labs/harn", "website": "https://harnlang.com", @@ -703,49 +703,49 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.19/harn-aarch64-apple-darwin.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.22/harn-aarch64-apple-darwin.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "05ba15c0cc05a9afa8a05182c0893b02a50242f8cd804005850a331e6155394a" + "sha256": "ee2d2fb2172d893c656848b8c25c4b527b162c03cac9931667f6da4d7bc8da07" }, "darwin-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.19/harn-x86_64-apple-darwin.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.22/harn-x86_64-apple-darwin.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "9804e91ce510c51a32e8c801a2865575c3091e577ff8c43aa5e2a67f16484959" + "sha256": "6a984d21eb6102bc9993ab89169a2acdc207e453ed88ef09157f1c6d4ed593b5" }, "linux-aarch64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.19/harn-aarch64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.22/harn-aarch64-unknown-linux-gnu.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "718ba0a9d42c9f1155bd27d183a6e314900c5829e202d41195545c6071f47033" + "sha256": "5d1d16000915e5d20cba00c794581890a44dccf5365a7ee729bc7248a37fddcf" }, "linux-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.19/harn-x86_64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.22/harn-x86_64-unknown-linux-gnu.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "5e1114e04105541a7d456ce6c20c031929b27471c271da34aa8dd9357c0b6122" + "sha256": "ac36baa6ddf61e64ee5d499be8c35b461527756f5006dbab333bc518d7ce6fca" }, "windows-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.19/harn-x86_64-pc-windows-msvc.zip", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.22/harn-x86_64-pc-windows-msvc.zip", "cmd": "harn.exe", "args": [ "serve", "acp" ], - "sha256": "ef8bb6b5643071cb482fa44bb56729df9af1a49d9212dcd9852ea817d051fe14" + "sha256": "352727aa4be51be711a70838df0f30856b5e6ef79014737d3ebeb867d4b128c4" } } }, @@ -806,7 +806,7 @@ { "id": "kilo", "name": "Kilo", - "version": "7.4.9", + "version": "7.4.11", "description": "The open source coding agent", "repository": "https://github.com/Kilo-Org/kilocode", "website": "https://kilo.ai/", @@ -818,48 +818,48 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/kilo-darwin-arm64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-darwin-arm64.zip", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "a3fab28d5b97f6d0b297f2d12302e2664344b7fcee3ccc9adc3a84db0be15c96" + "sha256": "14a030a354f3b51f0241662627702e7b06cddf3fcb6e0f1415279e9d3a3b8998" }, "darwin-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/kilo-darwin-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-darwin-x64.zip", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "cf6fe6bc77e63bbdf387e34fc31d1aa86a603b6de48011a86ffdc28708bc1453" + "sha256": "66e302e09b96fd9794012bdb608622b589e4810ba31eca35918d8acd1a32a438" }, "linux-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/kilo-linux-arm64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-linux-arm64.tar.gz", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "e0c4ff2ec2f65689406c43e6aff06cea30be8d12ed7193e1ed46a0e0e28d86fd" + "sha256": "48c5405eb05efb3558d120b521d1a2b097cd8e99d36d7caf015e7c467922793c" }, "linux-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/kilo-linux-x64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-linux-x64.tar.gz", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "0d3fc944dbf2b987ce9953d5c4701dbdc950b0905a186c814a24e8915fd7012c" + "sha256": "b060dd4e094b0f9966de03d8a0d0d5cc8e6bb23ab04f1d04b7e3951d13079770" }, "windows-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/kilo-windows-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-windows-x64.zip", "cmd": "./kilo.exe", "args": [ "acp" ], - "sha256": "2452f063e6149bffca64e862ae5b2063fe2be6a14200012f92743a8d1a3b4c0c" + "sha256": "1c04d25b1484526b1eb8abe44bbcfc179fb71b1efa88b0164cd5709ca8bb00e7" } }, "npx": { - "package": "@kilocode/cli@7.4.9", + "package": "@kilocode/cli@7.4.11", "args": [ "acp" ] @@ -869,7 +869,7 @@ { "id": "kimi", "name": "Kimi CLI", - "version": "1.48.0", + "version": "1.49.0", "description": "Moonshot AI's coding assistant", "repository": "https://github.com/MoonshotAI/kimi-cli", "website": "https://moonshotai.github.io/kimi-cli/", @@ -880,39 +880,44 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.48.0/kimi-1.48.0-aarch64-apple-darwin.tar.gz", + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-apple-darwin.tar.gz", "cmd": "./kimi", "args": [ "acp" - ] + ], + "sha256": "15018b20b203aee09658fdc64840c4846fc17c108d8dba1a19a95581d3ce2921" }, "linux-aarch64": { - "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.48.0/kimi-1.48.0-aarch64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-unknown-linux-gnu.tar.gz", "cmd": "./kimi", "args": [ "acp" - ] + ], + "sha256": "5ac54cabce16ede27b9d2069b9b88edee25528646e7bb5befa9980a1ca71febb" }, "linux-x86_64": { - "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.48.0/kimi-1.48.0-x86_64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-x86_64-unknown-linux-gnu.tar.gz", "cmd": "./kimi", "args": [ "acp" - ] + ], + "sha256": "6ce0b83f583c45a64cc9f51ffe7e1a8e03ee79acda69945fcf8c23341b9d892f" }, "windows-aarch64": { - "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.48.0/kimi-1.48.0-aarch64-pc-windows-msvc.zip", + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-pc-windows-msvc.zip", "cmd": "./kimi.exe", "args": [ "acp" - ] + ], + "sha256": "3ac8f05c7bd18d902a324c6c03a71084cfbe785b9669bbd556c071ee1d8f2f26" }, "windows-x86_64": { - "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.48.0/kimi-1.48.0-x86_64-pc-windows-msvc.zip", + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-x86_64-pc-windows-msvc.zip", "cmd": "./kimi.exe", "args": [ "acp" - ] + ], + "sha256": "2acbbc7ca8c8ac4b03dab1d970f53a292bd226168151b423499feab9fc203ddd" } } }, @@ -1004,7 +1009,7 @@ { "id": "opencode", "name": "OpenCode", - "version": "1.18.2", + "version": "1.18.3", "description": "The open source coding agent", "repository": "https://github.com/anomalyco/opencode", "website": "https://opencode.ai", @@ -1016,52 +1021,52 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.2/opencode-darwin-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "2cb1eb3301a73598890364dfeb45c535155a3855b37ed1d190172821e74e462c" + "sha256": "946f62b155638b911144b7bef520ee4a6442f696297907873463bca3524e40ef" }, "darwin-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.2/opencode-darwin-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-x64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "3672491ae6383468be40e54ee5c6fcbac354eb5cfb7c6afbd0ee3ae0c6af4b2e" + "sha256": "4ea147867ba19e4ec03559df557811f1674f40788aea4d10326dc563b7667c6d" }, "linux-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.2/opencode-linux-arm64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-arm64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "93352b30d37d8da2e5c226085f1afbf37cf57cfcecedc813520ff2d0f8581540" + "sha256": "da0a631174eba380b2a1d51f9d364fa3812da433e72743c72471d4b5da59c69d" }, "linux-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.2/opencode-linux-x64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-x64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "97c95e004bb73d2039f957ea33be0635ea4e22b8dceaedf8f0983765950cf1b6" + "sha256": "60f27b2679f00a511b6539f97e02448afaf58d9c66e2448285ea0c517ca84583" }, "windows-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.2/opencode-windows-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "7eb407240101a0ebca9acb99ed819a09a4211fdafa381161af6ca49f85736e4b" + "sha256": "a549fb2e9041db9438bcd9b77bfa0a4b2476caf2d550f37479aabfec1b079bfb" }, "windows-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.2/opencode-windows-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-x64.zip", "cmd": "./opencode.exe", "args": [ "acp" ], - "sha256": "aca23b18d03639c6bd576a2dd23cebfa6dab80d31358f17cfa03fd1f2a5c41fd" + "sha256": "68bc62930f6cb5755e0409aa9de0bb270a66ed2b8c9cf0c029e9f2287ed5486e" } } } @@ -1165,7 +1170,7 @@ { "id": "qwen-code", "name": "Qwen Code", - "version": "0.19.10", + "version": "0.19.11", "description": "Alibaba's Qwen coding assistant", "repository": "https://github.com/QwenLM/qwen-code", "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", @@ -1175,7 +1180,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@qwen-code/qwen-code@0.19.10", + "package": "@qwen-code/qwen-code@0.19.11", "args": [ "--acp", "--experimental-skills" diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 29764373e1..0370d123a8 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -493,9 +493,9 @@ "release_date": "2026-05-29", "last_updated": "2026-05-29", "cost": { - "input": 0.2, - "output": 1.15, - "cache_read": 0.04, + "input": 0.19, + "output": 1.14, + "cache_read": 0.03, "cache_write": 0 }, "type": "chat" @@ -614,9 +614,9 @@ "release_date": "2026-06-12", "last_updated": "2026-06-12", "cost": { - "input": 0.719, - "output": 3.49, - "cache_read": 0.149, + "input": 0.75, + "output": 3.5, + "cache_read": 0.16, "cache_write": 0 }, "type": "chat" @@ -37554,6 +37554,42 @@ }, "type": "chat" }, + { + "id": "k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "k2p6", "name": "Kimi K2.6", @@ -40434,6 +40470,43 @@ }, "type": "chat" }, + { + "id": "zai-org/GLM-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "cost": { + "input": 1.4375, + "output": 5.75 + }, + "type": "chat" + }, { "id": "mistralai/Mistral-Medium-3.5-128B", "name": "Mistral Medium 3.5", @@ -75663,6 +75736,47 @@ }, "type": "chat" }, + { + "id": "kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "type": "chat" + }, { "id": "kimi-k2.7-code-highspeed", "name": "Kimi K2.7 Code Highspeed", @@ -120641,6 +120755,62 @@ } ] }, + "thinkingmachines": { + "id": "thinkingmachines", + "name": "Thinking Machines", + "display_name": "Thinking Machines", + "api": "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1", + "doc": "https://tinker-docs.thinkingmachines.ai/tinker/compatible-apis/openai/", + "models": [ + { + "id": "inkling", + "name": "Inkling", + "display_name": "Inkling", + "modalities": { + "input": [ + "text", + "image", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-15", + "last_updated": "2026-07-15", + "cost": { + "input": 3.74, + "output": 9.36, + "cache_read": 0.748 + }, + "type": "chat" + } + ] + }, "nebius": { "id": "nebius", "name": "Nebius Token Factory", @@ -129318,54 +129488,9 @@ "type": "chat" }, { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "display_name": "DeepSeek V4 Flash", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 384000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-04-24", - "last_updated": "2026-04-24", - "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.0028 - }, - "type": "chat" - }, - { - "id": "qwen3.5-plus", - "name": "Qwen3.5 Plus", - "display_name": "Qwen3.5 Plus", + "id": "kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", "modalities": { "input": [ "text", @@ -129377,8 +129502,8 @@ ] }, "limit": { - "context": 262144, - "output": 65536 + "context": 1048576, + "output": 131072 }, "temperature": true, "tool_call": true, @@ -129398,22 +129523,20 @@ } }, "attachment": true, - "open_weights": false, - "knowledge": "2025-04", - "release_date": "2026-02-16", - "last_updated": "2026-02-16", + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", "cost": { - "input": 0.2, - "output": 1.2, - "cache_read": 0.02, - "cache_write": 0.25 + "input": 3, + "output": 15, + "cache_read": 0.3 }, "type": "chat" }, { - "id": "mimo-v2-pro", - "name": "MiMo V2 Pro", - "display_name": "MiMo V2 Pro", + "id": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "display_name": "DeepSeek V4 Flash", "modalities": { "input": [ "text" @@ -129423,8 +129546,8 @@ ] }, "limit": { - "context": 1048576, - "output": 128000 + "context": 1000000, + "output": 384000 }, "temperature": true, "tool_call": true, @@ -129443,38 +129566,186 @@ ] } }, - "attachment": true, + "attachment": false, "open_weights": true, - "knowledge": "2024-12", - "release_date": "2026-03-18", - "last_updated": "2026-03-18", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "cost": { - "input": 1, - "output": 3, - "cache_read": 0.2, - "tiers": [ - { - "input": 2, - "output": 6, - "cache_read": 0.4, - "tier": { - "type": "context", - "size": 256000 - } - } - ], - "context_over_200k": { - "input": 2, - "output": 6, - "cache_read": 0.4 - } + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "type": "chat" }, { - "id": "kimi-k2.6", - "name": "Kimi K2.6", - "display_name": "Kimi K2.6", + "id": "qwen3.5-plus", + "name": "Qwen3.5 Plus", + "display_name": "Qwen3.5 Plus", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-04", + "release_date": "2026-02-16", + "last_updated": "2026-02-16", + "cost": { + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write": 0.25 + }, + "type": "chat" + }, + { + "id": "grok-4.5", + "name": "Grok 4.5", + "display_name": "Grok 4.5", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 500000, + "output": 500000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } + }, + "type": "chat" + }, + { + "id": "mimo-v2-pro", + "name": "MiMo V2 Pro", + "display_name": "MiMo V2 Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "cost": { + "input": 1, + "output": 3, + "cache_read": 0.2, + "tiers": [ + { + "input": 2, + "output": 6, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 2, + "output": 6, + "cache_read": 0.4 + } + }, + "type": "chat" + }, + { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "display_name": "Kimi K2.6", "modalities": { "input": [ "text", @@ -156277,6 +156548,47 @@ }, "type": "chat" }, + { + "id": "kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "type": "chat" + }, { "id": "kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -158404,8 +158716,8 @@ ] }, "limit": { - "context": 1048576, - "output": 1048576 + "context": 256000, + "output": 256000 }, "temperature": true, "tool_call": true, @@ -175414,8 +175726,7 @@ "display_name": "MiMo V2.5 Pro", "modalities": { "input": [ - "text", - "pdf" + "text" ], "output": [ "text" @@ -176649,36 +176960,44 @@ "type": "chat" }, { - "id": "recraft/recraft-v4.1-utility", - "name": "Recraft V4.1 Utility", - "display_name": "Recraft V4.1 Utility", + "id": "thinkingmachines/inkling", + "name": "Inkling", + "display_name": "Inkling", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ - "image" + "text" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 256000, + "output": 256000 }, "temperature": true, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-15", + "last_updated": "2026-07-15", + "cost": { + "input": 1, + "output": 4.05, + "cache_read": 0.17 }, - "attachment": false, - "open_weights": false, - "release_date": "2026-05-14", - "last_updated": "2026-05-14", "type": "chat" }, { - "id": "recraft/recraft-v4", - "name": "Recraft V4", - "display_name": "Recraft V4", + "id": "recraft/recraft-v4.1-utility", + "name": "Recraft V4.1 Utility", + "display_name": "Recraft V4.1 Utility", "modalities": { "input": [ "text" @@ -176698,14 +177017,14 @@ }, "attachment": false, "open_weights": false, - "release_date": "2026-02-17", - "last_updated": "2026-02-17", + "release_date": "2026-05-14", + "last_updated": "2026-05-14", "type": "chat" }, { - "id": "recraft/recraft-v4.1-utility-pro", - "name": "Recraft V4.1 Utility Pro", - "display_name": "Recraft V4.1 Utility Pro", + "id": "recraft/recraft-v4", + "name": "Recraft V4", + "display_name": "Recraft V4", "modalities": { "input": [ "text" @@ -176725,14 +177044,41 @@ }, "attachment": false, "open_weights": false, - "release_date": "2026-05-14", - "last_updated": "2026-05-14", + "release_date": "2026-02-17", + "last_updated": "2026-02-17", "type": "chat" }, { - "id": "recraft/recraft-v4.1-pro", - "name": "Recraft V4.1 Pro", - "display_name": "Recraft V4.1 Pro", + "id": "recraft/recraft-v4.1-utility-pro", + "name": "Recraft V4.1 Utility Pro", + "display_name": "Recraft V4.1 Utility Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-14", + "last_updated": "2026-05-14", + "type": "chat" + }, + { + "id": "recraft/recraft-v4.1-pro", + "name": "Recraft V4.1 Pro", + "display_name": "Recraft V4.1 Pro", "modalities": { "input": [ "text" @@ -177673,6 +178019,69 @@ }, "type": "chat" }, + { + "id": "anthropic/claude-opus-4.8-fast", + "name": "Claude Opus 4.8 (Fast)", + "display_name": "Claude Opus 4.8 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "type": "chat" + }, { "id": "anthropic/claude-opus-4.8", "name": "Claude Opus 4.8", @@ -178202,6 +178611,69 @@ }, "type": "chat" }, + { + "id": "anthropic/claude-opus-4.7-fast", + "name": "Claude Opus 4.7 (Fast)", + "display_name": "Claude Opus 4.7 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "cost": { + "input": 30, + "output": 150, + "cache_read": 3, + "cache_write": 37.5 + }, + "type": "chat" + }, { "id": "anthropic/claude-opus-4.1", "name": "Claude Opus 4.1", @@ -179021,6 +179493,46 @@ }, "type": "chat" }, + { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "type": "chat" + }, { "id": "moonshotai/kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -183597,6 +184109,33 @@ "last_updated": "2024-01-25", "type": "embedding" }, + { + "id": "openai/gpt-realtime-whisper", + "name": "gpt-realtime-whisper", + "display_name": "gpt-realtime-whisper", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-07", + "last_updated": "2026-05-07", + "type": "chat" + }, { "id": "openai/gpt-5.1-thinking", "name": "GPT 5.1 Thinking", @@ -226933,6 +227472,25 @@ "name": "PPInfra", "display_name": "PPInfra", "models": [ + { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "limit": { + "context": 1048576 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "zai-org/glm-5.2", "name": "GLM 5.2", @@ -234348,6 +234906,38 @@ }, "type": "chat" }, + { + "id": "kimi-k3", + "name": "kimi-k3", + "display_name": "kimi-k3", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "type": "chat" + }, { "id": "claude-sonnet-5", "name": "claude-sonnet-5", @@ -234835,6 +235425,38 @@ }, "type": "chat" }, + { + "id": "coding-kimi-k3", + "name": "coding-kimi-k3", + "display_name": "coding-kimi-k3", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.12, + "output": 0.439999, + "cache_read": 0.012 + }, + "type": "chat" + }, { "id": "coding-glm-5.2-free", "name": "coding-glm-5.2-free", @@ -234866,20 +235488,21 @@ "type": "chat" }, { - "id": "gemini-3.1-flash-image", - "name": "gemini-3.1-flash-image", - "display_name": "gemini-3.1-flash-image", + "id": "coding-kimi-k3-free", + "name": "coding-kimi-k3-free", + "display_name": "coding-kimi-k3-free", "modalities": { "input": [ "text", - "image" + "image", + "video" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 1048576, + "output": 1048576 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": true, "default": true @@ -234890,11 +235513,11 @@ } }, "cost": { - "input": 0.5, - "output": 3, - "cache_read": 0.5 + "input": 0, + "output": 0, + "cache_read": 0 }, - "type": "imageGeneration" + "type": "chat" }, { "id": "kimi-k2.7-code", @@ -234961,34 +235584,9 @@ "type": "chat" }, { - "id": "kling-v3-omni", - "name": "kling-v3-omni", - "display_name": "kling-v3-omni", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "kling-video-o1", - "name": "kling-video-o1", - "display_name": "kling-video-o1", + "id": "gemini-3.1-flash-image", + "name": "gemini-3.1-flash-image", + "display_name": "gemini-3.1-flash-image", "modalities": { "input": [ "text", @@ -235000,30 +235598,6 @@ "output": 8192 }, "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "longcat-2.0", - "name": "longcat-2.0", - "display_name": "longcat-2.0", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, "reasoning": { "supported": true, "default": true @@ -235034,11 +235608,11 @@ } }, "cost": { - "input": 0.7746, - "output": 3.0984, - "cache_read": 0.015492 + "input": 0.5, + "output": 3, + "cache_read": 0.5 }, - "type": "chat" + "type": "imageGeneration" }, { "id": "gemini-3-pro-image", @@ -235145,6 +235719,86 @@ }, "type": "chat" }, + { + "id": "kling-v3-omni", + "name": "kling-v3-omni", + "display_name": "kling-v3-omni", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "kling-video-o1", + "name": "kling-video-o1", + "display_name": "kling-video-o1", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "longcat-2.0", + "name": "longcat-2.0", + "display_name": "longcat-2.0", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.7746, + "output": 3.0984, + "cache_read": 0.015492 + }, + "type": "chat" + }, { "id": "hy3-preview", "name": "hy3-preview", @@ -235463,18 +236117,17 @@ "type": "chat" }, { - "id": "grok-4.3", - "name": "grok-4.3", - "display_name": "grok-4.3", + "id": "coding-glm-5.2", + "name": "coding-glm-5.2", + "display_name": "coding-glm-5.2", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { - "context": 1000000, - "output": 1000000 + "context": 8192, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -235487,24 +236140,24 @@ } }, "cost": { - "input": 1.25, - "output": 2.5, - "cache_read": 0.2 + "input": 0.06, + "output": 0.22 }, "type": "chat" }, { - "id": "coding-glm-5.2", - "name": "coding-glm-5.2", - "display_name": "coding-glm-5.2", + "id": "grok-4.3", + "name": "grok-4.3", + "display_name": "grok-4.3", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { @@ -235517,8 +236170,9 @@ } }, "cost": { - "input": 0.06, - "output": 0.22 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "type": "chat" }, @@ -241877,13 +242531,12 @@ "type": "chat" }, { - "id": "wan2.2-i2v-plus", - "name": "wan2.2-i2v-plus", - "display_name": "wan2.2-i2v-plus", + "id": "wan2.5-t2v-preview", + "name": "wan2.5-t2v-preview", + "display_name": "wan2.5-t2v-preview", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -241901,12 +242554,13 @@ "type": "chat" }, { - "id": "wan2.5-t2v-preview", - "name": "wan2.5-t2v-preview", - "display_name": "wan2.5-t2v-preview", + "id": "wan2.5-i2v-preview", + "name": "wan2.5-i2v-preview", + "display_name": "wan2.5-i2v-preview", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { @@ -241924,9 +242578,9 @@ "type": "chat" }, { - "id": "wan2.5-i2v-preview", - "name": "wan2.5-i2v-preview", - "display_name": "wan2.5-i2v-preview", + "id": "wan2.2-i2v-plus", + "name": "wan2.2-i2v-plus", + "display_name": "wan2.2-i2v-plus", "modalities": { "input": [ "text", @@ -247299,24 +247953,6 @@ }, "type": "chat" }, - { - "id": "tencent/Hunyuan-A13B-Instruct", - "name": "tencent/Hunyuan-A13B-Instruct", - "display_name": "tencent/Hunyuan-A13B-Instruct", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.14, - "output": 0.56 - }, - "type": "chat" - }, { "id": "MiniMaxAI/MiniMax-M1-80k", "name": "MiniMaxAI/MiniMax-M1-80k", @@ -247451,6 +248087,24 @@ }, "type": "chat" }, + { + "id": "tencent/Hunyuan-A13B-Instruct", + "name": "tencent/Hunyuan-A13B-Instruct", + "display_name": "tencent/Hunyuan-A13B-Instruct", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.14, + "output": 0.56 + }, + "type": "chat" + }, { "id": "unsloth/gemma-3-27b-it", "name": "unsloth/gemma-3-27b-it", @@ -247644,24 +248298,6 @@ }, "type": "chat" }, - { - "id": "tencent/Hunyuan-MT-7B", - "name": "tencent/Hunyuan-MT-7B", - "display_name": "tencent/Hunyuan-MT-7B", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "type": "chat" - }, { "id": "BAAI/bge-large-en-v1.5", "name": "BAAI/bge-large-en-v1.5", @@ -247734,6 +248370,24 @@ }, "type": "rerank" }, + { + "id": "tencent/Hunyuan-MT-7B", + "name": "tencent/Hunyuan-MT-7B", + "display_name": "tencent/Hunyuan-MT-7B", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "type": "chat" + }, { "id": "V3", "name": "V3", @@ -253994,9 +254648,14 @@ "type": "chat" }, { - "id": "text-ada-001", - "name": "text-ada-001", - "display_name": "text-ada-001", + "id": "tts-1-hd-1106", + "name": "tts-1-hd-1106", + "display_name": "tts-1-hd-1106", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254006,15 +254665,14 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 - }, - "type": "chat" + "input": 30, + "output": 30 + } }, { - "id": "tts-1", - "name": "tts-1", - "display_name": "tts-1", + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", "modalities": { "input": [ "audio" @@ -254029,14 +254687,19 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 + "input": 30, + "output": 30 } }, { - "id": "text-search-ada-doc-001", - "name": "text-search-ada-doc-001", - "display_name": "text-search-ada-doc-001", + "id": "tts-1-1106", + "name": "tts-1-1106", + "display_name": "tts-1-1106", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254046,15 +254709,14 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 - }, - "type": "chat" + "input": 15, + "output": 15 + } }, { - "id": "tts-1-hd-1106", - "name": "tts-1-hd-1106", - "display_name": "tts-1-hd-1106", + "id": "tts-1", + "name": "tts-1", + "display_name": "tts-1", "modalities": { "input": [ "audio" @@ -254069,14 +254731,14 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 + "input": 15, + "output": 15 } }, { - "id": "text-moderation-stable", - "name": "text-moderation-stable", - "display_name": "text-moderation-stable", + "id": "text-search-ada-doc-001", + "name": "text-search-ada-doc-001", + "display_name": "text-search-ada-doc-001", "limit": { "context": 8192, "output": 8192 @@ -254086,15 +254748,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "text-moderation-latest", - "name": "text-moderation-latest", - "display_name": "text-moderation-latest", + "id": "text-moderation-007", + "name": "text-moderation-007", + "display_name": "text-moderation-007", "limit": { "context": 8192, "output": 8192 @@ -254110,9 +254772,14 @@ "type": "chat" }, { - "id": "text-moderation-007", - "name": "text-moderation-007", - "display_name": "text-moderation-007", + "id": "text-embedding-ada-002", + "name": "text-embedding-ada-002", + "display_name": "text-embedding-ada-002", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254122,18 +254789,18 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", + "id": "text-embedding-3-small", + "name": "text-embedding-3-small", + "display_name": "text-embedding-3-small", "modalities": { "input": [ - "audio" + "text" ] }, "limit": { @@ -254145,14 +254812,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 - } + "input": 0.02, + "output": 0.02 + }, + "type": "embedding" }, { - "id": "text-embedding-v1", - "name": "text-embedding-v1", - "display_name": "text-embedding-v1", + "id": "text-embedding-3-large", + "name": "text-embedding-3-large", + "display_name": "text-embedding-3-large", "modalities": { "input": [ "text" @@ -254167,20 +254835,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.13, + "output": 0.13 }, "type": "embedding" }, { - "id": "text-embedding-ada-002", - "name": "text-embedding-ada-002", - "display_name": "text-embedding-ada-002", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-moderation-stable", + "name": "text-moderation-stable", + "display_name": "text-moderation-stable", "limit": { "context": 8192, "output": 8192 @@ -254190,20 +254853,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.2, + "output": 0.2 }, - "type": "embedding" + "type": "chat" }, { - "id": "text-embedding-3-small", - "name": "text-embedding-3-small", - "display_name": "text-embedding-3-small", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-edit-001", + "name": "text-davinci-edit-001", + "display_name": "text-davinci-edit-001", "limit": { "context": 8192, "output": 8192 @@ -254213,10 +254871,10 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 20, + "output": 20 }, - "type": "embedding" + "type": "chat" }, { "id": "whisper-1", @@ -254288,14 +254946,9 @@ "type": "chat" }, { - "id": "text-embedding-3-large", - "name": "text-embedding-3-large", - "display_name": "text-embedding-3-large", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-003", + "name": "text-davinci-003", + "display_name": "text-davinci-003", "limit": { "context": 8192, "output": 8192 @@ -254305,20 +254958,15 @@ "supported": false }, "cost": { - "input": 0.13, - "output": 0.13 + "input": 20, + "output": 20 }, - "type": "embedding" + "type": "chat" }, { - "id": "tts-1-1106", - "name": "tts-1-1106", - "display_name": "tts-1-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "text-davinci-002", + "name": "text-davinci-002", + "display_name": "text-davinci-002", "limit": { "context": 8192, "output": 8192 @@ -254328,14 +254976,15 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 - } + "input": 20, + "output": 20 + }, + "type": "chat" }, { - "id": "text-davinci-edit-001", - "name": "text-davinci-edit-001", - "display_name": "text-davinci-edit-001", + "id": "text-curie-001", + "name": "text-curie-001", + "display_name": "text-curie-001", "limit": { "context": 8192, "output": 8192 @@ -254345,15 +254994,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "text-davinci-003", - "name": "text-davinci-003", - "display_name": "text-davinci-003", + "id": "text-babbage-001", + "name": "text-babbage-001", + "display_name": "text-babbage-001", "limit": { "context": 8192, "output": 8192 @@ -254363,15 +255012,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "text-davinci-002", - "name": "text-davinci-002", - "display_name": "text-davinci-002", + "id": "text-ada-001", + "name": "text-ada-001", + "display_name": "text-ada-001", "limit": { "context": 8192, "output": 8192 @@ -254381,15 +255030,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "text-curie-001", - "name": "text-curie-001", - "display_name": "text-curie-001", + "id": "text-moderation-latest", + "name": "text-moderation-latest", + "display_name": "text-moderation-latest", "limit": { "context": 8192, "output": 8192 @@ -254399,8 +255048,8 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, @@ -254513,9 +255162,14 @@ "type": "chat" }, { - "id": "text-babbage-001", - "name": "text-babbage-001", - "display_name": "text-babbage-001", + "id": "text-embedding-v1", + "name": "text-embedding-v1", + "display_name": "text-embedding-v1", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254525,10 +255179,10 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { "id": "meta-llama-3-70b", @@ -257185,8 +257839,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 110000, + "output": 110000 }, "tool_call": true, "reasoning": { @@ -257782,8 +258436,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 80000, + "output": 80000 }, "tool_call": false, "reasoning": { @@ -257926,6 +258580,33 @@ }, "type": "imageGeneration" }, + { + "id": "meta/muse-spark-1.1", + "name": "Meta: Muse Spark 1.1", + "display_name": "Meta: Muse Spark 1.1", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "type": "imageGeneration" + }, { "id": "microsoft/phi-4", "name": "Microsoft: Phi 4", @@ -258139,7 +258820,7 @@ ] }, "limit": { - "context": 204800, + "context": 196608, "output": 131072 }, "temperature": true, @@ -258821,6 +259502,35 @@ }, "type": "imageGeneration" }, + { + "id": "moonshotai/kimi-k3", + "name": "MoonshotAI: Kimi K3", + "display_name": "MoonshotAI: Kimi K3", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "imageGeneration" + }, { "id": "morph/morph-v3-fast", "name": "Morph: Morph V3 Fast", @@ -264548,6 +265258,36 @@ }, "type": "chat" }, + { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "gpt-5.6-terra", "name": "gpt-5.6-terra", @@ -272599,9 +273339,9 @@ "release_date": "2026-06-12", "last_updated": "2026-06-12", "cost": { - "input": 0.719, - "output": 3.49, - "cache_read": 0.149, + "input": 0.75, + "output": 3.5, + "cache_read": 0.16, "cache_write": 0 }, "type": "chat" @@ -272652,6 +273392,46 @@ }, "type": "chat" }, + { + "id": "moonshotai/kimi-k3", + "name": "MoonshotAI: Kimi K3", + "display_name": "MoonshotAI: Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "type": "chat" + }, { "id": "openai/chat-latest", "name": "OpenAI: Chat Latest (GPT-5.5 Instant)", @@ -274976,9 +275756,9 @@ "release_date": "2026-05-29", "last_updated": "2026-05-29", "cost": { - "input": 0.2, - "output": 1.15, - "cache_read": 0.04, + "input": 0.19, + "output": 1.14, + "cache_read": 0.03, "cache_write": 0 }, "type": "chat" @@ -275013,9 +275793,9 @@ "release_date": "2026-05-29", "last_updated": "2026-05-29", "cost": { - "input": 0.2, - "output": 1.15, - "cache_read": 0.04, + "input": 0.19, + "output": 1.14, + "cache_read": 0.03, "cache_write": 0 }, "type": "chat" diff --git a/src/main/agent/deepchat/runtime/dispatch.ts b/src/main/agent/deepchat/runtime/dispatch.ts index 9d41f7cf85..efc2d715ee 100644 --- a/src/main/agent/deepchat/runtime/dispatch.ts +++ b/src/main/agent/deepchat/runtime/dispatch.ts @@ -1345,6 +1345,7 @@ async function runToolCall(params: { } const enabledMcpServerIds = controls?.getEnabledMcpServerIds?.() const result = await toolExecution.execute(toolCall, { + runId: io.requestId, onProgress: applyProgressUpdate, signal: io.abortSignal, permissionMode: toolPermissionMode, diff --git a/src/main/desktop/browser/BrowserProfileImportService.ts b/src/main/desktop/browser/BrowserProfileImportService.ts new file mode 100644 index 0000000000..f17a4ec2ee --- /dev/null +++ b/src/main/desktop/browser/BrowserProfileImportService.ts @@ -0,0 +1,484 @@ +import { createDecipheriv, createHash, pbkdf2Sync } from 'crypto' +import { execFile } from 'child_process' +import { chmod, mkdtemp, readFile, readdir, rm, stat } from 'fs/promises' +import { homedir, tmpdir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import type { CookiesSetDetails, Session } from 'electron' +import Database from 'better-sqlite3-multiple-ciphers' +import { nanoid } from 'nanoid' +import type { + BrowserImportApplyResult, + BrowserImportPreview, + BrowserImportProfile, + BrowserImportScanResult +} from '@shared/types/browser' + +const execFileAsync = promisify(execFile) +const STAGE_TTL_MS = 5 * 60 * 1000 +const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 + +type BrowserSource = { + id: BrowserImportProfile['browser'] + name: string + userDataDirectory: string + keychainService: string +} + +const BROWSER_SOURCES: BrowserSource[] = [ + { + id: 'chrome', + name: 'Google Chrome', + userDataDirectory: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'), + keychainService: 'Chrome Safe Storage' + }, + { + id: 'arc', + name: 'Arc', + userDataDirectory: join(homedir(), 'Library', 'Application Support', 'Arc', 'User Data'), + keychainService: 'Arc Safe Storage' + } +] + +type ChromeCookieRow = { + host_key: string + top_frame_site_key: string + name: string + value: string + encrypted_value: Buffer + path: string + expires_utc: number + is_secure: number + is_httponly: number + samesite: number +} + +type DiscoveredProfile = BrowserImportProfile & { + source: BrowserSource + directoryName: string + cookiePath: string +} + +type StagedImport = { + createdAt: number + profile: DiscoveredProfile + sourceSize: number + sourceMtimeMs: number + cookies: CookiesSetDetails[] + skippedExpired: number + skippedPartitioned: number +} + +export class BrowserProfileImportService { + private readonly stagedImports = new Map() + private readonly stageExpiryTimers = new Map() + private applying = false + + constructor( + private readonly getTargetSession: () => Session, + private readonly getTargetUnpartitionedCookies: () => Promise, + private readonly platform: NodeJS.Platform = process.platform + ) {} + + async scan(): Promise { + if (this.platform !== 'darwin') { + return { + platformSupported: false, + profiles: [], + reason: 'platform_unsupported' + } + } + + const profiles = await this.discoverChromeProfiles() + return { + platformSupported: true, + profiles: profiles.map(this.toPublicProfile), + ...(profiles.length === 0 ? { reason: 'browser_not_found' as const } : {}) + } + } + + async preview(profileId: string): Promise { + if (this.platform !== 'darwin') { + throw new Error('browser_import_platform_unsupported') + } + this.removeExpiredStages() + const profile = (await this.discoverChromeProfiles()).find((item) => item.id === profileId) + if (!profile || !profile.supported) { + throw new Error('browser_import_profile_unavailable') + } + + const sourceStat = await stat(profile.cookiePath) + const password = await this.readSafeStoragePassword(profile.source.keychainService) + const temporaryDirectory = await mkdtemp(join(tmpdir(), 'deepchat-browser-import-')) + await chmod(temporaryDirectory, 0o700) + const snapshotPath = join(temporaryDirectory, 'Cookies') + + try { + const sourceDatabase = new Database(profile.cookiePath, { + readonly: true, + fileMustExist: true + }) + try { + await sourceDatabase.backup(snapshotPath) + } finally { + sourceDatabase.close() + } + + const decoded = this.readCookieSnapshot(snapshotPath, password) + const token = nanoid(24) + this.stagedImports.set(token, { + createdAt: Date.now(), + profile, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + cookies: decoded.cookies, + skippedExpired: decoded.skippedExpired, + skippedPartitioned: decoded.skippedPartitioned + }) + const expiryTimer = setTimeout(() => this.deleteStage(token), STAGE_TTL_MS) + expiryTimer.unref() + this.stageExpiryTimers.set(token, expiryTimer) + + return { + token, + profile: this.toPublicProfile(profile), + cookieCount: decoded.cookies.length, + skippedExpired: decoded.skippedExpired, + skippedPartitioned: decoded.skippedPartitioned + } + } finally { + password.fill(0) + await rm(temporaryDirectory, { recursive: true, force: true }) + } + } + + async apply(token: string): Promise { + if (this.applying) { + throw new Error('browser_import_already_running') + } + + this.removeExpiredStages() + const staged = this.stagedImports.get(token) + if (!staged) { + throw new Error('browser_import_preview_expired') + } + + const sourceStat = await stat(staged.profile.cookiePath) + if (sourceStat.size !== staged.sourceSize || sourceStat.mtimeMs !== staged.sourceMtimeMs) { + this.deleteStage(token) + throw new Error('browser_import_source_changed') + } + + this.applying = true + try { + const target = this.getTargetSession() + const rollbackCookies = await this.getTargetUnpartitionedCookies() + + try { + await this.removeCookies(target, rollbackCookies) + await this.setCookies(target, staged.cookies) + await target.cookies.flushStore() + await this.verifyCookies(staged.cookies) + } catch (error) { + await this.removeCookies(target, await this.getTargetUnpartitionedCookies()) + await this.setCookies(target, rollbackCookies) + await target.cookies.flushStore() + throw error + } + } finally { + this.deleteStage(token) + this.applying = false + } + + return { + importedCookies: staged.cookies.length, + skippedExpired: staged.skippedExpired, + skippedPartitioned: staged.skippedPartitioned, + syncedAt: Date.now() + } + } + + private async discoverChromeProfiles(): Promise { + const profiles: DiscoveredProfile[] = [] + for (const source of BROWSER_SOURCES) { + let entries: Array<{ name: string; isDirectory(): boolean }> + try { + entries = await readdir(source.userDataDirectory, { withFileTypes: true }) + } catch { + continue + } + + const profileNames = await this.readProfileNames(source) + for (const entry of entries) { + if (!entry.isDirectory() || !/^(Default|Profile \d+)$/.test(entry.name)) { + continue + } + + const profileDirectory = join(source.userDataDirectory, entry.name) + const networkCookiePath = join(profileDirectory, 'Network', 'Cookies') + const legacyCookiePath = join(profileDirectory, 'Cookies') + const cookiePath = await this.firstExistingPath([networkCookiePath, legacyCookiePath]) + if (!cookiePath) { + continue + } + + profiles.push({ + id: `${source.id}:${entry.name}`, + browser: source.id, + browserName: source.name, + profileName: profileNames.get(entry.name) || entry.name, + supported: true, + source, + directoryName: entry.name, + cookiePath + }) + } + } + + return profiles.sort((left, right) => left.id.localeCompare(right.id)) + } + + private async readProfileNames(source: BrowserSource): Promise> { + const result = new Map() + try { + const contents = await readFile(join(source.userDataDirectory, 'Local State'), 'utf8') + const parsed = JSON.parse(contents) as { + profile?: { info_cache?: Record } + } + for (const [directoryName, value] of Object.entries(parsed.profile?.info_cache ?? {})) { + if (typeof value.name === 'string' && value.name.trim()) { + result.set(directoryName, value.name.trim()) + } + } + } catch { + // Directory names remain usable when Local State is absent or malformed. + } + return result + } + + private async firstExistingPath(paths: string[]): Promise { + for (const path of paths) { + try { + if ((await stat(path)).isFile()) { + return path + } + } catch { + // Continue to the next known path. + } + } + return null + } + + private async readSafeStoragePassword(keychainService: string): Promise { + try { + const { stdout } = await execFileAsync('/usr/bin/security', [ + 'find-generic-password', + '-w', + '-s', + keychainService + ]) + const password = stdout.trim() + if (!password) { + throw new Error('empty keychain result') + } + return Buffer.from(password, 'utf8') + } catch { + throw new Error('browser_import_key_access_denied') + } + } + + private readCookieSnapshot( + snapshotPath: string, + password: Buffer + ): { + cookies: CookiesSetDetails[] + skippedExpired: number + skippedPartitioned: number + } { + const database = new Database(snapshotPath, { readonly: true, fileMustExist: true }) + try { + const integrity = database.pragma('integrity_check', { simple: true }) + if (integrity !== 'ok') { + throw new Error('browser_import_source_snapshot_invalid') + } + + const schemaVersion = Number( + ( + database.prepare("SELECT value FROM meta WHERE key = 'version'").get() as + | { value?: unknown } + | undefined + )?.value ?? 0 + ) + const cookieColumns = database.prepare('PRAGMA table_info(cookies)').all() as Array<{ + name: string + }> + const hasTopFrameSiteKey = cookieColumns.some( + (column) => column.name === 'top_frame_site_key' + ) + const rows = database + .prepare( + `SELECT host_key, ${hasTopFrameSiteKey ? 'top_frame_site_key' : "'' AS top_frame_site_key"}, name, value, + CAST(encrypted_value AS BLOB) AS encrypted_value, + path, expires_utc, is_secure, is_httponly, samesite + FROM cookies` + ) + .all() as ChromeCookieRow[] + + const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1') + const cookies: CookiesSetDetails[] = [] + let skippedExpired = 0 + let skippedPartitioned = 0 + const nowSeconds = Date.now() / 1000 + + try { + for (const row of rows) { + const expirationDate = this.chromeTimeToUnixSeconds(row.expires_utc) + if (expirationDate !== undefined && expirationDate <= nowSeconds) { + skippedExpired += 1 + continue + } + if (row.top_frame_site_key) { + skippedPartitioned += 1 + continue + } + + const value = row.value || this.decryptChromeCookie(row, key, schemaVersion) + cookies.push(this.rowToCookieDetails(row, value, expirationDate)) + } + } finally { + key.fill(0) + } + + return { cookies, skippedExpired, skippedPartitioned } + } finally { + database.close() + } + } + + private decryptChromeCookie(row: ChromeCookieRow, key: Buffer, schemaVersion: number): string { + if (!Buffer.isBuffer(row.encrypted_value) || row.encrypted_value.length <= 3) { + return '' + } + if (row.encrypted_value.subarray(0, 3).toString('ascii') !== 'v10') { + throw new Error('browser_import_encryption_unsupported') + } + + const decipher = createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20)) + const plaintext = Buffer.concat([ + decipher.update(row.encrypted_value.subarray(3)), + decipher.final() + ]) + const value = schemaVersion >= 24 ? this.removeHostDigest(row.host_key, plaintext) : plaintext + return value.toString('utf8') + } + + private removeHostDigest(host: string, plaintext: Buffer): Buffer { + if (plaintext.length < 32) { + throw new Error('browser_import_cookie_digest_missing') + } + const expected = createHash('sha256').update(host).digest() + if (!plaintext.subarray(0, 32).equals(expected)) { + throw new Error('browser_import_cookie_digest_invalid') + } + return plaintext.subarray(32) + } + + private rowToCookieDetails( + row: ChromeCookieRow, + value: string, + expirationDate?: number + ): CookiesSetDetails { + const hostname = row.host_key.replace(/^\./, '') + const secure = Boolean(row.is_secure) + return { + url: `${secure ? 'https' : 'http'}://${hostname}${row.path || '/'}`, + name: row.name, + value, + path: row.path || '/', + secure, + httpOnly: Boolean(row.is_httponly), + sameSite: this.toElectronSameSite(row.samesite), + ...(row.host_key.startsWith('.') ? { domain: row.host_key } : {}), + ...(expirationDate === undefined ? {} : { expirationDate }) + } + } + + private chromeTimeToUnixSeconds(value: number): number | undefined { + if (!Number.isFinite(value) || value <= 0) { + return undefined + } + return value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS + } + + private toElectronSameSite(value: number): CookiesSetDetails['sameSite'] { + if (value === 0) return 'no_restriction' + if (value === 1) return 'lax' + if (value === 2) return 'strict' + return 'unspecified' + } + + private async setCookies(target: Session, cookies: CookiesSetDetails[]): Promise { + const batchSize = 50 + for (let index = 0; index < cookies.length; index += batchSize) { + await Promise.all( + cookies.slice(index, index + batchSize).map((cookie) => target.cookies.set(cookie)) + ) + } + } + + private async removeCookies(target: Session, cookies: CookiesSetDetails[]): Promise { + await this.setCookies( + target, + cookies.map((cookie) => ({ + ...cookie, + value: '', + expirationDate: 1 + })) + ) + } + + private async verifyCookies(expected: CookiesSetDetails[]): Promise { + const actual = await this.getTargetUnpartitionedCookies() + const actualValues = new Map( + actual.map((cookie) => [ + `${cookie.domain ?? new URL(cookie.url).hostname}\u0000${cookie.path ?? '/'}\u0000${cookie.name}`, + cookie.value + ]) + ) + + for (const cookie of expected) { + const domain = cookie.domain ?? new URL(cookie.url).hostname + const identity = `${domain}\u0000${cookie.path ?? '/'}\u0000${cookie.name}` + if (actualValues.get(identity) !== cookie.value) { + throw new Error('browser_import_verification_failed') + } + } + } + + private removeExpiredStages(): void { + const now = Date.now() + for (const [token, staged] of this.stagedImports) { + if (now - staged.createdAt > STAGE_TTL_MS) { + this.deleteStage(token) + } + } + } + + private deleteStage(token: string): void { + this.stagedImports.delete(token) + const timer = this.stageExpiryTimers.get(token) + if (timer) clearTimeout(timer) + this.stageExpiryTimers.delete(token) + } + + private toPublicProfile(profile: DiscoveredProfile): BrowserImportProfile { + return { + id: profile.id, + browser: profile.browser, + browserName: profile.browserName, + profileName: profile.profileName, + supported: profile.supported, + ...(profile.reason ? { reason: profile.reason } : {}) + } + } +} diff --git a/src/main/desktop/browser/YoBrowserPresenter.ts b/src/main/desktop/browser/YoBrowserPresenter.ts index fb2eff808c..e11f50beac 100644 --- a/src/main/desktop/browser/YoBrowserPresenter.ts +++ b/src/main/desktop/browser/YoBrowserPresenter.ts @@ -1,8 +1,9 @@ -import { BrowserWindow, WebContents, WebContentsView } from 'electron' +import { BaseWindow, BrowserWindow, WebContents, WebContentsView } from 'electron' import type { Rectangle } from 'electron' import { is } from '@electron-toolkit/utils' import { nanoid } from 'nanoid' -import type { DeepchatEventPublisher } from '@shared/contracts/events' +import { DEEPCHAT_EVENT_CHANNEL } from '@shared/contracts/channels' +import { createDeepchatEventEnvelope, type DeepchatEventPublisher } from '@shared/contracts/events' import logger from '@shared/logger' import { BrowserPageStatus, @@ -19,10 +20,15 @@ import { import type { DownloadInfo } from '@shared/types/browser' import type { IWindowPresenter, IYoBrowserPresenter } from '@shared/types/desktop' import { BrowserTab as BrowserPage } from './BrowserTab' +import { BrowserProfileImportService } from './BrowserProfileImportService' import { CDPManager } from './CDPManager' import { DownloadManager } from './DownloadManager' import { ScreenshotManager } from './ScreenshotManager' -import { clearYoBrowserSessionData, getYoBrowserSession } from './yoBrowserSession' +import { + clearYoBrowserSessionData, + getYoBrowserSession, + getYoBrowserUnpartitionedCookies +} from './yoBrowserSession' import { YoBrowserOverlayWindow } from './YoBrowserOverlayWindow' import { YoBrowserToolHandler } from './YoBrowserToolHandler' @@ -46,6 +52,16 @@ type SessionBrowserState = { visible: boolean attachedWindowId: number | null lastBounds: Rectangle | null + owner: 'agent' | 'user' + agentRunId?: string + previewHost: BaseWindow | null + previewMode: 'capturing' | 'rendering' | 'stopped' + previewTargetWindowId: number | null + previewTimer: ReturnType | null + previewCapture: Promise | null + previewEpoch: number + previewSequence: number + previewBurstUntil: number } type HostWindowListeners = { @@ -58,12 +74,23 @@ type HostWindowListeners = { closed: () => void } +const PREVIEW_VIEWPORT = { width: 1280, height: 800 } +const PREVIEW_FRAME = { width: 480, height: 300 } +const PREVIEW_ACTIVE_INTERVAL_MS = 250 +const PREVIEW_IDLE_INTERVAL_MS = 1000 +const PREVIEW_MAX_BYTES = 512 * 1024 + export class YoBrowserPresenter implements IYoBrowserPresenter { private readonly sessionBrowsers = new Map() private readonly hostWindowListeners = new Map() private readonly cdpManager = new CDPManager() private readonly screenshotManager = new ScreenshotManager(this.cdpManager) private readonly downloadManager = new DownloadManager() + private readonly profileImportService = new BrowserProfileImportService( + getYoBrowserSession, + getYoBrowserUnpartitionedCookies + ) + private browserDataMutationActive = false private readonly windowPresenter: IWindowPresenter readonly toolHandler: YoBrowserToolHandler @@ -88,7 +115,8 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { url: string, timeoutMs?: number, hostWindowId?: number, - activitySource?: YoBrowserActivitySource + activitySource?: YoBrowserActivitySource, + agentRunId?: string ): Promise { const normalizedSessionId = sessionId.trim() if (!normalizedSessionId) { @@ -104,13 +132,25 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } const state = this.ensureSessionBrowserState(normalizedSessionId) + this.updateOwner(state, activitySource, agentRunId) + if (activitySource === 'agent' && !state.visible) { + this.ensurePreviewHost(state) + } else if (activitySource !== 'agent' && state.previewHost) { + await this.releasePreviewHost(state) + } this.logLifecycle('open requested', { sessionId: normalizedSessionId, windowId: resolvedHostWindowId, url }) - this.emitOpenRequested(normalizedSessionId, resolvedHostWindowId, url) + this.emitOpenRequested( + normalizedSessionId, + resolvedHostWindowId, + url, + activitySource ?? 'user', + state.agentRunId + ) const navigate = () => state.page.navigateUntilDomReady(url, timeoutMs ?? 30000) if (activitySource === 'agent') { @@ -139,6 +179,8 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return false } + await this.releasePreviewHost(state) + this.detachOtherSessionBrowsers(hostWindowId, sessionId) if (state.attachedWindowId != null && state.attachedWindowId !== hostWindowId) { @@ -161,7 +203,6 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { this.attachHostWindowListeners(hostWindowId) state.attachedWindowId = hostWindowId state.updatedAt = Date.now() - this.preserveHostWebContentsFocus(hostWindow) this.emitWindowUpdated(sessionId) return true } @@ -177,11 +218,13 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return } - const normalizedBounds = this.normalizeBounds(bounds) + const hostWindow = BrowserWindow.fromId(hostWindowId) + const normalizedBounds = this.normalizeBounds(bounds, hostWindow) state.lastBounds = normalizedBounds state.updatedAt = Date.now() if (!visible || normalizedBounds.width <= 0 || normalizedBounds.height <= 0) { + state.view.setVisible(false) this.setSessionVisibility(state, false) return } @@ -194,10 +237,9 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } state.view.setBounds(normalizedBounds) - const hostWindow = BrowserWindow.fromId(hostWindowId) + state.view.setVisible(true) if (hostWindow && !hostWindow.isDestroyed() && hostWindow.isFocused()) { await state.overlay.updateBounds(hostWindow, normalizedBounds, true) - this.preserveHostWebContentsFocus(hostWindow) } this.setSessionVisibility(state, true) } @@ -213,12 +255,62 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { this.setSessionVisibility(state, false) } + async setPreviewMode( + sessionId: string, + mode: 'capturing' | 'rendering' | 'stopped', + hostWindowId?: number, + runId?: string + ): Promise { + const state = this.sessionBrowsers.get(sessionId) + if (!state) { + return false + } + + if (mode === 'stopped') { + if (runId != null && state.agentRunId != null && runId !== state.agentRunId) { + return false + } + await this.releasePreviewHost(state) + return true + } + + if ( + state.owner !== 'agent' || + !state.agentRunId || + (runId != null && runId !== state.agentRunId) || + state.visible + ) { + return false + } + + if (mode === 'capturing') { + const targetWindow = hostWindowId == null ? null : BrowserWindow.fromId(hostWindowId) + if (!targetWindow || targetWindow.isDestroyed()) { + return false + } + } + + await this.stopPreviewCapture(state) + if (!this.ensurePreviewHost(state)) { + return false + } + state.previewMode = mode + state.previewTargetWindowId = hostWindowId ?? null + state.previewEpoch += 1 + + if (mode === 'capturing') { + this.schedulePreviewCapture(state, 0, state.previewEpoch) + } + return true + } + async destroySessionBrowser(sessionId: string): Promise { const state = this.sessionBrowsers.get(sessionId) if (!state) { return } + await this.releasePreviewHost(state) await this.detachSessionBrowser(sessionId) state.page.destroy() state.overlay.destroy() @@ -312,13 +404,26 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { sessionId: string, method: string, params?: Record, - activitySource?: YoBrowserActivitySource + activitySource?: YoBrowserActivitySource, + agentRunId?: string ): Promise { const state = this.sessionBrowsers.get(sessionId) if (!state) { throw new Error(`Session browser ${sessionId} is not initialized`) } + if (activitySource === 'agent') { + this.updateOwner(state, activitySource, agentRunId) + if (!state.visible) { + this.ensurePreviewHost(state) + } + const windowId = state.attachedWindowId ?? this.resolveHostWindowId() + if (windowId != null) { + this.emitOpenRequested(sessionId, windowId, state.page.url, 'agent', state.agentRunId) + } + this.emitWindowUpdated(sessionId) + } + const descriptor = this.describeCdpActivity(method, params) if (activitySource === 'agent' && descriptor) { return await this.runAgentActivity(sessionId, descriptor, () => @@ -338,12 +443,34 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } async clearSandboxData(): Promise { - await clearYoBrowserSessionData() - for (const state of this.sessionBrowsers.values()) { - if (!state.page.contents.isDestroyed()) { - state.page.contents.reloadIgnoringCache() + await this.runBrowserDataMutation(async () => { + await clearYoBrowserSessionData() + for (const state of this.sessionBrowsers.values()) { + if (!state.page.contents.isDestroyed()) { + state.page.contents.reloadIgnoringCache() + } } - } + }) + } + + async scanImportSources() { + return await this.profileImportService.scan() + } + + async previewImport(profileId: string) { + return await this.profileImportService.preview(profileId) + } + + async applyImport(token: string) { + return await this.runBrowserDataMutation(async () => { + const result = await this.profileImportService.apply(token) + for (const state of this.sessionBrowsers.values()) { + if (!state.page.contents.isDestroyed()) { + state.page.contents.reloadIgnoringCache() + } + } + return result + }) } async shutdown(): Promise { @@ -380,7 +507,16 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { updatedAt: now, visible: false, attachedWindowId: null, - lastBounds: null + lastBounds: null, + owner: 'user', + previewHost: null, + previewMode: 'stopped', + previewTargetWindowId: null, + previewTimer: null, + previewCapture: null, + previewEpoch: 0, + previewSequence: 0, + previewBurstUntil: 0 } this.sessionBrowsers.set(sessionId, state) @@ -389,6 +525,18 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return state } + private async runBrowserDataMutation(mutation: () => Promise): Promise { + if (this.browserDataMutationActive) { + throw new Error('browser_data_mutation_in_progress') + } + this.browserDataMutationActive = true + try { + return await mutation() + } finally { + this.browserDataMutationActive = false + } + } + private setupPageListeners(state: SessionBrowserState, contents: WebContents): void { const sessionId = state.sessionId const getState = () => this.sessionBrowsers.get(sessionId) @@ -485,6 +633,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return } + this.disposePreviewHost(state) state.page.destroy() state.overlay.destroy() state.attachedWindowId = null @@ -606,6 +755,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // Ignore already detached view. } } + state.view.setVisible(false) state.attachedWindowId = null state.overlay.hide() } @@ -668,7 +818,9 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { canGoBack: state.page.contents.navigationHistory.canGoBack(), canGoForward: state.page.contents.navigationHistory.canGoForward(), visible: state.visible, - loading: state.page.contents.isLoading() || state.page.status === BrowserPageStatus.Loading + loading: state.page.contents.isLoading() || state.page.status === BrowserPageStatus.Loading, + owner: state.owner, + ...(state.agentRunId ? { agentRunId: state.agentRunId } : {}) } } @@ -702,12 +854,22 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { void state.overlay.updateBounds(hostWindow, state.lastBounds, true) } - private normalizeBounds(bounds: Rectangle): Rectangle { + private normalizeBounds(bounds: Rectangle, hostWindow?: BrowserWindow | null): Rectangle { + const contentBounds = + hostWindow && !hostWindow.isDestroyed() && typeof hostWindow.getContentBounds === 'function' + ? hostWindow.getContentBounds() + : null + const x = Math.max(0, Math.round(bounds.x)) + const y = Math.max(0, Math.round(bounds.y)) + const maxWidth = contentBounds ? Math.max(0, contentBounds.width - x) : Number.MAX_SAFE_INTEGER + const maxHeight = contentBounds + ? Math.max(0, contentBounds.height - y) + : Number.MAX_SAFE_INTEGER return { - x: Math.max(0, Math.round(bounds.x)), - y: Math.max(0, Math.round(bounds.y)), - width: Math.max(0, Math.round(bounds.width)), - height: Math.max(0, Math.round(bounds.height)) + x, + y, + width: Math.min(maxWidth, Math.max(0, Math.round(bounds.width))), + height: Math.min(maxHeight, Math.max(0, Math.round(bounds.height))) } } @@ -732,11 +894,19 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { }) } - private emitOpenRequested(sessionId: string, windowId: number, url: string): void { + private emitOpenRequested( + sessionId: string, + windowId: number, + url: string, + source: 'agent' | 'user', + runId?: string + ): void { const payload = { sessionId, windowId, - url + url, + source, + ...(runId ? { runId } : {}) } this.publishEvent('browser.open.requested', { @@ -745,6 +915,21 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { }) } + private updateOwner( + state: SessionBrowserState, + activitySource?: YoBrowserActivitySource, + agentRunId?: string + ): void { + if (activitySource === 'agent') { + state.owner = 'agent' + state.agentRunId = agentRunId?.trim() || state.agentRunId || nanoid(12) + return + } + + state.owner = 'user' + state.agentRunId = undefined + } + private emitWindowUpdated(sessionId: string): void { const status = this.toStatus(this.sessionBrowsers.get(sessionId) ?? null) this.publishEvent('browser.status.changed', { @@ -812,6 +997,9 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { phase: YoBrowserActivityPayload['phase'] ): void { const state = this.sessionBrowsers.get(sessionId) ?? null + if (state) { + state.previewBurstUntil = Date.now() + 1500 + } const windowId = state?.attachedWindowId ?? this.resolveHostWindowId() ?? null const payload: YoBrowserActivityPayload = { id: activityId, @@ -1057,20 +1245,178 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return undefined } - private preserveHostWebContentsFocus(hostWindow: BrowserWindow): void { - if (hostWindow.isDestroyed() || hostWindow.webContents.isDestroyed()) { + private ensurePreviewHost(state: SessionBrowserState): boolean { + if (state.previewHost && !state.previewHost.isDestroyed()) { + return true + } + + let host: BaseWindow | null = null + try { + host = new BaseWindow({ + x: -10000, + y: -10000, + width: PREVIEW_VIEWPORT.width, + height: PREVIEW_VIEWPORT.height, + show: true, + opacity: 0, + focusable: false, + skipTaskbar: true, + hiddenInMissionControl: true, + frame: false, + transparent: true + }) + host.setIgnoreMouseEvents(true) + if (state.attachedWindowId != null) { + this.detachFromWindow(state, state.attachedWindowId) + this.setSessionVisibility(state, false) + } + host.contentView.addChildView(state.view) + state.view.setBounds({ x: 0, y: 0, ...PREVIEW_VIEWPORT }) + state.view.setVisible(true) + state.page.contents.setBackgroundThrottling(false) + state.previewHost = host + host.once('closed', () => { + if (state.previewHost === host) { + state.previewHost = null + state.previewMode = 'stopped' + state.previewTargetWindowId = null + state.previewEpoch += 1 + state.view.setVisible(false) + if (!state.page.contents.isDestroyed()) { + state.page.contents.setBackgroundThrottling(true) + } + } + }) + return true + } catch (error) { + if (host && !host.isDestroyed()) { + host.destroy() + } + logger.warn('[YoBrowser] Preview render host unavailable', { + sessionId: state.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + private async stopPreviewCapture(state: SessionBrowserState): Promise { + state.previewEpoch += 1 + if (state.previewTimer) { + clearTimeout(state.previewTimer) + state.previewTimer = null + } + const capture = state.previewCapture + if (capture) { + let timeout: ReturnType | null = null + await Promise.race([ + capture.catch(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(resolve, 500) + timeout.unref?.() + }) + ]) + if (timeout) { + clearTimeout(timeout) + } + } + } + + private async releasePreviewHost(state: SessionBrowserState): Promise { + await this.stopPreviewCapture(state) + this.disposePreviewHost(state) + } + + private disposePreviewHost(state: SessionBrowserState): void { + state.previewEpoch += 1 + if (state.previewTimer) { + clearTimeout(state.previewTimer) + state.previewTimer = null + } + state.previewMode = 'stopped' + state.previewTargetWindowId = null + const host = state.previewHost + state.previewHost = null + if (!host || host.isDestroyed()) { return } + try { + host.contentView.removeChildView(state.view) + } catch { + // Ignore already detached views during shutdown. + } + state.view.setVisible(false) + if (!state.page.contents.isDestroyed()) { + state.page.contents.setBackgroundThrottling(true) + } + host.destroy() + } - if (!hostWindow.isFocused()) { + private schedulePreviewCapture(state: SessionBrowserState, delayMs: number, epoch: number): void { + if (state.previewMode !== 'capturing' || state.previewEpoch !== epoch) { return } + state.previewTimer = setTimeout(() => { + state.previewTimer = null + const capture = this.capturePreviewFrame(state, epoch) + state.previewCapture = capture + void capture.finally(() => { + if (state.previewCapture === capture) { + state.previewCapture = null + } + }) + }, delayMs) + state.previewTimer.unref?.() + } - queueMicrotask(() => { - if (hostWindow.isDestroyed() || hostWindow.webContents.isDestroyed()) { + private async capturePreviewFrame(state: SessionBrowserState, epoch: number): Promise { + if ( + state.previewMode !== 'capturing' || + state.previewEpoch !== epoch || + state.previewTargetWindowId == null || + !state.agentRunId || + state.page.contents.isDestroyed() + ) { + return + } + + try { + const image = await state.page.contents.capturePage( + { x: 0, y: 0, ...PREVIEW_VIEWPORT }, + { stayHidden: true } + ) + if (state.previewMode !== 'capturing' || state.previewEpoch !== epoch) { return } - hostWindow.webContents.focus() - }) + const data = image.resize(PREVIEW_FRAME).toJPEG(72) + if (data.byteLength <= PREVIEW_MAX_BYTES) { + state.previewSequence += 1 + this.windowPresenter.sendToWindow( + state.previewTargetWindowId, + DEEPCHAT_EVENT_CHANNEL, + createDeepchatEventEnvelope('browser.preview.frame', { + sessionId: state.sessionId, + runId: state.agentRunId, + sequence: state.previewSequence, + ...PREVIEW_FRAME, + mimeType: 'image/jpeg', + data, + timestamp: Date.now() + }) + ) + } + } catch (error) { + logger.warn('[YoBrowser] Preview capture failed', { + sessionId: state.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + + const active = state.page.contents.isLoading() || Date.now() < state.previewBurstUntil + this.schedulePreviewCapture( + state, + active ? PREVIEW_ACTIVE_INTERVAL_MS : PREVIEW_IDLE_INTERVAL_MS, + epoch + ) } } diff --git a/src/main/desktop/browser/YoBrowserToolHandler.ts b/src/main/desktop/browser/YoBrowserToolHandler.ts index ec241265b1..26133d3e37 100644 --- a/src/main/desktop/browser/YoBrowserToolHandler.ts +++ b/src/main/desktop/browser/YoBrowserToolHandler.ts @@ -22,7 +22,8 @@ export class YoBrowserToolHandler { async callTool( toolName: string, args: Record, - conversationId?: string + conversationId?: string, + runId?: string ): Promise { try { const sessionId = conversationId?.trim() @@ -39,7 +40,9 @@ export class YoBrowserToolHandler { throw new Error('url is required') } return JSON.stringify( - await this.presenter.loadUrl(sessionId, url, undefined, undefined, 'agent') + runId + ? await this.presenter.loadUrl(sessionId, url, undefined, undefined, 'agent', runId) + : await this.presenter.loadUrl(sessionId, url, undefined, undefined, 'agent') ) } case 'cdp_send': { @@ -50,18 +53,15 @@ export class YoBrowserToolHandler { const status = await this.presenter.getBrowserStatus(sessionId) const page = status.page - if ( - !status.initialized || - !status.visible || - !page || - page.status === BrowserPageStatus.Closed - ) { + if (!status.initialized || !page || page.status === BrowserPageStatus.Closed) { throw await this.createUnavailableError(sessionId, method, status) } try { const params = this.normalizeCdpParams(args.params) - const response = await this.presenter.sendCdpCommand(sessionId, method, params, 'agent') + const response = runId + ? await this.presenter.sendCdpCommand(sessionId, method, params, 'agent', runId) + : await this.presenter.sendCdpCommand(sessionId, method, params, 'agent') return JSON.stringify(response ?? {}) } catch (error) { if (error instanceof Error && error.name === 'YoBrowserNotReadyError') { diff --git a/src/main/desktop/browser/yoBrowserSession.ts b/src/main/desktop/browser/yoBrowserSession.ts index 472893b41f..a73674ac6a 100644 --- a/src/main/desktop/browser/yoBrowserSession.ts +++ b/src/main/desktop/browser/yoBrowserSession.ts @@ -1,17 +1,18 @@ -import { session, type Session } from 'electron' +import { session, WebContentsView, type CookiesSetDetails, type Session } from 'electron' export const YO_BROWSER_PARTITION = 'persist:yo-browser' let cachedSession: Session | null = null function buildYoBrowserUserAgent(): string { + const chromeVersion = process.versions.chrome ?? '0.0.0.0' if (process.platform === 'darwin') { - return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36' + return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36` } if (process.platform === 'win32') { - return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36' + return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36` } - return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36' + return `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36` } function configureYoBrowserSession(target: Session): void { @@ -42,6 +43,67 @@ export function getYoBrowserSession(): Session { return cachedSession } +type DevToolsCookie = { + name: string + value: string + domain: string + path: string + expires: number | null + httpOnly: boolean + secure: boolean + session: boolean + sameSite?: 'Strict' | 'Lax' | 'None' + partitionKey?: unknown + partitionKeyOpaque?: boolean +} + +function toElectronCookie(cookie: DevToolsCookie): CookiesSetDetails { + const path = cookie.path || '/' + return { + url: `${cookie.secure ? 'https' : 'http'}://${cookie.domain.replace(/^\./, '')}${path}`, + name: cookie.name, + value: cookie.value, + ...(cookie.domain.startsWith('.') ? { domain: cookie.domain } : {}), + path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: + cookie.sameSite === 'Strict' + ? 'strict' + : cookie.sameSite === 'Lax' + ? 'lax' + : cookie.sameSite === 'None' + ? 'no_restriction' + : 'unspecified', + ...(cookie.session || cookie.expires === null || cookie.expires < 0 + ? {} + : { expirationDate: cookie.expires }) + } +} + +export async function getYoBrowserUnpartitionedCookies(): Promise { + const view = new WebContentsView({ + webPreferences: { + sandbox: true, + session: getYoBrowserSession() + } + }) + const debugSession = view.webContents.debugger + + try { + debugSession.attach('1.3') + const response = (await debugSession.sendCommand('Storage.getCookies')) as { + cookies?: DevToolsCookie[] + } + return (response.cookies ?? []) + .filter((cookie) => !cookie.partitionKey && !cookie.partitionKeyOpaque) + .map(toElectronCookie) + } finally { + if (debugSession.isAttached()) debugSession.detach() + view.webContents.close() + } +} + export async function clearYoBrowserSessionData(): Promise { const targetSession = getYoBrowserSession() await Promise.all([ diff --git a/src/main/desktop/routes.ts b/src/main/desktop/routes.ts index e8da48a212..c4c42b4aed 100644 --- a/src/main/desktop/routes.ts +++ b/src/main/desktop/routes.ts @@ -9,6 +9,7 @@ import type { DialogServicePort } from '@shared/types/dialog' import type { DesktopSettings } from './settings' import { browserAttachCurrentWindowRoute, + browserApplyImportRoute, browserClearSandboxDataRoute, browserDestroyRoute, browserDetachRoute, @@ -16,7 +17,10 @@ import { browserGoBackRoute, browserGoForwardRoute, browserLoadUrlRoute, + browserPreviewImportRoute, browserReloadRoute, + browserScanImportSourcesRoute, + browserSetPreviewModeRoute, browserUpdateCurrentWindowBoundsRoute, configGetFloatingButtonRoute, configGetLanguageRoute, @@ -384,6 +388,20 @@ export function createDesktopRoutes(deps: { return browserDetachRoute.output.parse({ detached: true }) } ], + [ + browserSetPreviewModeRoute.name, + async (rawInput, context) => { + const input = browserSetPreviewModeRoute.input.parse(rawInput) + return browserSetPreviewModeRoute.output.parse({ + updated: await browserPresenter.setPreviewMode( + input.sessionId, + input.mode, + context.windowId ?? undefined, + input.runId + ) + }) + } + ], [ browserDestroyRoute.name, async (rawInput) => { @@ -426,6 +444,31 @@ export function createDesktopRoutes(deps: { return browserClearSandboxDataRoute.output.parse({ cleared: true }) } ], + [ + browserScanImportSourcesRoute.name, + async (rawInput) => { + browserScanImportSourcesRoute.input.parse(rawInput) + return browserScanImportSourcesRoute.output.parse( + await browserPresenter.scanImportSources() + ) + } + ], + [ + browserPreviewImportRoute.name, + async (rawInput) => { + const input = browserPreviewImportRoute.input.parse(rawInput) + return browserPreviewImportRoute.output.parse( + await browserPresenter.previewImport(input.profileId) + ) + } + ], + [ + browserApplyImportRoute.name, + async (rawInput) => { + const input = browserApplyImportRoute.input.parse(rawInput) + return browserApplyImportRoute.output.parse(await browserPresenter.applyImport(input.token)) + } + ], [ tabCaptureCurrentAreaRoute.name, async (rawInput, context) => { diff --git a/src/main/tool/agentTools/agentToolManager.ts b/src/main/tool/agentTools/agentToolManager.ts index 26fed6ad8f..069d318486 100644 --- a/src/main/tool/agentTools/agentToolManager.ts +++ b/src/main/tool/agentTools/agentToolManager.ts @@ -115,6 +115,7 @@ interface AgentToolManagerOptions { interface AgentToolExecutionOptions { toolCallId?: string + runId?: string onProgress?: (update: AgentToolProgressUpdate) => void signal?: AbortSignal allowExternalFileAccess?: boolean @@ -636,7 +637,8 @@ export class AgentToolManager { const response = await this.getYoBrowserToolHandler().callTool( toolName, args, - conversationId + conversationId, + options?.runId ) return { content: response diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts index 8eea8cc31c..1b90adf2b9 100644 --- a/src/main/tool/index.ts +++ b/src/main/tool/index.ts @@ -313,6 +313,7 @@ export class ToolService implements ToolServicePort { request.conversationId, { toolCallId: request.id, + runId: options?.runId, onProgress: options?.onProgress, signal: options?.signal, allowExternalFileAccess: allowsExternalFileAccess(options?.permissionMode), diff --git a/src/main/tool/runtimePorts.ts b/src/main/tool/runtimePorts.ts index 13e9bc49ac..3bda002d1c 100644 --- a/src/main/tool/runtimePorts.ts +++ b/src/main/tool/runtimePorts.ts @@ -130,7 +130,8 @@ export interface AgentBrowserToolPort { callTool( toolName: string, args: Record, - conversationId?: string + conversationId?: string, + runId?: string ): Promise } diff --git a/src/renderer/api/BrowserClient.ts b/src/renderer/api/BrowserClient.ts index e2249d708b..38325cd905 100644 --- a/src/renderer/api/BrowserClient.ts +++ b/src/renderer/api/BrowserClient.ts @@ -2,10 +2,13 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' import { browserActivityChangedEvent, browserOpenRequestedEvent, - browserStatusChangedEvent + browserPreviewFrameEvent, + browserStatusChangedEvent, + type DeepchatEventPayload } from '@shared/contracts/events' import { browserAttachCurrentWindowRoute, + browserApplyImportRoute, browserClearSandboxDataRoute, browserDestroyRoute, browserDetachRoute, @@ -13,7 +16,10 @@ import { browserGoBackRoute, browserGoForwardRoute, browserLoadUrlRoute, + browserPreviewImportRoute, browserReloadRoute, + browserScanImportSourcesRoute, + browserSetPreviewModeRoute, browserUpdateCurrentWindowBoundsRoute } from '@shared/contracts/routes' import type { YoBrowserStatus } from '@shared/types/browser' @@ -73,6 +79,15 @@ export function createBrowserClient(bridge: DeepchatBridge = getDeepchatBridge() return result.detached } + async function setPreviewMode( + sessionId: string, + mode: 'capturing' | 'rendering' | 'stopped', + runId?: string + ) { + const result = await bridge.invoke(browserSetPreviewModeRoute.name, { sessionId, mode, runId }) + return result.updated + } + async function destroy(sessionId: string) { const result = await bridge.invoke(browserDestroyRoute.name, { sessionId }) return result.destroyed @@ -98,6 +113,18 @@ export function createBrowserClient(bridge: DeepchatBridge = getDeepchatBridge() return result.cleared } + async function scanImportSources() { + return await bridge.invoke(browserScanImportSourcesRoute.name, {}) + } + + async function previewImport(profileId: string) { + return await bridge.invoke(browserPreviewImportRoute.name, { profileId }) + } + + async function applyImport(token: string) { + return await bridge.invoke(browserApplyImportRoute.name, { token }) + } + async function openExternal(url: string) { await openRuntimeExternal(url) } @@ -107,6 +134,8 @@ export function createBrowserClient(bridge: DeepchatBridge = getDeepchatBridge() sessionId: string windowId: number url: string + source: 'agent' | 'user' + runId?: string version: number }) => void ) { @@ -118,6 +147,8 @@ export function createBrowserClient(bridge: DeepchatBridge = getDeepchatBridge() sessionId: string windowId: number url: string + source: 'agent' | 'user' + runId?: string version: number }) => void ) { @@ -168,22 +199,33 @@ export function createBrowserClient(bridge: DeepchatBridge = getDeepchatBridge() return bridge.on(browserActivityChangedEvent.name, listener) } + function onPreviewFrame( + listener: (payload: DeepchatEventPayload) => void + ) { + return bridge.on(browserPreviewFrameEvent.name, listener) + } + return { getStatus, loadUrl, attachCurrentWindow, updateCurrentWindowBounds, detach, + setPreviewMode, destroy, goBack, goForward, reload, clearSandboxData, + scanImportSources, + previewImport, + applyImport, openExternal, onOpenRequested, onOpenRequestedForCurrentWindow, onStatusChanged, - onActivityChanged + onActivityChanged, + onPreviewFrame } } diff --git a/src/renderer/settings/components/BrowserDataImportDialog.vue b/src/renderer/settings/components/BrowserDataImportDialog.vue new file mode 100644 index 0000000000..12e0ab76e7 --- /dev/null +++ b/src/renderer/settings/components/BrowserDataImportDialog.vue @@ -0,0 +1,250 @@ + + + diff --git a/src/renderer/settings/components/DataSettings.vue b/src/renderer/settings/components/DataSettings.vue index a1d66730bb..e1d64cc103 100644 --- a/src/renderer/settings/components/DataSettings.vue +++ b/src/renderer/settings/components/DataSettings.vue @@ -937,49 +937,55 @@

- - - - - - - {{ - t('settings.data.yoBrowser.confirmTitle') - }} - - {{ t('settings.data.yoBrowser.confirmDescription') }} - - - - - {{ t('dialog.cancel') }} - - - {{ - isClearingSandbox - ? t('settings.data.yoBrowser.clearing') - : t('settings.data.yoBrowser.confirmAction') - }} - - - - +
+ + + + + + + + {{ + t('settings.data.yoBrowser.confirmTitle') + }} + + {{ t('settings.data.yoBrowser.confirmDescription') }} + + + + + {{ t('dialog.cancel') }} + + + {{ + isClearingSandbox + ? t('settings.data.yoBrowser.clearing') + : t('settings.data.yoBrowser.confirmAction') + }} + + + + +
@@ -1074,6 +1080,7 @@ import { useToast } from '@/components/use-toast' import PrivacySettingsSection from './common/PrivacySettingsSection.vue' import SettingsPageShell from './control-center/SettingsPageShell.vue' import ProviderConfigImportDialog from './ProviderConfigImportDialog.vue' +import BrowserDataImportDialog from './BrowserDataImportDialog.vue' const PROVIDER_IMPORT_SECTION = 'provider-import' const DATABASE_REPAIR_SECTION = 'database-repair' diff --git a/src/renderer/src/components/browser/AgentBrowserPiP.vue b/src/renderer/src/components/browser/AgentBrowserPiP.vue new file mode 100644 index 0000000000..e1d7140248 --- /dev/null +++ b/src/renderer/src/components/browser/AgentBrowserPiP.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/src/renderer/src/components/sidepanel/BrowserPanel.vue b/src/renderer/src/components/sidepanel/BrowserPanel.vue index f40e253b0c..4e4bf57520 100644 --- a/src/renderer/src/components/sidepanel/BrowserPanel.vue +++ b/src/renderer/src/components/sidepanel/BrowserPanel.vue @@ -41,6 +41,18 @@ spellcheck="false" /> +
@@ -61,15 +73,15 @@ import { createBrowserClient } from '@api/BrowserClient' import BrowserPlaceholder from './BrowserPlaceholder.vue' import type { YoBrowserStatus } from '@shared/types/browser' import { useSidepanelStore } from '@/stores/ui/sidepanel' -import { useSessionStore } from '@/stores/ui/session' const props = defineProps<{ sessionId: string | null + isFullscreen?: boolean }>() +const emit = defineEmits<{ (event: 'toggle-fullscreen'): void }>() const { t } = useI18n() const sidepanelStore = useSidepanelStore() -const sessionStore = useSessionStore() const browserClient = createBrowserClient() const containerRef = ref(null) @@ -86,7 +98,6 @@ const urlInput = ref('') const canGoBack = ref(false) const canGoForward = ref(false) let lastSyncedBounds: Rectangle | null = null -const pendingBrowserDestroySessionIds = new Set() let visibilityRunId = 0 let stopOpenRequestedListener: (() => void) | null = null let stopStatusChangedListener: (() => void) | null = null @@ -103,10 +114,6 @@ const isBrowserPanelVisible = computed( () => sidepanelStore.open && sidepanelStore.activeTab === 'browser' ) -const getSessionUiStatus = (sessionId: string) => { - return sessionStore.sessions.find((session) => session.id === sessionId)?.status ?? null -} - const callBrowserAction = async (action: string, run: () => Promise): Promise => { try { return await run() @@ -337,6 +344,8 @@ const handleOpenRequested = async (payload: { sessionId: string windowId: number url: string + source: 'agent' | 'user' + runId?: string version: number }) => { if (payload.sessionId !== currentSessionId.value) { @@ -442,24 +451,6 @@ const cleanupInactiveSession = async (sessionId: string) => { } await hideEmbedded(sessionId) - if (getSessionUiStatus(sessionId) === 'working') { - pendingBrowserDestroySessionIds.add(sessionId) - return - } - - pendingBrowserDestroySessionIds.delete(sessionId) - await callBrowserAction('destroy', () => browserClient.destroy(sessionId)) -} - -const flushPendingSessionDestroys = async () => { - for (const sessionId of Array.from(pendingBrowserDestroySessionIds)) { - if (getSessionUiStatus(sessionId) === 'working') { - continue - } - - pendingBrowserDestroySessionIds.delete(sessionId) - await callBrowserAction('destroy', () => browserClient.destroy(sessionId)) - } } useResizeObserver(containerRef, () => { @@ -496,16 +487,6 @@ watch( { immediate: true } ) -watch( - () => sessionStore.sessions.map((session) => `${session.id}:${session.status}`).join('|'), - () => { - void flushPendingSessionDestroys() - if (currentSessionId.value) { - void loadState(currentSessionId.value) - } - } -) - onMounted(async () => { window.addEventListener('resize', scheduleVisibleBoundsSync) stopOpenRequestedListener = browserClient.onOpenRequestedForCurrentWindow(handleOpenRequested) diff --git a/src/renderer/src/components/sidepanel/ChatSidePanel.vue b/src/renderer/src/components/sidepanel/ChatSidePanel.vue index 20d5335c7b..253a639102 100644 --- a/src/renderer/src/components/sidepanel/ChatSidePanel.vue +++ b/src/renderer/src/components/sidepanel/ChatSidePanel.vue @@ -3,17 +3,18 @@ data-testid="chat-side-panel-shell" class="chat-side-panel-shell h-full min-h-0 overflow-hidden" :class="[ - isWorkspaceFullscreenActive ? 'absolute inset-0 w-full' : 'relative shrink-0', + isSidepanelFullscreenActive ? 'absolute inset-0 w-full' : 'relative shrink-0', { 'chat-side-panel-shell--resizing': isResizing } ]" :style="shellStyle" :data-workspace-fullscreen="String(isWorkspaceFullscreenActive)" + :data-browser-fullscreen="String(isBrowserFullscreenActive)" >
@@ -111,16 +117,23 @@ const layoutWidth = ref(shouldShow.value ? sidepanelStore.width : 0) const panelVisible = ref(shouldShow.value) const isResizing = ref(false) const isWorkspaceFullscreen = ref(false) +const isBrowserFullscreen = ref(false) const fullscreenMotionState = ref<'expanding' | 'collapsing' | null>(null) const isWorkspaceFullscreenActive = computed(() => { return isWorkspaceFullscreen.value && shouldShow.value && sidepanelStore.activeTab === 'workspace' }) +const isBrowserFullscreenActive = computed(() => { + return isBrowserFullscreen.value && shouldShow.value && sidepanelStore.activeTab === 'browser' +}) +const isSidepanelFullscreenActive = computed( + () => isWorkspaceFullscreenActive.value || isBrowserFullscreenActive.value +) const shellStyle = computed(() => { return { - width: isWorkspaceFullscreenActive.value ? '100%' : `${layoutWidth.value}px`, - ...(isWorkspaceFullscreenActive.value ? { zIndex: 'var(--dc-z-sidepanel)' } : {}) + width: isSidepanelFullscreenActive.value ? '100%' : `${layoutWidth.value}px`, + ...(isSidepanelFullscreenActive.value ? { zIndex: 'var(--dc-z-sidepanel)' } : {}) } }) @@ -128,13 +141,17 @@ const handleBrowserOpenRequested = (payload: { sessionId: string windowId: number url: string + source: 'agent' | 'user' + runId?: string version: number }) => { if (!props.sessionId || payload.sessionId !== props.sessionId) { return } - sidepanelStore.openBrowser() + if (payload.source !== 'agent' || sidepanelStore.open) { + sidepanelStore.openBrowser() + } } const clearPanelMotionHandles = () => { @@ -188,6 +205,11 @@ const resetWorkspaceFullscreen = () => { clearFullscreenMotionHandle() } +const resetBrowserFullscreen = () => { + isBrowserFullscreen.value = false + clearFullscreenMotionHandle() +} + const toggleWorkspaceFullscreen = () => { if (!shouldShow.value || sidepanelStore.activeTab !== 'workspace') { return @@ -202,6 +224,20 @@ const toggleWorkspaceFullscreen = () => { isWorkspaceFullscreen.value = !isWorkspaceFullscreen.value } +const toggleBrowserFullscreen = () => { + if (!shouldShow.value || sidepanelStore.activeTab !== 'browser') { + return + } + + clearFullscreenMotionHandle() + fullscreenMotionState.value = isBrowserFullscreen.value ? 'collapsing' : 'expanding' + fullscreenMotionTimer = window.setTimeout(() => { + fullscreenMotionTimer = null + fullscreenMotionState.value = null + }, FULLSCREEN_MOTION_MS) + isBrowserFullscreen.value = !isBrowserFullscreen.value +} + const handleWorkspaceInsertFileReference = (filePath: string) => { const sessionId = props.sessionId?.trim() const targetPath = filePath.trim() @@ -222,7 +258,7 @@ const handleWorkspaceInsertFileReference = (filePath: string) => { const startResize = (event: MouseEvent) => { event.preventDefault() - if (isWorkspaceFullscreenActive.value) { + if (isSidepanelFullscreenActive.value) { return } @@ -260,6 +296,7 @@ watch(shouldShow, (visible) => { if (!visible) { resetWorkspaceFullscreen() + resetBrowserFullscreen() } if (visible) { @@ -286,6 +323,9 @@ watch( if (activeTab !== 'workspace') { resetWorkspaceFullscreen() } + if (activeTab !== 'browser') { + resetBrowserFullscreen() + } } ) @@ -294,6 +334,7 @@ watch( (sessionId, previousSessionId) => { if (!sessionId || sessionId !== previousSessionId) { resetWorkspaceFullscreen() + resetBrowserFullscreen() } } ) diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 9276f9d6a4..f687dbb36e 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -152,7 +152,33 @@ "confirmDescription": "YoBrowser-sessionens cookies, cache, LocalStorage, IndexedDB og andre data vil blive slettet, og du skal muligvis logge ind på hjemmesiden igen.", "confirmTitle": "Bekræft at rydde YoBrowser-data?", "description": "En uafhængig browsersandbox, der ikke deler cookies og lokal lagring med hovedapplikationen. YoBrowser-data kan ryddes med et enkelt klik her.", - "title": "YoBrowser Sandbox" + "title": "YoBrowser Sandbox", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "databaseRepair": { "title": "Databasereparation", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 6772c94cb6..0264821f34 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -547,7 +547,33 @@ "clearedTitle": "YoBrowser-Daten gelöscht", "clearedDescription": "Die Sandbox-Sitzung wurde zurückgesetzt; zugehörige Tabs werden neu geladen.", "clearFailedTitle": "Löschen fehlgeschlagen", - "clearFailedDescription": "Bitte erneut versuchen oder Protokolle für weitere Informationen prüfen." + "clearFailedDescription": "Bitte erneut versuchen oder Protokolle für weitere Informationen prüfen.", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "privacyTitle": "Data & Privacy", "privacyDescription": "Backup-Synchronisierung, Privatsphärenmodus, Datenwartung und gefährliche Aktionen verwalten.", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index d42a33e68d..64e1b49134 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -589,6 +589,32 @@ "confirmDescription": "The YoBrowser session's cookies, cache, LocalStorage, IndexedDB and other data will be deleted, and you may need to log in to the website again.", "confirmTitle": "Confirm to clear YoBrowser data?", "description": "An independent browser sandbox that does not share cookies and local storage with the main application. \nYoBrowser data can be cleared with one click here.", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + }, "title": "YoBrowser Sandbox" }, "privacyTitle": "Data & Privacy", diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index d3a44c6bcb..51bab5b0cf 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -547,7 +547,33 @@ "clearedTitle": "YoBrowser datos borrados", "clearedDescription": "La sesión de sandbox se restableció y se recargará la pestaña asociada.", "clearFailedTitle": "Error al borrar", - "clearFailedDescription": "Inténtelo de nuevo o consulte los registros para obtener más información." + "clearFailedDescription": "Inténtelo de nuevo o consulte los registros para obtener más información.", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "privacyTitle": "Datos y privacidad", "privacyDescription": "Administre copias de seguridad, sincronización, modo de privacidad, mantenimiento y operaciones peligrosas.", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 0cd027462a..17f15204d5 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "کوکی‌ها، حافظه پنهان، LocalStorage، IndexedDB و سایر داده‌های جلسه YoBrowser حذف خواهند شد و ممکن است لازم باشد دوباره وارد وب‌سایت شوید.", "confirmTitle": "برای پاک کردن داده‌های YoBrowser تأیید می‌کنید؟", "description": "یک سندباکس مرورگر مستقل که کوکی‌ها و فضای ذخیره‌سازی محلی را با برنامه اصلی به اشتراک نمی‌گذارد. داده های YoBrowser را می توان با یک کلیک در اینجا پاک کرد.", - "title": "YoBrowser Sandbox" + "title": "YoBrowser Sandbox", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "databaseRepair": { "title": "ترمیم پایگاه داده", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 207ed1a511..f62c9a3e97 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "Les cookies, le cache, LocalStorage, IndexedDB et autres données de la session YoBrowser seront supprimés et vous devrez peut-être vous reconnecter au site Web.", "confirmTitle": "Confirmer pour effacer les données de YoBrowser ?", "description": "Un bac à sable de navigateur indépendant qui ne partage pas de cookies ni de stockage local avec l'application principale. Les données YoBrowser peuvent être effacées en un seul clic ici.", - "title": "Bac à sable YoBrowser" + "title": "Bac à sable YoBrowser", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "databaseRepair": { "title": "Réparation de la base de données", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 1eb66b5460..673df72268 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "העוגיות, המטמון, LocalStorage, IndexedDB ונתונים אחרים של הפעלת YoBrowser יימחקו, וייתכן שתצטרך להיכנס שוב לאתר.", "confirmTitle": "לאשר לנקות נתוני YoBrowser?", "description": "ארגז חול עצמאי של דפדפן שאינו חולק עוגיות ואחסון מקומי עם האפליקציה הראשית. ניתן לנקות את נתוני YoBrowser בלחיצה אחת כאן.", - "title": "ארגז חול של YoBrowser" + "title": "ארגז חול של YoBrowser", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "databaseRepair": { "title": "תיקון מסד הנתונים", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 3561aa0603..7a67002ae7 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -547,7 +547,33 @@ "clearedTitle": "Menghapus data YoBrowser", "clearedDescription": "Sesi sandbox telah disetel ulang dan tab terkait akan dimuat ulang.", "clearFailedTitle": "Pembersihan gagal", - "clearFailedDescription": "Silakan coba lagi atau periksa log untuk informasi lebih lanjut." + "clearFailedDescription": "Silakan coba lagi atau periksa log untuk informasi lebih lanjut.", + "import": { + "button": "Impor data situs web", + "title": "Impor data sesi browser", + "description": "Pilih profil Chrome atau Arc. macOS mungkin meminta Anda mengizinkan akses Rantai Kunci. Sumber tidak akan diubah.", + "scanning": "Mencari profil browser...", + "unsupportedTitle": "Tidak didukung di platform ini", + "unsupportedDescription": "Impor data browser secara langsung saat ini hanya tersedia di macOS. Windows belum didukung karena data Chrome saat ini terikat pada aplikasi.", + "profileLabel": "Profil sumber", + "profilePlaceholder": "Pilih profil", + "noProfiles": "Tidak ditemukan profil Chrome atau Arc yang didukung dengan data cookie.", + "cookies": "Cookie dan sesi", + "supported": "Disertakan dalam impor ini", + "otherData": "Kata sandi dan penyimpanan web", + "notIncluded": "Tidak disertakan dalam versi ini", + "preview": "Izinkan dan pratinjau", + "previewTitle": "Siap mengganti cookie YoBrowser", + "previewDescription": "{count} cookie dapat diimpor. Cookie YoBrowser yang ada akan diganti.", + "skipped": "Dilewati: {expired} kedaluwarsa, {partitioned} terpartisi.", + "confirm": "Ganti dan impor", + "doneTitle": "Impor selesai", + "doneDescription": "{count} cookie telah diimpor. Halaman browser yang terbuka telah dimuat ulang.", + "keyDenied": "Akses Rantai Kunci ditolak atau kunci browser tidak dapat dibaca.", + "previewExpired": "Sumber berubah atau pratinjau kedaluwarsa. Buat pratinjau baru.", + "encryptionUnsupported": "Profil ini menggunakan format enkripsi yang tidak didukung oleh pengimpor ini.", + "failed": "Impor gagal sebelum selesai. Cookie YoBrowser yang ada telah dipulihkan jika perubahan sempat dimulai." + } }, "privacyTitle": "Data & Privasi", "privacyDescription": "Kelola sinkronisasi cadangan, mode privasi, pemeliharaan data, dan operasi berbahaya.", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 3b6b64357b..6d354ac986 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -547,7 +547,33 @@ "clearedTitle": "Dati YoBrowser cancellati", "clearedDescription": "La sessione sandbox è stata ripristinata e le schede correlate verranno ricaricate.", "clearFailedTitle": "Cancellazione non riuscita", - "clearFailedDescription": "Riprova o controlla i log per altre informazioni." + "clearFailedDescription": "Riprova o controlla i log per altre informazioni.", + "import": { + "button": "Importa dati dei siti web", + "title": "Importa dati della sessione del browser", + "description": "Scegli un profilo Chrome o Arc. macOS potrebbe chiederti di autorizzare l'accesso al Portachiavi. La sorgente non viene mai modificata.", + "scanning": "Ricerca dei profili del browser...", + "unsupportedTitle": "Non supportato su questa piattaforma", + "unsupportedDescription": "L'importazione diretta dei dati del browser è attualmente disponibile solo su macOS. Windows non è supportato perché i dati correnti di Chrome sono vincolati all'applicazione.", + "profileLabel": "Profilo sorgente", + "profilePlaceholder": "Seleziona un profilo", + "noProfiles": "Non sono stati trovati profili Chrome o Arc supportati con dati dei cookie.", + "cookies": "Cookie e sessioni", + "supported": "Inclusi in questa importazione", + "otherData": "Password e archiviazione web", + "notIncluded": "Non inclusi in questa versione", + "preview": "Autorizza e visualizza l'anteprima", + "previewTitle": "Pronto a sostituire i cookie di YoBrowser", + "previewDescription": "È possibile importare {count} cookie. I cookie esistenti di YoBrowser verranno sostituiti.", + "skipped": "Ignorati: {expired} scaduti, {partitioned} partizionati.", + "confirm": "Sostituisci e importa", + "doneTitle": "Importazione completata", + "doneDescription": "Importati {count} cookie. Le pagine del browser aperte sono state ricaricate.", + "keyDenied": "L'accesso al Portachiavi è stato negato oppure non è stato possibile leggere la chiave del browser.", + "previewExpired": "La sorgente è cambiata o l'anteprima è scaduta. Crea una nuova anteprima.", + "encryptionUnsupported": "Questo profilo usa un formato di crittografia non supportato dallo strumento di importazione.", + "failed": "L'importazione non è stata completata. I cookie esistenti di YoBrowser sono stati ripristinati se la modifica era già iniziata." + } }, "privacyTitle": "Dati e privacy", "privacyDescription": "Gestisci backup, sincronizzazione, modalità privacy, manutenzione dati e operazioni rischiose.", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 15b3e044e2..2ae8c84f02 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "YoBrowser セッションの Cookie、キャッシュ、LocalStorage、IndexedDB およびその他のデータが削除されるため、Web サイトへの再ログインが必要になる場合があります。", "confirmTitle": "YoBrowser データをクリアしますか?", "description": "Cookie やローカル ストレージをメイン アプリケーションと共有しない独立したブラウザ サンドボックス。ここで、YoBrowser のデータをワンクリックでクリアできます。", - "title": "YoBrowser サンドボックス" + "title": "YoBrowser サンドボックス", + "import": { + "button": "Web サイトデータをインポート", + "title": "ブラウザーのセッションデータをインポート", + "description": "Chrome または Arc のプロファイルを選択してください。macOS からキーチェーンへのアクセス許可を求められる場合があります。インポート元は変更されません。", + "scanning": "ブラウザーのプロファイルを検索しています...", + "unsupportedTitle": "このプラットフォームではサポートされていません", + "unsupportedDescription": "ブラウザーデータの直接インポートは現在 macOS でのみ利用できます。現在の Chrome データはアプリに関連付けられているため、Windows はサポートされていません。", + "profileLabel": "インポート元のプロファイル", + "profilePlaceholder": "プロファイルを選択", + "noProfiles": "Cookie データを含む、サポート対象の Chrome または Arc プロファイルが見つかりませんでした。", + "cookies": "Cookie とセッション", + "supported": "今回のインポートに含まれます", + "otherData": "パスワードと Web ストレージ", + "notIncluded": "このバージョンには含まれません", + "preview": "許可してプレビュー", + "previewTitle": "YoBrowser の Cookie を置き換える準備ができました", + "previewDescription": "{count} 件の Cookie をインポートできます。既存の YoBrowser の Cookie は置き換えられます。", + "skipped": "スキップ: 期限切れ {expired} 件、パーティション化 {partitioned} 件。", + "confirm": "置き換えてインポート", + "doneTitle": "インポートが完了しました", + "doneDescription": "{count} 件の Cookie をインポートしました。開いているブラウザーページを再読み込みしました。", + "keyDenied": "キーチェーンへのアクセスが拒否されたか、ブラウザーのキーを読み取れませんでした。", + "previewExpired": "インポート元が変更されたか、プレビューの有効期限が切れました。新しいプレビューを作成してください。", + "encryptionUnsupported": "このプロファイルでは、インポーターがサポートしていない暗号化形式が使用されています。", + "failed": "インポートが完了前に失敗しました。変更が開始されていた場合、既存の YoBrowser の Cookie は復元されました。" + } }, "databaseRepair": { "title": "データベース修復", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 7491d1b580..837e426e4d 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "YoBrowser 세션의 쿠키, 캐시, LocalStorage, IndexedDB 및 기타 데이터가 삭제되며 웹사이트에 다시 로그인해야 할 수도 있습니다.", "confirmTitle": "YoBrowser 데이터를 삭제하시겠습니까?", "description": "기본 애플리케이션과 쿠키 및 로컬 저장소를 공유하지 않는 독립적인 브라우저 샌드박스입니다. 여기를 클릭하면 YoBrowser 데이터를 삭제할 수 있습니다.", - "title": "요브라우저 샌드박스" + "title": "요브라우저 샌드박스", + "import": { + "button": "웹사이트 데이터 가져오기", + "title": "브라우저 세션 데이터 가져오기", + "description": "Chrome 또는 Arc 프로필을 선택하세요. macOS에서 키체인 접근 권한을 요청할 수 있습니다. 원본 데이터는 변경되지 않습니다.", + "scanning": "브라우저 프로필을 찾는 중...", + "unsupportedTitle": "이 플랫폼에서는 지원되지 않음", + "unsupportedDescription": "브라우저 데이터 직접 가져오기는 현재 macOS에서만 사용할 수 있습니다. 현재 Chrome 데이터는 앱에 바인딩되어 있으므로 Windows는 지원되지 않습니다.", + "profileLabel": "원본 프로필", + "profilePlaceholder": "프로필 선택", + "noProfiles": "쿠키 데이터가 있는 지원 대상 Chrome 또는 Arc 프로필을 찾지 못했습니다.", + "cookies": "쿠키 및 세션", + "supported": "이번 가져오기에 포함됨", + "otherData": "비밀번호 및 웹 저장소", + "notIncluded": "이 버전에는 포함되지 않음", + "preview": "권한 부여 및 미리보기", + "previewTitle": "YoBrowser 쿠키를 교체할 준비가 되었습니다", + "previewDescription": "쿠키 {count}개를 가져올 수 있습니다. 기존 YoBrowser 쿠키가 교체됩니다.", + "skipped": "건너뜀: 만료됨 {expired}개, 파티션됨 {partitioned}개.", + "confirm": "교체하고 가져오기", + "doneTitle": "가져오기 완료", + "doneDescription": "쿠키 {count}개를 가져왔습니다. 열려 있는 브라우저 페이지를 다시 불러왔습니다.", + "keyDenied": "키체인 접근이 거부되었거나 브라우저 키를 읽을 수 없습니다.", + "previewExpired": "원본이 변경되었거나 미리보기가 만료되었습니다. 새 미리보기를 만드세요.", + "encryptionUnsupported": "이 프로필은 가져오기 도구에서 지원하지 않는 암호화 형식을 사용합니다.", + "failed": "가져오기가 완료되기 전에 실패했습니다. 변경이 시작된 경우 기존 YoBrowser 쿠키가 복원되었습니다." + } }, "databaseRepair": { "title": "데이터베이스 복구", diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index c3b687e02b..25a424ddae 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -547,7 +547,33 @@ "clearedTitle": "Data YoBrowser dikosongkan", "clearedDescription": "Sesi kotak pasir telah ditetapkan semula dan tab yang berkaitan akan dimuatkan semula.", "clearFailedTitle": "Pembersihan gagal", - "clearFailedDescription": "Sila cuba lagi atau semak log untuk mendapatkan maklumat lanjut." + "clearFailedDescription": "Sila cuba lagi atau semak log untuk mendapatkan maklumat lanjut.", + "import": { + "button": "Import data laman web", + "title": "Import data sesi pelayar", + "description": "Pilih profil Chrome atau Arc. macOS mungkin meminta anda membenarkan akses Rantai Kunci. Sumber tidak akan diubah.", + "scanning": "Mencari profil pelayar...", + "unsupportedTitle": "Tidak disokong pada platform ini", + "unsupportedDescription": "Import terus data pelayar kini hanya tersedia pada macOS. Windows belum disokong kerana data Chrome semasa terikat pada aplikasi.", + "profileLabel": "Profil sumber", + "profilePlaceholder": "Pilih profil", + "noProfiles": "Tiada profil Chrome atau Arc yang disokong dengan data kuki ditemui.", + "cookies": "Kuki dan sesi", + "supported": "Disertakan dalam import ini", + "otherData": "Kata laluan dan storan web", + "notIncluded": "Tidak disertakan dalam versi ini", + "preview": "Benarkan dan pratonton", + "previewTitle": "Sedia untuk menggantikan kuki YoBrowser", + "previewDescription": "{count} kuki boleh diimport. Kuki YoBrowser sedia ada akan digantikan.", + "skipped": "Dilangkau: {expired} tamat tempoh, {partitioned} berpartisi.", + "confirm": "Gantikan dan import", + "doneTitle": "Import selesai", + "doneDescription": "{count} kuki telah diimport. Halaman pelayar yang terbuka telah dimuatkan semula.", + "keyDenied": "Akses Rantai Kunci ditolak atau kunci pelayar tidak dapat dibaca.", + "previewExpired": "Sumber berubah atau pratonton tamat tempoh. Cipta pratonton baharu.", + "encryptionUnsupported": "Profil ini menggunakan format penyulitan yang tidak disokong oleh pengimport ini.", + "failed": "Import gagal sebelum selesai. Kuki YoBrowser sedia ada telah dipulihkan jika perubahan telah bermula." + } }, "privacyTitle": "Data & Privacy", "privacyDescription": "Urus penyegerakan sandaran, mod privasi, penyelenggaraan data dan operasi berbahaya.", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index 13b92ab843..07f9fb6181 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -547,7 +547,33 @@ "confirmDescription": "Pliki cookie, pamięć podręczna, LocalStorage, IndexedDB i inne dane sesji YoBrowser zostaną usunięte, a konieczne może być ponowne zalogowanie się na stronie.", "confirmTitle": "Potwierdzić, aby wyczyścić dane YoBrowser?", "description": "Niezależna piaskownica przeglądarki, która nie udostępnia plików cookie i pamięci lokalnej z główną aplikacją. \nTutaj możesz wyczyścić dane YoBrowser jednym kliknięciem.", - "title": "Piaskownica YoBrowser" + "title": "Piaskownica YoBrowser", + "import": { + "button": "Importuj dane witryn", + "title": "Importuj dane sesji przeglądarki", + "description": "Wybierz profil Chrome lub Arc. macOS może poprosić o zezwolenie na dostęp do pęku kluczy. Źródło nigdy nie jest modyfikowane.", + "scanning": "Wyszukiwanie profili przeglądarki...", + "unsupportedTitle": "Brak obsługi na tej platformie", + "unsupportedDescription": "Bezpośredni import danych przeglądarki jest obecnie dostępny tylko w systemie macOS. System Windows nie jest obsługiwany, ponieważ bieżące dane Chrome są powiązane z aplikacją.", + "profileLabel": "Profil źródłowy", + "profilePlaceholder": "Wybierz profil", + "noProfiles": "Nie znaleziono obsługiwanych profili Chrome ani Arc z danymi plików cookie.", + "cookies": "Pliki cookie i sesje", + "supported": "Uwzględnione w tym imporcie", + "otherData": "Hasła i pamięć internetowa", + "notIncluded": "Nieuwzględnione w tej wersji", + "preview": "Zezwól i wyświetl podgląd", + "previewTitle": "Gotowe do zastąpienia plików cookie YoBrowser", + "previewDescription": "Można zaimportować {count} plików cookie. Istniejące pliki cookie YoBrowser zostaną zastąpione.", + "skipped": "Pominięto: {expired} wygasłych, {partitioned} partycjonowanych.", + "confirm": "Zastąp i importuj", + "doneTitle": "Import zakończony", + "doneDescription": "Zaimportowano {count} plików cookie. Otwarte strony przeglądarki zostały ponownie wczytane.", + "keyDenied": "Odmówiono dostępu do pęku kluczy lub nie można było odczytać klucza przeglądarki.", + "previewExpired": "Źródło uległo zmianie lub podgląd wygasł. Utwórz nowy podgląd.", + "encryptionUnsupported": "Ten profil używa formatu szyfrowania, którego importer nie obsługuje.", + "failed": "Import nie został ukończony. Jeśli modyfikacja została rozpoczęta, istniejące pliki cookie YoBrowser zostały przywrócone." + } }, "privacyTitle": "Dane i prywatność", "privacyDescription": "Zarządzaj kopiami zapasowymi, synchronizacją, trybem prywatności, konserwacją i niebezpiecznymi operacjami.", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index be4c9bff3b..9d2a5190ca 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "Os cookies, cache, LocalStorage, IndexedDB e outros dados da sessão YoBrowser serão excluídos e pode ser necessário fazer login no site novamente.", "confirmTitle": "Confirmar para limpar os dados do YoBrowser?", "description": "Uma sandbox de navegador independente que não compartilha cookies e armazenamento local com o aplicativo principal. Os dados do YoBrowser podem ser apagados com um clique aqui.", - "title": "Caixa de areia YoBrowser" + "title": "Caixa de areia YoBrowser", + "import": { + "button": "Importar dados de sites", + "title": "Importar dados da sessão do navegador", + "description": "Escolha um perfil do Chrome ou Arc. O macOS pode solicitar autorização para acessar as Chaves. A fonte nunca é modificada.", + "scanning": "Procurando perfis do navegador...", + "unsupportedTitle": "Não compatível com esta plataforma", + "unsupportedDescription": "A importação direta de dados do navegador está disponível atualmente apenas no macOS. O Windows não é compatível porque os dados atuais do Chrome são vinculados ao aplicativo.", + "profileLabel": "Perfil de origem", + "profilePlaceholder": "Selecione um perfil", + "noProfiles": "Nenhum perfil compatível do Chrome ou Arc com dados de cookies foi encontrado.", + "cookies": "Cookies e sessões", + "supported": "Incluídos nesta importação", + "otherData": "Senhas e armazenamento da web", + "notIncluded": "Não incluídos nesta versão", + "preview": "Autorizar e visualizar", + "previewTitle": "Pronto para substituir os cookies do YoBrowser", + "previewDescription": "É possível importar {count} cookies. Os cookies existentes do YoBrowser serão substituídos.", + "skipped": "Ignorados: {expired} expirados, {partitioned} particionados.", + "confirm": "Substituir e importar", + "doneTitle": "Importação concluída", + "doneDescription": "{count} cookies importados. As páginas abertas no navegador foram recarregadas.", + "keyDenied": "O acesso às Chaves foi negado ou não foi possível ler a chave do navegador.", + "previewExpired": "A fonte mudou ou a visualização expirou. Crie uma nova visualização.", + "encryptionUnsupported": "Este perfil usa um formato de criptografia que não é compatível com este importador.", + "failed": "A importação falhou antes da conclusão. Os cookies existentes do YoBrowser foram restaurados caso a alteração já tivesse começado." + } }, "databaseRepair": { "title": "Reparo do banco de dados", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 5b378d2685..0fa0fb2434 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "Файлы cookie, кеш, LocalStorage, IndexedDB и другие данные сеанса YoBrowser будут удалены, и вам, возможно, придется снова войти на веб-сайт.", "confirmTitle": "Подтвердить очистку данных YoBrowser?", "description": "Независимая песочница браузера, которая не использует файлы cookie и локальное хранилище совместно с основным приложением. Данные YoBrowser можно очистить одним щелчком мыши здесь.", - "title": "Песочница YoBrowser" + "title": "Песочница YoBrowser", + "import": { + "button": "Импортировать данные сайтов", + "title": "Импортировать данные сеанса браузера", + "description": "Выберите профиль Chrome или Arc. macOS может запросить разрешение на доступ к Связке ключей. Исходные данные не изменяются.", + "scanning": "Поиск профилей браузера...", + "unsupportedTitle": "Не поддерживается на этой платформе", + "unsupportedDescription": "Прямой импорт данных браузера пока доступен только в macOS. Windows не поддерживается, поскольку текущие данные Chrome привязаны к приложению.", + "profileLabel": "Исходный профиль", + "profilePlaceholder": "Выберите профиль", + "noProfiles": "Поддерживаемые профили Chrome или Arc с файлами cookie не найдены.", + "cookies": "Файлы cookie и сеансы", + "supported": "Включено в этот импорт", + "otherData": "Пароли и веб-хранилище", + "notIncluded": "Не включено в эту версию", + "preview": "Разрешить и просмотреть", + "previewTitle": "Всё готово к замене файлов cookie YoBrowser", + "previewDescription": "Можно импортировать файлов cookie: {count}. Существующие файлы cookie YoBrowser будут заменены.", + "skipped": "Пропущено: просроченных — {expired}, секционированных — {partitioned}.", + "confirm": "Заменить и импортировать", + "doneTitle": "Импорт завершён", + "doneDescription": "Импортировано файлов cookie: {count}. Открытые страницы браузера перезагружены.", + "keyDenied": "В доступе к Связке ключей отказано или не удалось прочитать ключ браузера.", + "previewExpired": "Источник изменился или срок действия предпросмотра истёк. Создайте новый предпросмотр.", + "encryptionUnsupported": "Этот профиль использует формат шифрования, который не поддерживается средством импорта.", + "failed": "Не удалось завершить импорт. Если изменение уже началось, существующие файлы cookie YoBrowser были восстановлены." + } }, "databaseRepair": { "title": "Восстановление базы данных", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 8f068f930a..6ae663f2ac 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -547,7 +547,33 @@ "clearedTitle": "YoBrowser verileri temizlendi", "clearedDescription": "Korumalı alan oturumu sıfırlandı ve ilgili sekme yeniden yüklenecek.", "clearFailedTitle": "Temizleme başarısız oldu", - "clearFailedDescription": "Lütfen tekrar deneyin veya daha fazla bilgi için günlükleri kontrol edin." + "clearFailedDescription": "Lütfen tekrar deneyin veya daha fazla bilgi için günlükleri kontrol edin.", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "privacyTitle": "Veri ve Gizlilik", "privacyDescription": "Yedeklemeyi, senkronizasyonu, gizlilik modunu, bakımı ve tehlikeli işlemleri yönetin.", diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index e41945e3b6..f30c90f986 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -547,7 +547,33 @@ "confirmDescription": "Cookie, bộ nhớ đệm, LocalStorage, IndexedDB và các dữ liệu khác của phiên YoBrowser sẽ bị xóa và bạn có thể cần phải đăng nhập lại vào trang web.", "confirmTitle": "Xác nhận xóa dữ liệu YoBrowser?", "description": "Hộp cát trình duyệt độc lập không chia sẻ cookie và bộ nhớ cục bộ với ứng dụng chính. \nDữ liệu YoBrowser có thể bị xóa chỉ bằng một cú nhấp chuột vào đây.", - "title": "Hộp cát YoBrowser" + "title": "Hộp cát YoBrowser", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "privacyTitle": "Dữ liệu & Quyền riêng tư", "privacyDescription": "Quản lý sao lưu, đồng bộ hóa, chế độ riêng tư, bảo trì và các hoạt động nguy hiểm.", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 8e806d61a4..0b9c247615 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -588,7 +588,33 @@ "clearedTitle": "已清空 YoBrowser 数据", "clearedDescription": "沙盒会话已重置,相关标签页将重新加载。", "clearFailedTitle": "清空失败", - "clearFailedDescription": "请重试或查看日志获取更多信息。" + "clearFailedDescription": "请重试或查看日志获取更多信息。", + "import": { + "button": "导入网站数据", + "title": "导入浏览器会话数据", + "description": "选择 Chrome 或 Arc 配置。macOS 可能要求授权访问钥匙串,源浏览器数据不会被修改。", + "scanning": "正在查找浏览器配置…", + "unsupportedTitle": "当前平台暂不支持", + "unsupportedDescription": "直接导入目前仅支持 macOS。Windows 的新版 Chrome 数据受应用绑定保护,因此不提供导入。", + "profileLabel": "来源配置", + "profilePlaceholder": "选择一个配置", + "noProfiles": "没有找到包含 Cookie 数据的 Chrome 或 Arc 配置。", + "cookies": "Cookie 与登录会话", + "supported": "本次导入包含", + "otherData": "密码与网页存储", + "notIncluded": "当前版本暂不包含", + "preview": "授权并预览", + "previewTitle": "可以替换 YoBrowser Cookie", + "previewDescription": "可导入 {count} 个 Cookie。现有 YoBrowser Cookie 将被覆盖。", + "skipped": "已跳过:过期 {expired} 个,分区 Cookie {partitioned} 个。", + "confirm": "覆盖并导入", + "doneTitle": "导入完成", + "doneDescription": "已导入 {count} 个 Cookie,并重新加载已打开的浏览器页面。", + "keyDenied": "钥匙串授权被拒绝,或无法读取浏览器密钥。", + "previewExpired": "来源数据已变化或预览已过期,请重新预览。", + "encryptionUnsupported": "该配置使用了当前导入器不支持的加密格式。", + "failed": "导入未能完成;如果已开始覆盖,原有 YoBrowser Cookie 已恢复。" + } }, "privacyTitle": "Data & Privacy", "privacyDescription": "管理备份同步、隐私模式、数据维护与危险操作。", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 47d9a15143..71bb4cb630 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "將刪除 YoBrowser 會話的 Cookie、緩存、LocalStorage、IndexedDB 等數據,可能需要重新登錄網站。", "confirmTitle": "確認清空 YoBrowser 數據?", "description": "獨立的瀏覽器沙盒,不與主應用共享 Cookie 和本地存儲。\n可在此一鍵清空 YoBrowser 數據。", - "title": "YoBrowser 沙盒" + "title": "YoBrowser 沙盒", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "databaseRepair": { "title": "資料庫修復", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 1ea922ae5d..09630a1a92 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -219,7 +219,33 @@ "confirmDescription": "將刪除 YoBrowser 會話的 Cookie、緩存、LocalStorage、IndexedDB 等數據,可能需要重新登錄網站。", "confirmTitle": "確認清空 YoBrowser 數據?", "description": "獨立的瀏覽器沙盒,不與主應用共享 Cookie 和本地存儲。\n可在此一鍵清空 YoBrowser 數據。", - "title": "YoBrowser 沙盒" + "title": "YoBrowser 沙盒", + "import": { + "button": "Import website data", + "title": "Import browser session data", + "description": "Choose a Chrome or Arc profile. macOS may ask you to authorize Keychain access. The source is never modified.", + "scanning": "Looking for browser profiles...", + "unsupportedTitle": "Not supported on this platform", + "unsupportedDescription": "Direct browser data import is currently available only on macOS. Windows remains unsupported because current Chrome data is app-bound.", + "profileLabel": "Source profile", + "profilePlaceholder": "Select a profile", + "noProfiles": "No supported Chrome or Arc profiles with cookie data were found.", + "cookies": "Cookies and sessions", + "supported": "Included in this import", + "otherData": "Passwords and web storage", + "notIncluded": "Not included in this version", + "preview": "Authorize and preview", + "previewTitle": "Ready to replace YoBrowser cookies", + "previewDescription": "{count} cookies can be imported. Existing YoBrowser cookies will be replaced.", + "skipped": "Skipped: {expired} expired, {partitioned} partitioned.", + "confirm": "Replace and import", + "doneTitle": "Import complete", + "doneDescription": "Imported {count} cookies. Open browser pages were reloaded.", + "keyDenied": "Keychain access was denied or the browser key could not be read.", + "previewExpired": "The source changed or the preview expired. Create a new preview.", + "encryptionUnsupported": "This profile uses an encryption format that this importer does not support.", + "failed": "Import failed before completion. Existing YoBrowser cookies were restored when mutation had started." + } }, "databaseRepair": { "title": "資料庫修復", diff --git a/src/renderer/src/lib/icons/icon-collections.generated.ts b/src/renderer/src/lib/icons/icon-collections.generated.ts index 5e88a411ca..e036858e96 100644 --- a/src/renderer/src/lib/icons/icon-collections.generated.ts +++ b/src/renderer/src/lib/icons/icon-collections.generated.ts @@ -353,6 +353,9 @@ export const lucideIconCollection = { images: { body: '' }, + import: { + body: '' + }, inbox: { body: '' }, @@ -395,9 +398,6 @@ export const lucideIconCollection = { 'list-tree': { body: '' }, - loader: { - body: '' - }, 'loader-circle': { body: '' }, @@ -479,6 +479,9 @@ export const lucideIconCollection = { 'panel-right-close': { body: '' }, + 'panel-right-open': { + body: '' + }, paperclip: { body: '' }, @@ -724,9 +727,6 @@ export const lucideIconCollection = { 'help-circle': { parent: 'circle-question-mark' }, - 'loader-2': { - parent: 'loader-circle' - }, 'message-circle-question': { parent: 'message-circle-question-mark' }, @@ -746,7 +746,7 @@ export const lucideIconCollection = { parent: 'circle-x' } }, - lastModified: 1782727192, + lastModified: 1778908382, width: 24, height: 24 } as const @@ -807,7 +807,7 @@ export const vscodeIconCollection = { } }, aliases: {}, - lastModified: 1782727390, + lastModified: 1779168064, width: 32, height: 32 } as const diff --git a/src/renderer/src/lib/icons/icon-whitelist.generated.ts b/src/renderer/src/lib/icons/icon-whitelist.generated.ts index e6e57a70ea..8ae68d8513 100644 --- a/src/renderer/src/lib/icons/icon-whitelist.generated.ts +++ b/src/renderer/src/lib/icons/icon-whitelist.generated.ts @@ -124,6 +124,7 @@ export const GENERATED_ICON_WHITELIST: Record { const clampWidth = (nextWidth: number) => { const maxWidth = resolveMaxWidth() - const minWidth = Math.min(420, maxWidth) + const minWidth = Math.min(360, maxWidth) const widthValue = Number(nextWidth) if (!Number.isFinite(widthValue)) { return Math.min(maxWidth, Math.max(minWidth, 520)) diff --git a/src/renderer/src/views/ChatTabView.vue b/src/renderer/src/views/ChatTabView.vue index ace6dc4c06..fb5f2245eb 100644 --- a/src/renderer/src/views/ChatTabView.vue +++ b/src/renderer/src/views/ChatTabView.vue @@ -29,6 +29,9 @@ + ( + (value) => value instanceof Uint8Array && value.byteLength <= 512 * 1024 + ), + timestamp: TimestampMsSchema + }) +}) diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index ff7c690a24..0c847ceea2 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -3,6 +3,7 @@ import type { RouteContract } from './common' import { acpTerminalInputRoute, acpTerminalKillRoute } from './routes/acp-terminal.routes' import { browserAttachCurrentWindowRoute, + browserApplyImportRoute, browserClearSandboxDataRoute, browserDestroyRoute, browserDetachRoute, @@ -10,7 +11,10 @@ import { browserGoBackRoute, browserGoForwardRoute, browserLoadUrlRoute, + browserPreviewImportRoute, browserReloadRoute, + browserScanImportSourcesRoute, + browserSetPreviewModeRoute, browserUpdateCurrentWindowBoundsRoute } from './routes/browser.routes' import { @@ -679,6 +683,10 @@ const DEEPCHAT_ROUTE_CATALOG_PART_2 = { [browserGoForwardRoute.name]: browserGoForwardRoute, [browserReloadRoute.name]: browserReloadRoute, [browserClearSandboxDataRoute.name]: browserClearSandboxDataRoute, + [browserScanImportSourcesRoute.name]: browserScanImportSourcesRoute, + [browserSetPreviewModeRoute.name]: browserSetPreviewModeRoute, + [browserPreviewImportRoute.name]: browserPreviewImportRoute, + [browserApplyImportRoute.name]: browserApplyImportRoute, [tabCaptureCurrentAreaRoute.name]: tabCaptureCurrentAreaRoute, [tabStitchImagesWithWatermarkRoute.name]: tabStitchImagesWithWatermarkRoute } satisfies Record diff --git a/src/shared/contracts/routes/browser.routes.ts b/src/shared/contracts/routes/browser.routes.ts index b7a577f983..c3a81c16d1 100644 --- a/src/shared/contracts/routes/browser.routes.ts +++ b/src/shared/contracts/routes/browser.routes.ts @@ -56,6 +56,18 @@ export const browserDetachRoute = defineRouteContract({ }) }) +export const browserSetPreviewModeRoute = defineRouteContract({ + name: 'browser.setPreviewMode', + input: z.object({ + sessionId: z.string().min(1), + mode: z.enum(['capturing', 'rendering', 'stopped']), + runId: z.string().min(1).optional() + }), + output: z.object({ + updated: z.boolean() + }) +}) + export const browserDestroyRoute = defineRouteContract({ name: 'browser.destroy', input: z.object({ @@ -103,3 +115,55 @@ export const browserClearSandboxDataRoute = defineRouteContract({ cleared: z.boolean() }) }) + +const BrowserImportCapabilityReasonSchema = z.enum([ + 'platform_unsupported', + 'browser_not_found', + 'profile_data_missing' +]) + +const BrowserImportProfileSchema = z.object({ + id: z.string().min(1), + browser: z.enum(['chrome', 'arc']), + browserName: z.string().min(1), + profileName: z.string().min(1), + supported: z.boolean(), + reason: BrowserImportCapabilityReasonSchema.optional() +}) + +export const browserScanImportSourcesRoute = defineRouteContract({ + name: 'browser.import.scan', + input: z.object({}).default({}), + output: z.object({ + platformSupported: z.boolean(), + profiles: z.array(BrowserImportProfileSchema), + reason: BrowserImportCapabilityReasonSchema.optional() + }) +}) + +export const browserPreviewImportRoute = defineRouteContract({ + name: 'browser.import.preview', + input: z.object({ + profileId: z.string().min(1) + }), + output: z.object({ + token: z.string().min(1), + profile: BrowserImportProfileSchema, + cookieCount: z.number().int().nonnegative(), + skippedExpired: z.number().int().nonnegative(), + skippedPartitioned: z.number().int().nonnegative() + }) +}) + +export const browserApplyImportRoute = defineRouteContract({ + name: 'browser.import.apply', + input: z.object({ + token: z.string().min(1) + }), + output: z.object({ + importedCookies: z.number().int().nonnegative(), + skippedExpired: z.number().int().nonnegative(), + skippedPartitioned: z.number().int().nonnegative(), + syncedAt: z.number().int().nonnegative() + }) +}) diff --git a/src/shared/types/browser.ts b/src/shared/types/browser.ts index dbd5bfa99b..2f8e7ac66b 100644 --- a/src/shared/types/browser.ts +++ b/src/shared/types/browser.ts @@ -23,6 +23,43 @@ export interface YoBrowserStatus { canGoForward: boolean visible: boolean loading: boolean + owner?: 'agent' | 'user' + agentRunId?: string +} + +export type BrowserImportCapabilityReason = + | 'platform_unsupported' + | 'browser_not_found' + | 'profile_data_missing' + +export interface BrowserImportProfile { + id: string + browser: 'chrome' | 'arc' + browserName: string + profileName: string + supported: boolean + reason?: BrowserImportCapabilityReason +} + +export interface BrowserImportScanResult { + platformSupported: boolean + profiles: BrowserImportProfile[] + reason?: BrowserImportCapabilityReason +} + +export interface BrowserImportPreview { + token: string + profile: BrowserImportProfile + cookieCount: number + skippedExpired: number + skippedPartitioned: number +} + +export interface BrowserImportApplyResult { + importedCookies: number + skippedExpired: number + skippedPartitioned: number + syncedAt: number } export interface ScreenshotOptions { diff --git a/src/shared/types/desktop.ts b/src/shared/types/desktop.ts index 3db2b60068..c1f59953a2 100644 --- a/src/shared/types/desktop.ts +++ b/src/shared/types/desktop.ts @@ -1,5 +1,13 @@ import type { BrowserWindow, WebContents, WebContentsView } from 'electron' -import type { BrowserPageInfo, DownloadInfo, ScreenshotOptions, YoBrowserStatus } from './browser' +import type { + BrowserImportApplyResult, + BrowserImportPreview, + BrowserImportScanResult, + BrowserPageInfo, + DownloadInfo, + ScreenshotOptions, + YoBrowserStatus +} from './browser' import type { MCPToolDefinition } from './mcp' import type { ProviderInstallPreview } from '@shared/providerDeeplink' import type { SettingsNavigationPayload } from '@shared/settingsNavigation' @@ -113,6 +121,12 @@ export interface IYoBrowserPresenter { visible: boolean ): Promise detachSessionBrowser(sessionId: string): Promise + setPreviewMode( + sessionId: string, + mode: 'capturing' | 'rendering' | 'stopped', + hostWindowId?: number, + runId?: string + ): Promise destroySessionBrowser(sessionId: string): Promise goBack(sessionId: string): Promise goForward(sessionId: string): Promise @@ -125,13 +139,17 @@ export interface IYoBrowserPresenter { getBrowserPage(sessionId: string): Promise startDownload(url: string, savePath?: string): Promise clearSandboxData(): Promise + scanImportSources(): Promise + previewImport(profileId: string): Promise + applyImport(token: string): Promise shutdown(): Promise readonly toolHandler: { getToolDefinitions(): MCPToolDefinition[] callTool( toolName: string, args: Record, - conversationId?: string + conversationId?: string, + runId?: string ): Promise } } diff --git a/src/shared/types/tool.d.ts b/src/shared/types/tool.d.ts index f42256b627..0ffef8eeb8 100644 --- a/src/shared/types/tool.d.ts +++ b/src/shared/types/tool.d.ts @@ -33,6 +33,7 @@ export interface ToolDefinitionContext { } export interface ToolCallOptions { + runId?: string onProgress?: (update: AgentToolProgressUpdate) => void signal?: AbortSignal permissionMode?: PermissionMode diff --git a/test/main/desktop/browser/BrowserProfileImportService.test.ts b/test/main/desktop/browser/BrowserProfileImportService.test.ts new file mode 100644 index 0000000000..9d2805d050 --- /dev/null +++ b/test/main/desktop/browser/BrowserProfileImportService.test.ts @@ -0,0 +1,195 @@ +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createCipheriv, createHash, pbkdf2Sync } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Cookie, CookiesSetDetails, Session } from 'electron' +import { BrowserProfileImportService } from '@/desktop/browser/BrowserProfileImportService' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +const createCookie = (details: CookiesSetDetails): Cookie => ({ + name: details.name, + value: details.value, + domain: details.domain ?? new URL(details.url).hostname, + hostOnly: details.domain === undefined, + path: details.path ?? '/', + secure: details.secure ?? false, + httpOnly: details.httpOnly ?? false, + session: details.expirationDate === undefined, + sameSite: details.sameSite ?? 'unspecified' +}) + +const createSession = (options?: { corruptImportedValue?: boolean }) => { + let cookies: Cookie[] = [ + createCookie({ + url: 'https://old.example/', + name: 'old', + value: 'old-value' + }) + ] + const set = vi.fn(async (details: CookiesSetDetails) => { + const nextCookie = createCookie({ + ...details, + value: options?.corruptImportedValue && details.name === 'session' ? 'corrupt' : details.value + }) + const identity = (cookie: Cookie) => `${cookie.domain}\0${cookie.path}\0${cookie.name}` + cookies = cookies.filter((cookie) => identity(cookie) !== identity(nextCookie)) + if ((details.expirationDate ?? Number.POSITIVE_INFINITY) > Date.now() / 1000) { + cookies.push(nextCookie) + } + }) + const target = { + clearStorageData: vi.fn(), + cookies: { + get: vi.fn(async () => [...cookies]), + set, + flushStore: vi.fn(async () => undefined) + } + } as unknown as Session + const readCookies = () => cookies + const readUnpartitionedCookies = async () => + readCookies().map((cookie) => ({ + url: `${cookie.secure ? 'https' : 'http'}://${cookie.domain?.replace(/^\./, '')}${cookie.path}`, + name: cookie.name, + value: cookie.value, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.hostOnly ? {} : { domain: cookie.domain }), + ...(cookie.session ? {} : { expirationDate: cookie.expirationDate }) + })) + return { target, readCookies, readUnpartitionedCookies } +} + +const stageImport = async (service: BrowserProfileImportService) => { + const directory = await mkdtemp(join(tmpdir(), 'deepchat-import-test-')) + temporaryDirectories.push(directory) + const cookiePath = join(directory, 'Cookies') + await writeFile(cookiePath, 'snapshot') + const sourceStat = await stat(cookiePath) + + const internals = service as unknown as { + stagedImports: Map + } + internals.stagedImports.set('preview-token', { + createdAt: Date.now(), + profile: { + id: 'chrome:Default', + browser: 'chrome', + browserName: 'Google Chrome', + profileName: 'Default', + supported: true, + source: {}, + directoryName: 'Default', + cookiePath + }, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + cookies: [ + { + url: 'https://example.com/', + name: 'session', + value: 'session-value', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' + } + ], + skippedExpired: 2, + skippedPartitioned: 1 + }) +} + +describe('BrowserProfileImportService', () => { + it('reports direct import as unsupported outside macOS', async () => { + const service = new BrowserProfileImportService( + () => ({}) as Session, + async () => [], + 'win32' + ) + + await expect(service.scan()).resolves.toEqual({ + platformSupported: false, + profiles: [], + reason: 'platform_unsupported' + }) + }) + + it('decrypts a macOS Chromium v10 cookie with the schema host digest', () => { + const service = new BrowserProfileImportService( + () => ({}) as Session, + async () => [], + 'darwin' + ) + const host = '.example.com' + const key = pbkdf2Sync('keychain-password', 'saltysalt', 1003, 16, 'sha1') + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20)) + const plaintext = Buffer.concat([ + createHash('sha256').update(host).digest(), + Buffer.from('session-value') + ]) + const encryptedValue = Buffer.concat([ + Buffer.from('v10'), + cipher.update(plaintext), + cipher.final() + ]) + + const internals = service as unknown as { + decryptChromeCookie( + row: { host_key: string; encrypted_value: Buffer }, + key: Buffer, + schemaVersion: number + ): string + } + expect( + internals.decryptChromeCookie({ host_key: host, encrypted_value: encryptedValue }, key, 24) + ).toBe('session-value') + }) + + it('replaces target cookies and verifies the imported values', async () => { + const { target, readCookies, readUnpartitionedCookies } = createSession() + const service = new BrowserProfileImportService( + () => target, + readUnpartitionedCookies, + 'darwin' + ) + await stageImport(service) + + await expect(service.apply('preview-token')).resolves.toMatchObject({ + importedCookies: 1, + skippedExpired: 2, + skippedPartitioned: 1 + }) + expect(readCookies()).toHaveLength(1) + expect(readCookies()[0]).toMatchObject({ name: 'session', value: 'session-value' }) + expect(target.clearStorageData).not.toHaveBeenCalled() + }) + + it('restores target cookies when readback verification fails', async () => { + const { target, readCookies, readUnpartitionedCookies } = createSession({ + corruptImportedValue: true + }) + const service = new BrowserProfileImportService( + () => target, + readUnpartitionedCookies, + 'darwin' + ) + await stageImport(service) + + await expect(service.apply('preview-token')).rejects.toThrow( + 'browser_import_verification_failed' + ) + expect(readCookies()).toHaveLength(1) + expect(readCookies()[0]).toMatchObject({ name: 'old', value: 'old-value' }) + expect(target.clearStorageData).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/desktop/browser/YoBrowserPresenter.test.ts b/test/main/desktop/browser/YoBrowserPresenter.test.ts index 4eb32f1ec2..cfaf9eb95c 100644 --- a/test/main/desktop/browser/YoBrowserPresenter.test.ts +++ b/test/main/desktop/browser/YoBrowserPresenter.test.ts @@ -51,6 +51,12 @@ class MockWebContents extends EventEmitter { this.emit('destroyed') }) sendInputEvent = vi.fn() + setBackgroundThrottling = vi.fn() + capturePage = vi.fn(async () => ({ + resize: vi.fn(() => ({ + toJPEG: vi.fn(() => Buffer.from('preview-frame')) + })) + })) constructor(id: number) { super() @@ -126,6 +132,21 @@ class MockBrowserWindow extends EventEmitter { } } +class MockBaseWindow extends EventEmitter { + contentView = new MockContentView() + destroyed = false + setIgnoreMouseEvents = vi.fn() + + isDestroyed() { + return this.destroyed + } + + destroy() { + this.destroyed = true + this.emit('closed') + } +} + describe('YoBrowserPresenter', () => { beforeEach(() => { vi.resetModules() @@ -145,6 +166,7 @@ describe('YoBrowserPresenter', () => { let nextWebContentsId = 100 const windows = new Map() const viewConfigs: Array> = [] + const previewHosts: MockBaseWindow[] = [] vi.doMock('electron', () => { class MockWebContentsView { @@ -152,6 +174,7 @@ describe('YoBrowserPresenter', () => { setBorderRadius = vi.fn() setBackgroundColor = vi.fn() setBounds = vi.fn() + setVisible = vi.fn() constructor(options: Record) { viewConfigs.push(options) @@ -163,6 +186,12 @@ describe('YoBrowserPresenter', () => { app: { getPath: vi.fn(() => 'C:/mock-user-data') }, + BaseWindow: class extends MockBaseWindow { + constructor() { + super() + previewHosts.push(this) + } + }, BrowserWindow: { fromId: (id: number) => windows.get(id) ?? null }, @@ -186,6 +215,7 @@ describe('YoBrowserPresenter', () => { vi.doMock('@/desktop/browser/yoBrowserSession', () => ({ getYoBrowserSession: () => ({}), + getYoBrowserUnpartitionedCookies: vi.fn(async () => []), clearYoBrowserSessionData: vi.fn() })) @@ -215,7 +245,11 @@ describe('YoBrowserPresenter', () => { }), closeWindow: vi.fn(async () => undefined), getFocusedWindow: vi.fn(() => windows.get(1) ?? null), - getAllWindows: vi.fn(() => Array.from(windows.values())) + getAllWindows: vi.fn(() => Array.from(windows.values())), + sendToWindow: vi.fn((windowId: number, channel: string, envelope: unknown) => { + sendToAllWindowsMock(channel, envelope, windowId) + return true + }) } const presenter = new YoBrowserPresenter(windowPresenter as any, (name, payload) => { @@ -231,6 +265,7 @@ describe('YoBrowserPresenter', () => { presenter, windows, viewConfigs, + previewHosts, getSessionWebContents } } @@ -425,6 +460,75 @@ describe('YoBrowserPresenter', () => { expect(JSON.stringify(activityPayloads)).not.toContain('example.com') }) + it('captures a bounded preview frame without creating a second page', async () => { + const { presenter, windows, previewHosts, getSessionWebContents } = await setupPresenter() + windows.set(1, new MockBrowserWindow(1)) + + const loadPromise = presenter.loadUrl( + 'session-a', + 'https://example.com', + undefined, + 1, + 'agent', + 'run-1' + ) + await Promise.resolve() + const webContents = getSessionWebContents('session-a') + webContents?.emitDomReady() + await loadPromise + sendToAllWindowsMock.mockClear() + + expect(await presenter.setPreviewMode('session-a', 'capturing', 1, 'run-1')).toBe(true) + await vi.advanceTimersByTimeAsync(1) + + expect(previewHosts).toHaveLength(1) + expect(webContents?.capturePage).toHaveBeenCalledWith( + { x: 0, y: 0, width: 1280, height: 800 }, + { stayHidden: true } + ) + expect(sendToAllWindowsMock).toHaveBeenCalledWith( + 'deepchat:event', + expect.objectContaining({ + name: 'browser.preview.frame', + payload: expect.objectContaining({ + sessionId: 'session-a', + runId: 'run-1', + width: 480, + height: 300, + mimeType: 'image/jpeg' + }) + }), + 1 + ) + + const captureCount = webContents?.capturePage.mock.calls.length + expect(await presenter.setPreviewMode('session-a', 'rendering', 1, 'run-1')).toBe(true) + await vi.advanceTimersByTimeAsync(1100) + expect(webContents?.capturePage).toHaveBeenCalledTimes(captureCount ?? 0) + + expect(await presenter.setPreviewMode('session-a', 'stopped', 1, 'run-1')).toBe(true) + expect(previewHosts[0].destroyed).toBe(true) + }) + + it('rejects preview capture for a stale Agent run', async () => { + const { presenter, windows, getSessionWebContents } = await setupPresenter() + windows.set(1, new MockBrowserWindow(1)) + + const loadPromise = presenter.loadUrl( + 'session-a', + 'https://example.com', + undefined, + 1, + 'agent', + 'run-1' + ) + await Promise.resolve() + getSessionWebContents('session-a')?.emitDomReady() + await loadPromise + + expect(await presenter.setPreviewMode('session-a', 'capturing', 1, 'run-old')).toBe(false) + }) + it('maps agent CDP mouse and screenshot commands to overlay activity', async () => { const { presenter, windows, getSessionWebContents } = await setupPresenter() windows.set(1, new MockBrowserWindow(1)) @@ -555,7 +659,7 @@ describe('YoBrowserPresenter', () => { expect(extractPoint('document.querySelector("button")?.click()')).toBeUndefined() }) - it('returns focus to the host renderer after attaching the browser view', async () => { + it('does not steal focus from the attached browser view', async () => { const { presenter, windows, getSessionWebContents } = await setupPresenter() const hostWindow = new MockBrowserWindow(1) windows.set(1, hostWindow) @@ -568,7 +672,7 @@ describe('YoBrowserPresenter', () => { await presenter.attachSessionBrowser('session-a', 1) await Promise.resolve() - expect(hostWindow.webContents.focus).toHaveBeenCalled() + expect(hostWindow.webContents.focus).not.toHaveBeenCalled() }) it('does not show overlay activity when the host window is backgrounded', async () => { diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index be96045f22..ecc2147c37 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -72,6 +72,7 @@ describe('main kernel contracts', () => { 'acpTerminal.kill', 'browser.attachCurrentWindow', 'browser.clearSandboxData', + 'browser.setPreviewMode', 'databaseSecurity.repairSchema', 'debug.createMockChatSession', 'config.addManualAcpAgent', @@ -1700,6 +1701,7 @@ describe('main kernel contracts', () => { 'appRuntime.windowFocused', 'browser.activity.changed', 'browser.open.requested', + 'browser.preview.frame', 'browser.status.changed', 'chat.plan.updated', 'chat.stream.completed', @@ -1782,6 +1784,37 @@ describe('main kernel contracts', () => { expect(new Set(eventKeys).size).toBe(eventKeys.length) }) + it('accepts only byte arrays for browser preview frames', () => { + const payload = { + sessionId: 'session-1', + runId: 'run-1', + sequence: 0, + width: 480, + height: 300, + mimeType: 'image/jpeg', + timestamp: Date.now() + } as const + + expect( + DEEPCHAT_EVENT_CATALOG['browser.preview.frame'].payload.safeParse({ + ...payload, + data: new Uint8Array([1, 2, 3]) + }).success + ).toBe(true) + expect( + DEEPCHAT_EVENT_CATALOG['browser.preview.frame'].payload.safeParse({ + ...payload, + data: new Uint16Array([1, 2, 3]) + }).success + ).toBe(false) + expect( + DEEPCHAT_EVENT_CATALOG['browser.preview.frame'].payload.safeParse({ + ...payload, + data: new DataView(new ArrayBuffer(3)) + }).success + ).toBe(false) + }) + it('validates typed chat stream payloads', () => { expect(() => chatStreamUpdatedEvent.payload.parse({ diff --git a/test/renderer/api/clients.test.ts b/test/renderer/api/clients.test.ts index 09d317e099..099ec8d512 100644 --- a/test/renderer/api/clients.test.ts +++ b/test/renderer/api/clients.test.ts @@ -943,6 +943,29 @@ describe('renderer api clients', () => { return { updated: true } case 'browser.clearSandboxData': return { cleared: true } + case 'browser.import.scan': + return { platformSupported: true, profiles: [] } + case 'browser.import.preview': + return { + token: 'preview-token', + profile: { + id: payload?.profileId, + browser: 'chrome', + browserName: 'Google Chrome', + profileName: 'Default', + supported: true + }, + cookieCount: 3, + skippedExpired: 1, + skippedPartitioned: 0 + } + case 'browser.import.apply': + return { + importedCookies: 3, + skippedExpired: 1, + skippedPartitioned: 0, + syncedAt: 123 + } case 'window.closeSettings': return { closed: true } case 'window.focusMain': @@ -2089,6 +2112,36 @@ describe('renderer api clients', () => { expect(bridge.invoke).toHaveBeenCalledWith('browser.clearSandboxData', {}) }) + it('routes browser preview mode through the shared registry name', async () => { + const bridge = createBridge() + const browserClient = createBrowserClient(bridge) + + await browserClient.setPreviewMode('session-1', 'capturing', 'run-1') + + expect(bridge.invoke).toHaveBeenCalledWith('browser.setPreviewMode', { + sessionId: 'session-1', + mode: 'capturing', + runId: 'run-1' + }) + }) + + it('routes browser website-data import through typed registry names', async () => { + const bridge = createBridge() + const browserClient = createBrowserClient(bridge) + + await browserClient.scanImportSources() + await browserClient.previewImport('chrome:Default') + await browserClient.applyImport('preview-token') + + expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'browser.import.scan', {}) + expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'browser.import.preview', { + profileId: 'chrome:Default' + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(3, 'browser.import.apply', { + token: 'preview-token' + }) + }) + it('routes database security operations through the shared registry names', async () => { const bridge = createBridge() const databaseSecurityClient = createDatabaseSecurityClient(bridge) @@ -2749,4 +2802,14 @@ describe('renderer api clients', () => { expect(bridge.on).toHaveBeenCalledWith('browser.activity.changed', listener) }) + + it('subscribes to browser preview frames', () => { + const bridge = createBridge() + const browserClient = createBrowserClient(bridge) + const listener = vi.fn() + + browserClient.onPreviewFrame(listener) + + expect(bridge.on).toHaveBeenCalledWith('browser.preview.frame', listener) + }) }) diff --git a/test/renderer/components/AgentBrowserPiP.test.ts b/test/renderer/components/AgentBrowserPiP.test.ts new file mode 100644 index 0000000000..676fa8740d --- /dev/null +++ b/test/renderer/components/AgentBrowserPiP.test.ts @@ -0,0 +1,285 @@ +import { defineComponent, nextTick, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { YoBrowserStatus } from '@shared/types/browser' + +const mountedWrappers: Array<{ unmount: () => void }> = [] + +const createStatus = (runId = 'run-1'): YoBrowserStatus => ({ + initialized: true, + page: { + id: 'page-1', + url: 'https://example.com', + title: 'Example', + status: 'ready' as never, + createdAt: 1, + updatedAt: 1 + }, + canGoBack: false, + canGoForward: false, + visible: false, + loading: false, + owner: 'agent', + agentRunId: runId +}) + +const setup = async (options: { wide?: boolean } = {}) => { + vi.resetModules() + if (options.wide) { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}) + }) + } + let statusChangedHandler: + | ((payload: { sessionId: string; status: YoBrowserStatus | null }) => void) + | null = null + let previewFrameHandler: + | ((payload: { + sessionId: string + runId: string + sequence: number + width: number + height: number + mimeType: 'image/jpeg' + data: Uint8Array + timestamp: number + }) => void) + | null = null + let windowStateHandler: ((payload: { exists: boolean; isFocused: boolean }) => void) | null = null + const status = createStatus() + const browserClient = { + getStatus: vi.fn(async () => status), + setPreviewMode: vi.fn(async () => true), + onOpenRequestedForCurrentWindow: vi.fn(() => vi.fn()), + onStatusChanged: vi.fn( + (handler: (payload: { sessionId: string; status: YoBrowserStatus | null }) => void) => { + statusChangedHandler = handler + return vi.fn() + } + ), + onActivityChanged: vi.fn(() => vi.fn()), + onPreviewFrame: vi.fn((handler: NonNullable) => { + previewFrameHandler = handler + return vi.fn() + }) + } + const windowClient = { + getCurrentState: vi.fn(async () => ({ exists: true, isFocused: true })), + onCurrentStateChanged: vi.fn((handler: NonNullable) => { + windowStateHandler = handler + return vi.fn() + }) + } + const sidepanelStore = reactive({ + open: false, + activeTab: 'workspace', + openBrowser: vi.fn(() => { + sidepanelStore.open = true + sidepanelStore.activeTab = 'browser' + }) + }) + const sessionStore = reactive({ + sessions: [{ id: 'session-1', status: 'working' }] + }) + + vi.doMock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + vi.doMock('@iconify/vue', () => ({ + Icon: defineComponent({ name: 'Icon', template: '' }) + })) + vi.doMock('@api/BrowserClient', () => ({ createBrowserClient: () => browserClient })) + vi.doMock('@api/WindowClient', () => ({ createWindowClient: () => windowClient })) + vi.doMock('@/stores/ui/sidepanel', () => ({ useSidepanelStore: () => sidepanelStore })) + vi.doMock('@/stores/ui/session', () => ({ useSessionStore: () => sessionStore })) + + const AgentBrowserPiP = (await import('@/components/browser/AgentBrowserPiP.vue')).default + const wrapper = mount(AgentBrowserPiP, { + props: { sessionId: 'session-1' }, + global: { + stubs: { + Button: defineComponent({ + name: 'Button', + emits: ['click'], + template: '' + }) + } + } + }) + mountedWrappers.push(wrapper) + await flushPromises() + + return { + wrapper, + browserClient, + sidepanelStore, + sessionStore, + emitStatus: statusChangedHandler!, + emitPreviewFrame: previewFrameHandler!, + emitWindowState: windowStateHandler! + } +} + +afterEach(async () => { + mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount()) + await flushPromises() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('AgentBrowserPiP', () => { + it('shows a compact activity bar for an active Agent run and hides when the loop ends', async () => { + const { wrapper, browserClient, sessionStore } = await setup() + + expect(wrapper.find('[data-testid="agent-browser-pip"]').exists()).toBe(true) + + sessionStore.sessions[0].status = 'completed' + await nextTick() + await flushPromises() + + expect(wrapper.find('[data-testid="agent-browser-pip"]').exists()).toBe(false) + expect(browserClient.setPreviewMode).toHaveBeenCalledWith('session-1', 'stopped', 'run-1') + }) + + it('moves the active Agent browser into the sidepanel on request', async () => { + const { wrapper, browserClient, sidepanelStore } = await setup() + + await wrapper.get('[aria-label="common.open"]').trigger('click') + await flushPromises() + + expect(browserClient.setPreviewMode).toHaveBeenCalledWith('session-1', 'stopped', 'run-1') + expect(sidepanelStore.openBrowser).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-testid="agent-browser-pip"]').exists()).toBe(false) + }) + + it('stays dismissed for the current run', async () => { + const { wrapper } = await setup() + + await wrapper.get('[aria-label="common.close"]').trigger('click') + await nextTick() + + expect(wrapper.find('[data-testid="agent-browser-pip"]').exists()).toBe(false) + }) + + it('reveals controls on click and drags from the mirror surface', async () => { + const { wrapper, browserClient } = await setup({ wide: true }) + const pip = wrapper.get('[data-testid="agent-browser-pip"]') + + const toolbar = wrapper.get('[data-testid="agent-browser-pip-toolbar"]') + expect(toolbar.classes()).toContain('group-hover:opacity-100') + expect(wrapper.find('[data-testid="agent-browser-pip-drag-hint"]').exists()).toBe(true) + expect(pip.attributes('style')).toContain('width: 400px') + expect(pip.attributes('style')).toContain('height: 250px') + await pip.trigger('pointerdown', { button: 0, pointerId: 1, clientX: 100, clientY: 100 }) + await pip.trigger('pointerup', { pointerId: 1, clientX: 100, clientY: 100 }) + expect(toolbar.classes()).toContain('opacity-100') + + const leftBefore = pip.attributes('style') + await pip.trigger('pointerdown', { button: 0, pointerId: 2, clientX: 100, clientY: 100 }) + await pip.trigger('pointermove', { pointerId: 2, clientX: 150, clientY: 130 }) + await pip.trigger('pointerup', { pointerId: 2, clientX: 150, clientY: 130 }) + + expect(pip.attributes('style')).not.toBe(leftBefore) + expect(browserClient.setPreviewMode).toHaveBeenCalledWith('session-1', 'capturing', 'run-1') + }) + + it('draws a decoded preview frame into the local Canvas', async () => { + const drawImage = vi.fn() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as never) + const close = vi.fn() + vi.stubGlobal( + 'createImageBitmap', + vi.fn().mockResolvedValueOnce({ close }).mockRejectedValueOnce(new Error('decode failed')) + ) + const { wrapper, emitPreviewFrame } = await setup({ wide: true }) + + emitPreviewFrame({ + sessionId: 'session-1', + runId: 'run-1', + sequence: 1, + width: 480, + height: 300, + mimeType: 'image/jpeg', + data: new Uint8Array([1, 2, 3]), + timestamp: Date.now() + }) + await flushPromises() + + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 480, 300) + expect(close).toHaveBeenCalled() + expect(wrapper.find('[data-testid="agent-browser-pip-placeholder"]').exists()).toBe(false) + + emitPreviewFrame({ + sessionId: 'session-1', + runId: 'run-1', + sequence: 2, + width: 480, + height: 300, + mimeType: 'image/jpeg', + data: new Uint8Array([4, 5, 6]), + timestamp: Date.now() + }) + await flushPromises() + + expect(drawImage).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-testid="agent-browser-pip-placeholder"]').exists()).toBe(false) + }) + + it('accepts a reset frame sequence when the Agent run changes', async () => { + const drawImage = vi.fn() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as never) + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ close: vi.fn() })) + ) + const { wrapper, emitStatus, emitPreviewFrame } = await setup({ wide: true }) + + emitPreviewFrame({ + sessionId: 'session-1', + runId: 'run-1', + sequence: 10, + width: 480, + height: 300, + mimeType: 'image/jpeg', + data: new Uint8Array([1]), + timestamp: Date.now() + }) + await flushPromises() + + emitStatus({ sessionId: 'session-1', status: createStatus('run-2') }) + await nextTick() + expect(wrapper.find('[data-testid="agent-browser-pip-placeholder"]').exists()).toBe(true) + + emitPreviewFrame({ + sessionId: 'session-1', + runId: 'run-2', + sequence: 0, + width: 480, + height: 300, + mimeType: 'image/jpeg', + data: new Uint8Array([2]), + timestamp: Date.now() + }) + await flushPromises() + + expect(drawImage).toHaveBeenCalledTimes(2) + expect(wrapper.find('[data-testid="agent-browser-pip-placeholder"]').exists()).toBe(false) + }) + + it('keeps rendering in the background without publishing frames when the window blurs', async () => { + const { wrapper, browserClient, emitWindowState } = await setup({ wide: true }) + + emitWindowState({ exists: true, isFocused: false }) + await nextTick() + await flushPromises() + + expect(wrapper.find('[data-testid="agent-browser-pip"]').exists()).toBe(false) + expect(browserClient.setPreviewMode).toHaveBeenCalledWith('session-1', 'rendering', 'run-1') + }) +}) diff --git a/test/renderer/components/ChatSidePanel.test.ts b/test/renderer/components/ChatSidePanel.test.ts index 6dbab952e4..a309682a22 100644 --- a/test/renderer/components/ChatSidePanel.test.ts +++ b/test/renderer/components/ChatSidePanel.test.ts @@ -129,6 +129,42 @@ describe('ChatSidePanel', () => { expect(sidepanelStore.openBrowser).toHaveBeenCalledTimes(1) }) + it('keeps a closed sidepanel closed for Agent browser activity', async () => { + const { sidepanelStore, emitOpenRequested } = await setup({ + open: false, + activeTab: 'workspace' + }) + + emitOpenRequested({ + windowId: 7, + sessionId: 'session-1', + url: 'https://example.com', + source: 'agent', + runId: 'run-1', + version: Date.now() + }) + + expect(sidepanelStore.openBrowser).not.toHaveBeenCalled() + }) + + it('switches an open workspace sidepanel to Browser for Agent activity', async () => { + const { sidepanelStore, emitOpenRequested } = await setup({ + open: true, + activeTab: 'workspace' + }) + + emitOpenRequested({ + windowId: 7, + sessionId: 'session-1', + url: 'https://example.com', + source: 'agent', + runId: 'run-1', + version: Date.now() + }) + + expect(sidepanelStore.openBrowser).toHaveBeenCalledTimes(1) + }) + it('dispatches session-scoped workspace insertion requests from the workspace panel', async () => { const insertionListener = vi.fn() window.addEventListener(WORKSPACE_EVENTS.INSERT_REFERENCE_REQUESTED, insertionListener) diff --git a/test/renderer/components/ChatTabView.test.ts b/test/renderer/components/ChatTabView.test.ts index 4733f35cef..8bd45e7de0 100644 --- a/test/renderer/components/ChatTabView.test.ts +++ b/test/renderer/components/ChatTabView.test.ts @@ -153,6 +153,13 @@ const setup = async (options: SetupOptions = {}) => { template: '
' }) })) + + vi.doMock('@/components/browser/AgentBrowserPiP.vue', () => ({ + default: defineComponent({ + name: 'AgentBrowserPiP', + template: '
' + }) + })) vi.doMock('@/pages/AgentWelcomePage.vue', () => ({ default: defineComponent({ name: 'AgentWelcomePage', diff --git a/test/setup.ts b/test/setup.ts index ddb7b02cca..2782b2d396 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -21,6 +21,7 @@ function getDefaultDeepchatInvokeResult( case 'browser.attachCurrentWindow': return { attached: true } case 'browser.updateCurrentWindowBounds': + case 'browser.setPreviewMode': return { updated: true } case 'browser.detach': return { detached: true }