Commit graph

29 commits

Author SHA1 Message Date
Danila Poyarkov 8bf71bd92f
feat(ai): add guided AI setup for providers, coding agents, and Pi (#916)
* feat(storybook): prototype guided AI setup and task assignments

* refactor(storybook): adopt shared control foundations

* refactor(storybook): build AI setup on current settings foundations

Move the prototype to settings/ai-setup and compose SettingsSection, SettingsGroup, SettingsRow, AppAlert, AppBadge, and AppCheckbox instead of local section, status, and badge markup. Replace the nonexistent danger color and raw amber with the error and warning tokens.

* feat(ui): add a shared radio group

AppRadioGroup wraps the Reka radio group with typed options, labels each radio by its option text with any description as its accessible description, and supports all arrow keys unless an orientation is set. The AI setup wizard uses it for the spending choice, named by the step heading, and shares the choice card style with its checkboxes.

* fix(ui): draw unchecked checkboxes on the field background

AppCheckbox filled its box with the surface (text) color, so unchecked boxes were nearly black in the light theme and nearly white in the dark theme. Use the panel field background and accent hover border shared with the radio group and switch.

* refactor(storybook): drop the simplified AI connections panel

AI setup has two modes: skippable guided onboarding for most people and the existing advanced settings for power users, both editing the same model settings. Remove the third, simplified connections and tasks panel. The wizard's Advanced settings action and the returning-user screen now stand in for ModelsPanel, which offers Run guided setup. Removing Gateway's own Back button also fixes the blank screen it led to.

* feat(ai): plan guided AI setup from the model catalog

planOnboarding proposes design and vision models from the access a person already has, using the real provider and agent catalog, and falls back to OpenRouter only when pay-as-you-go is allowed. applyOnboardingPlan merges a confirmed plan into the model settings, reusing matching connections and profiles, keeping roles it was not asked about, and dropping only the empty fresh-install profile.

* refactor(ai): share model provider display names

Move the provider, agent, and Pi display-name lookup out of the model settings workflow so guided setup can reuse it.

* feat(ai): offer guided AI setup over the real model settings

Guided setup asks what AI should help with, what access the person
already has, and whether pay-as-you-go models are allowed, then
proposes design and vision models from the provider and agent catalog.
Connections reuse the provider key field and connection test, keys are
saved through the credential manager, and the confirmed plan is merged
into the model settings, keeping anything configured by hand. Saving
reports saved, partial, or failed like the profile editor.

A fresh install whose model settings are still the empty placeholder is
offered setup once with a skippable welcome; existing setups never see
it. Settings → AI & agents can run it again, and Advanced settings hands
off to the model editor. The Storybook fixtures for agents, OpenRouter
sign-in, Vercel AI Gateway, and the local server are replaced by the
real flow, with all copy translated.

Browser tests start with the offer dismissed through the shared
Playwright storage state; the first-run spec clears it.

* fix(ai): keep configured models and credentials safe in guided setup

Running guided setup again planned from the catalog defaults, so it
replaced hand-configured design and vision models and dropped their
settings; it now keeps a configured model while its access is still
selected or onboarding cannot offer that provider, and keeps vision when
nothing new covers it. Reused profiles must have the capabilities the
plan relies on, and an explicit vision assignment without image input is
cleared.

A server's saved key and its status now apply only to the connection at
the address being entered, and its connection test uses that
connection's API type. Entered keys are copied before saving, so closing
setup mid-save no longer drops them, and the Models list refreshes key
status after setup saves a key. Servers that do not check keys get a
hint to enter any value, and a step that only keeps configured models
says so instead of showing nothing.

* test(ai): check key status right after guided setup

The Models list must show a key saved by guided setup as connected without reopening Settings.

* feat(ai): sign in to OpenRouter from guided setup

OpenRouter can now be connected with its OAuth PKCE flow instead of a
pasted key. In the browser, sign-in opens in a popup that returns to a
static callback page on the app's origin, which relays the redirect to
the editor over a BroadcastChannel, so the editor never navigates away.
The desktop app opens the system browser and receives the redirect on
a one-shot 127.0.0.1 listener, the localhost callback OpenRouter
documents. Either way the editor checks the state, exchanges the code
for a key directly with OpenRouter, fills it in, and runs the
connection test. Waiting, blocked pop-ups, cancellation, expiry, and
failures are reported in the step, which keeps the pasted-key path.

Setup no longer offers Clear for a saved key, since removing keys
belongs to the advanced settings, and the service worker leaves
/oauth/ pages to the network.

* fix(settings): report unreadable model keys as unavailable

A saved key the browser credential store could not read, for example one left from an older session on the same origin, rejected the model status refresh and the startup credential check, which surfaced as a global error toast. Each read failure now marks only that connection as unavailable.

* feat(ai): map every role in guided setup and verify OpenRouter sign-in

Guided setup now proposes a model for design, vision, review, and fast
work, and the review step is a map of those roles with every suitable
model from the connected providers and a "Use recommended setup"
shortcut. Fast work defaults to the provider's catalog model tagged as
fast; behind an agent, review and fast work use the API model chosen
for vision. Review and fast work are never asked about, so a configured
choice, including none, stays unless it follows a design model it can
no longer follow.

The pay-as-you-go question only appears when the access already
selected leaves a requested role without a model, so choosing
OpenRouter or another account no longer asks it.

After signing in with OpenRouter, setup checks the key with
OpenRouter's key endpoint, which costs no credits, instead of a text
generation test. The step then says it is signed in, names the key,
warns when the account has no credits yet, and offers another account
in place of the key field and test button.

* feat(ai): offer OpenRouter only for goals nothing selected covers

The separate pay-as-you-go step asked an abstract question even when
the selected access already covered every goal. The connect step now
names a goal nothing selected can cover, such as visual review behind
a coding agent or a local server, and offers to add OpenRouter for it;
once added, it says OpenRouter fills the gap and can be removed again.
Setup can finish without visual review, but not without a design model.

A local or company server can be marked as able to read images, which
lets it cover visual review and makes it the preferred vision model
over a paid account.

* feat(ai): show provider logos and more providers in guided setup

Guided setup shows monochrome logos for coding agents, API accounts,
and local servers, from LobeHub's MIT-licensed static SVG set loaded as
an `ai` icon collection, so they follow the theme like Lucide icons.
DeepSeek, Z.ai, and MiniMax are offered under "More providers", and a
local server can start from the Ollama or LM Studio address instead of
typing it.

* feat(ai): guide coding agent setup in guided setup

Choosing Claude Code, Codex, or Gemini CLI in the desktop app now checks
whether the agent's ACP program and OpenPencil's MCP server, which
agents use to reach the canvas, are installed. An allowlisted
agent_lookup command finds the program on the same widened PATH as the
MCP lookup. The card shows install commands only for what is missing,
checks again on request, links a new setup guide, and copies a prompt
that asks an agent the person already uses to install both, confirm
they are on PATH, and sign in. In the browser, the agent section links
to the desktop app.

* fix(ai): space the More providers toggle like a group heading

The toggle sat flush against the account cards above and below it; it now reads as a group heading with the same rhythm as the other sections.

* feat(ai): detect and install coding agents in guided setup

Adopt the local agent discovery from #847. A desktop agent_lookup
command reports each agent's own CLI, its ACP adapter, npm, and
OpenPencil's MCP server on the widened PATH without starting any of
them, and the app can install a missing adapter or the MCP server with
npm, limited by the shell capability to those exact packages and the
MCP version that matches the app. Guided setup now tells "installed but
the OpenPencil adapter is missing" apart from "not installed", offers
one-click installs, links each vendor's own setup guide, and keeps the
manual commands and setup prompt for the browser, missing npm, or a
failed install. Codex install instructions move to
@agentclientprotocol/codex-acp, which replaces @zed-industries/codex-acp.

Kiro CLI support from the same pull request is left for a separate
change, since it needs ACP transport work.

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>

* build(app): resolve LobeHub icons with import.meta.resolve

The architecture lint forbids createRequire in ESM build code.

* test(app): seed AI setup specs through storageState

Follows the storage seeding used by other browser specs and the import type rule.

* feat(ai): set up Pi with its own sign-ins in guided setup

Pi now runs with the providers signed in to in the Pi CLI and Pi's
default model. The Harness companion reuses ~/.pi/agent; the app reads
only Pi's settings.json for the default model and never auth.json. A
saved key is still used as an AI Gateway key.

Guided setup offers Pi next to the other coding agents. On the desktop
it checks the Harness companion, the MCP server, and Pi's default
model, and installs the companion with one click through npm.

Agent discovery now reads the installed versions of the MCP server and
the Harness companion from their package.json without starting them.
Setup flags a version that does not match the app and shows the update
command for the package manager that installed it, instead of
reporting the server as installed and failing at the first message.

* feat(ai): check agent companions before a chat starts

A Pi chat without the Harness companion, or any agent chat whose
companion or MCP server version does not match the app, failed when the
process started and showed only the generic request error. The chat now
checks the companions through agent discovery first and names the fix,
with an action that opens guided setup. Pi sign-in and model problems
use the same path.

The Pi model editor shows the same companion, MCP server, and default
model status as guided setup, and no longer requires a model ID, since
Pi falls back to the default model set in Pi.

Supersedes the companion detection in #566, which ran the companion to
read its version and required an exact version match.

* fix(harness): start Pi sessions with MCP tools and keep unsent messages

Pi chats in the desktop app always configure OpenPencil's MCP server,
and three companion problems stopped them:

- @ai-sdk/harness-pi imports pi-mcp-adapter, which publishes TypeScript
  sources. Node refuses to strip types under node_modules, so the
  companion now strips them through a module load hook limited to
  TypeScript dependencies. Bun runs them as is.
- pi-mcp-adapter imports @earendil-works/pi-tui, declared only as an
  optional peer, so npm left it out. The companion depends on it at the
  version pi-coding-agent uses.
- Pi reports live-process resume, yet the service handed it state saved
  by an earlier session, and the just-bash sandbox cannot resume, so
  every later session with that ID failed. Live-process backends now
  start fresh and drop saved state.

When a chat cannot start, the composer now keeps the typed message
instead of discarding it.

* fix(harness): keep companion stdout for protocol messages

Pi prepares the packages listed in a person's Pi settings with npm,
which inherits the companion's stdout, and libraries log through
console.log. Both landed in the JSONL protocol stream, where the app
discarded them with warnings. The companion now keeps the real stdout
for protocol messages, sends other stdout writes to stderr, and quiets
npm on success through its environment.

The type-stripping hook no longer prints Node's experimental warning,
and the app logs companion stderr as diagnostics rather than errors,
since failures arrive as protocol errors.

Document Pi in the coding agents guide, the AI chat page, and the
README: guided setup installs the companion, Pi uses the Pi CLI's
sign-ins and default model, an AI Gateway key is optional, and the
companion needs Node.js 22.15 or later.

* test(harness): keep the pi-tui pin in step with pi-coding-agent

The companion depends on pi-tui only because pi-mcp-adapter imports it while declaring it optional (nicobailon/pi-mcp-adapter#805). Upgrading @ai-sdk/harness-pi moves pi-coding-agent, and a pin left behind would make npm install a second, mismatched pi-tui. The test fails until the pin matches.

* test(ai): follow the model catalog in guided setup tests

The plan and apply tests repeated catalog default and fast model IDs, so master's model update broke them without any change in setup behavior. They now read those models from the catalog.

* test(ai): keep the model catalog helper with the shared test helpers

Unit test homes under tests/app accept only *.test.ts files, so the onboarding tests' catalog helper moves to tests/helpers/ai.

* docs: tighten the guided setup and Pi changelog entries

Name every provider and server preset guided setup offers, describe the role step as it now works, and shorten the Pi entry.

* test(ai): type the guided setup test stubs for the test typecheck

Master now typechecks the test suites: fetch fakes go through fetchStub, the chat ref is shallow like the real one, and mocks declare the arguments the tests inspect.

* test: type the tabs module in the closed-documents spec

The spec imported the tabs module by its served URL without a type, which fails the test type check on master.

* test(ai): assert outcomes instead of copy in guided setup tests

Drop the setup-prompt test, which checked prompt prose, the onboarding wrapper cases that restated discovery, and the coversGoals case. Story plays and the OpenRouter E2E flow now assert controls and saved models instead of sentences and catalog model names, and the fast-model helper checks the planned model's catalog entry instead of recomputing the choice. tests/AGENTS.md states the rule.

* feat(ai): return desktop OpenRouter sign-in through a deep link

The desktop app ran a hand-written HTTP server on a localhost port to receive OpenRouter's redirect, and OpenRouter labels apps with a localhost callback by host and port. OpenRouter now redirects to a page on the web app that opens openpencil://oauth/openrouter with the same query, and the desktop shell forwards that link to the webview as an oauth-callback event. The attempt that started sign-in checks the state and exchanges the code with its PKCE verifier, which never leaves the app.

* feat(ai): ask OpenPencil's companions for their version

The desktop app read a companion's version by following its executable's symlink up to a package.json. That only worked for the Unix npm and bun layouts: Windows .cmd and .exe shims and version-manager shims such as Volta and mise are not links into the package, so the version was always unknown and an outdated companion went unreported. The MCP server, stdio bridge, and Harness companion now print their version for --version, and the app runs each installed one with --version --help under a timeout. A release older than --version prints its help or exits without a version line, which reads as outdated. A bun global install on Windows now gets the bun update command too.

* fix(ai): ask for a Pi sign-in when Pi has none

readPiAccount returned an account whenever a home folder existed, so a chat with no Pi sign-in reached the Harness and failed with a provider error instead of the guided pi-sign-in fix. It now reports whether Pi's auth.json exists, without reading it, and the capability allows that one check.

* fix(ai): keep the attachments of a message that was not sent

A message that never reached the chat came back to the composer as text only: its image previews were revoked and its referenced layers dropped. The composer now takes back the whole submission, or releases the previews when newer text replaced it. A message counts as sent once the chat holds it, so a failure after that no longer hands it back to be sent twice.

* refactor(app): read the app version from one constant

Four modules each derived the app version from the build define with the same test fallback.

* refactor(ai): report chat submission errors from their own module

Reverting turns from master and keeping unsent drafts together took useChatSubmission past the composition-root limit. The test for reverted turns now passes the setup messages the submission reports.

* refactor(app): keep the app version with the runtime config

Tools typecheck src/constants.ts through app imports without the Vite defines, so the version constant moves to src/app/runtime/version.ts.

* test(desktop): check npm installs of the companions at the app's release version

The scope test named the companion packages and version literally, so it would keep passing if the app requested something else. It now builds them from the app's package names and the release version a build embeds.

* refactor(ai): parse OpenRouter, Pi, and sign-in callback data with Valibot

The OpenRouter key info and code exchange checked their JSON with typeof chains, Pi's settings parsed JSON in a try before validating it, and the desktop sign-in trusted the shell's callback payload as typed. Each now goes through one schema.

* fix(ai): take back a message whose images could not be prepared

A message with images appears in the chat before its images are prepared, so a preparation failure counted as sent: the draft did not come back and its previews were already revoked. A message now counts as sent once it is dispatched; a failure before that removes the shown message and hands the draft back, and the composer's previews are revoked only after dispatch.

* fix(ai): restore an unsent message only in its own conversation

Switching conversations while a message was being sent could restore it into the newly opened one. The draft now comes back only if the conversation is unchanged, and its previews are released otherwise.

* refactor(app): keep one app version constant

The update window added an APP_VERSION to src/constants.ts beside the one in src/app/runtime/version.ts. The tools typecheck reaches src/constants.ts without the Vite defines, so the update window now reads the runtime one.

---------

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>
2026-10-07 08:46:57 +00:00
Danila Poyarkov 55d8c57822
feat(desktop): show updates in a Software Update window (#936)
* refactor(app): share Markdown rendering outside chat

Move the vue-stream-markdown wrapper, inline code, token mapping, and
styles out of chat into components/markdown and theme/markdown, so other
surfaces can render Markdown without importing chat components. A density
attribute selects the compact chat styles or a comfortable reading size,
and Shiki highlighting is opt-in. ChatMarkdown keeps its streaming render
key and wraps the shared component.

* refactor(ui): extract AppProgress from the toast

The toast drew its own progress track, fill, and label. Move them into
AppProgress in ui/feedback, built on Reka's Progress so the bar reports
its value and indeterminate state, with an accent tone for panels and a
current-colour tone for coloured surfaces. ToastProgress becomes the
shared ProgressAmount. AppPlaceholder also accepts an h1 label for
placeholders that stand for a whole window.

* feat(desktop): show updates in a Software Update window

The update prompt passed the release's CHANGELOG section to the native
confirm dialog, which cannot format Markdown or scroll, so the 0.15.1
notes showed raw headings and pushed the buttons off screen.

When the main window finds an update it now opens a small `updater`
webview loading its own `updater.html` entry, which never boots the
editor. The window renders the notes with the shared Markdown component
in a scrolling box and links to the full release page.

Installing is split into stages. The download shows progress and can be
cancelled; Tauri cannot abort it, so Cancel detaches and a later Install
reuses the running download. On macOS and Linux the update then installs
and the window offers Restart Now or Later. A restart, and on Windows the
installer that quits the app, first asks the editor window to run the
same unsaved-documents approval as Quit. Failed checks, downloads,
installs, and restarts keep the release visible and can be retried. The
window's capability grants only update, restart, close, and link
opening.

Closes #743

* fix(desktop): stop waiting for a restart reply from a closed editor

The Software Update window waited for the editor's answer with no bound,
so an editor closed mid-request left Restart Now, and the Windows
install, stuck. Treat the editor window's destruction as approval: it
ran its own unsaved-changes prompt and holds no documents. A timeout
would instead fail people still answering that prompt.
2026-10-06 14:57:31 +00:00
Danila Poyarkov 22d5dfe6b5
fix: refuse risky desktop writes and run every Storybook play function (#933)
* fix(desktop): refuse writes where a written file would run

The fs scope let the webview write, create folders, and delete anywhere. Documents may still be saved anywhere, but the global scope now denies login items and startup folders, PowerShell profiles, and the global package and executable folders where coding agents and OpenPencil's companions live, and writes to the MCP discovery files agents trust. requireLiteralLeadingDot keeps hidden files and folders, such as shell profiles and agent settings, out of ** on Windows as Tauri already does on macOS and Linux. A native test saves a document and is refused a LaunchAgents file, a home dotfile, and the discovery file.

* fix(ui): draw segmented controls at panel field height

Panel fields moved to 24px when sizing tokens became plain utilities, but segmented control items stayed 22px inside a 2px padding, so the Typography and resizing controls stood 2px taller than the fields beside them. The panel foundation story renders its inputs at the panel size and checks 24px.

* test(storybook): run every story and its play function

No test ran the play functions, and five had gone stale: the layer tree example labelled a wrapper with the same name as its row, the chat composer's label gained an ellipsis, the MCP failure story queried a test id attribute the app does not use, and the property primitives story still collapsed sections whose titles are static now. bun run test:storybook now renders every story and fails on a story or play function that throws.

* fix(desktop): deny protected folders themselves and writable opens of the discovery files

Each protected folder is denied alongside its contents, so a recursive remove cannot target the folder itself, and the MCP discovery files are denied to open as well as write, since opening with truncate would empty them. The native test opens the discovery file for writing without truncating, and removes only files it created. The story test waits for storyFinished, which follows afterEach, and judges exceptions and non-accessibility reports.

* test(desktop): run the file scope check only on macOS

Its protected paths are macOS ones, so other platforms skip it rather than pass for another reason.

* docs: note that documents opened from hidden folders can still be saved
2026-10-06 14:52:12 +00:00
Danila Poyarkov bd7acd6e34
fix(desktop): pin every command line the app may start (#922)
* fix(desktop): pin every command line the app may start

On Windows the app starts npm-installed CLIs through cmd /c, and the shell scope let cmd take any arguments, so any code running in the webview could run any command. Each program now has a scope entry with its exact arguments, and Windows shims go through their own cmd-<name> entries with fixed /c <name> arguments. The agents and the MCP server no longer accept arbitrary arguments either. A test checks that every command the app starts has a matching entry on both platforms.

* test(desktop): expect Windows shims through their own scope entries

* test(desktop): check the executable of each shell scope entry

* test(desktop): allow no shell scope entry beyond the programs the app starts

An extra entry with a permissive validator passed the per-program checks.
2026-10-06 11:19:05 +00:00
Danila Poyarkov f2485d6926
fix(desktop): allow reading recent .fig files for thumbnails (#872)
* fix(desktop): allow reading recent .fig files for thumbnails

The recent-files thumbnail loader stats a .fig file, opens it, and seeks
and reads its thumbnail entry, but the desktop capability granted none of
those fs commands, so the WebView rejected the first call ("fs.stat not
allowed") and no thumbnail ever loaded. Grant stat and open with the same
path scope as read-file, plus the handle commands they lead to.

A native spec runs the loader's command sequence on a real .fig file.

* test(desktop): check the bytes the recent-file thumbnail read returns

The native spec discarded the read result, so a read returning nothing would pass. Assert the zip signature and the byte count the fs plugin appends, through a bytes variant of the native invoke helper.
2026-10-04 13:03:20 +00:00
Marc Went 511bb481ac
feat: open documents and layers from openpencil:// and web links (#708)
* feat(desktop): register openpencil:// deep link scheme

Signed-off-by: Marc Went <marc@went.io>

* feat(desktop): parse openpencil://open?file&node links

Signed-off-by: Marc Went <marc@went.io>

* feat(desktop): queue openpencil:// links as pending opens

Signed-off-by: Marc Went <marc@went.io>

* fix(desktop): read cold-start deep links on windows/linux

Signed-off-by: Marc Went <marc@went.io>

* feat(app): resolve openpencil:// links and select the target node

A link's file is repo-relative, so it is resolved against the paths of the
open tabs and otherwise located once by the user through the dialog picker.
Nothing else is read from disk and no fs scope is widened. The node is matched
by exact name on the current page, selected and zoomed to; a missing node
raises a notice instead of failing silently.

Signed-off-by: Marc Went <marc@went.io>

* docs: document the openpencil:// URL scheme

Describe the link format, the relative-path rule, how the file is resolved
against open tabs or a one-time picker, and that the scheme can only open a
document and select a layer.

Signed-off-by: Marc Went <marc@went.io>

* test(app): cover cancelled deep-link picks

Inject the file picker and open entry points into openDeepLink so the test can
drive the branch where the picked file is not the requested one. The repository
lint forbids module registry mocking, and the existing file batch helper takes
its opener the same way.

Reword the module comment: the opened file joins the recent-files list like any
other opened file, and a one-segment file matches the first open tab whose path
ends with it.

Signed-off-by: Marc Went <marc@went.io>

* docs: sharpen the URL scheme notes

Selecting by name selects every layer with that name on the current page and
zooms to the whole selection. Record that the first matching open tab wins,
that path separators may stay literal in the query, and how the scheme reaches
the app on each platform.

Signed-off-by: Marc Went <marc@went.io>

* refactor(app): keep the deep-link io type internal

Nothing outside the module names the injected io type, so contextual typing at
the call site is enough. Drop the redundant recording array from the cancelled
pick test.

Signed-off-by: Marc Went <marc@went.io>

* fix(desktop): tag pending opens by producer

The frontend classified a pending entry by the shape of its path, which
called a canonicalized Windows path (`\\?\C:\…`) relative and sent a
double-clicked document into the deep-link resolver. Rust now says which
producer queued the entry, and the tail both producers shared moves into
`queue_pending`.

Signed-off-by: Marc Went <marc@went.io>

* test(app): assert the opener receives the resolved path

Signed-off-by: Marc Went <marc@went.io>

* test(desktop): refuse a percent-encoded parent segment

Signed-off-by: Marc Went <marc@went.io>

* chore(desktop): relax the deep-link plugin pin

Signed-off-by: Marc Went <marc@went.io>

* chore(desktop): drop the unused deep-link capability

Draining links is Rust-side, so the webview never calls
`deep-link:allow-get-current`.

Signed-off-by: Marc Went <marc@went.io>

* fix(app): clamp link values in notices

Signed-off-by: Marc Went <marc@went.io>

* fix(desktop): pass deep links through the linux desktop entry

The bundler's default desktop template writes `Exec={{exec}}` with no
field code, so a Linux cold start from a deb, rpm or AppImage never
receives the `openpencil://` link as an argument and `get_current()`
has nothing to recover. Ship a custom template that is the bundler
default plus `%U`, wired to both the deb and rpm bundlers (AppImage
reuses the deb data dir). MIME types still come from `{{mime_type}}`,
so the file associations are unchanged.

Signed-off-by: Marc Went <marc@went.io>

* feat(app): open documents from ?file= links in the browser

The desktop build takes openpencil:// links; the web app had no equivalent.
It now reads file and node off its own address bar on boot, fetches the
document from an absolute https URL without credentials and without
following redirects, selects the named layer through the same path the
deep link uses, and strips both params so a reload does not re-open.

Signed-off-by: Marc Went <marc@went.io>

* fix(app): keep router state coherent when stripping web link params

Rewriting history directly left the router's own record of the current URL
pointing at the un-stripped one, so the next router.push wrote file and node
back into the history entry. The strip is now an injected action that goes
through router.replace, preserving the route, hash and every other query key.

Also clamp the failure detail, take the last value of a repeated key like the
desktop parser does, share deep-link's clamp instead of copying it, and report
a failed fetch through toast.error.

Signed-off-by: Marc Went <marc@went.io>

* fix(app): resolve deep links by filesystem case and bound remote fetches

Deep links resolved their file by comparing path segments in JavaScript,
which is case-sensitive: on macOS and Windows `Web/Design/hikyo.pen` and
`web/design/hikyo.pen` name the same file, yet both the open-tab lookup and
the picker check refused it and the link was cancelled. The comparison now
goes through a `path_matches_suffix` Tauri command that canonicalizes the
candidate and folds ASCII case on macOS and Windows while staying exact on
Linux. `resolveDeepLinkFile` takes the comparator as an argument, so it
stays testable without Tauri, and the rule is tested in `deep_link.rs`.

An already open document is focused through `activateTabForPath` instead of
`openFileFromPath`, which re-read the file from disk first and rejected the
whole link when it had moved or lost its permissions since the tab opened
it. The picker branch still opens the file, and a tab that closed between
the snapshot and the activate falls back to opening it.

A web link's `file` URL drops its fragment. The tab identity compares source
URLs exactly, so two links to one document differing only in fragment opened
two tabs.

A document fetched from a URL is capped at 64 MiB, counted off the streamed
body rather than the sender's `Content-Length`, with the request aborted the
moment it goes over instead of buffering whatever the host decides to send.

Draining the pending-open queue goes through `openDesignFileBatch`, the
per-item catch every other open path already uses, so one failing entry no
longer skips the rest of the batch.

Signed-off-by: Marc Went <marc@went.io>

* fix(desktop): match a deep-link suffix against the literal path too

Canonicalizing the candidate resolves a symlink that sits inside the trailing
segments, so a monorepo checkout where `packages/web` links to `../apps/web`
would stop matching a link that spells the path the way the tab does. Compare
both spellings: the canonical path keeps `..` and prefix symlinks working, the
literal one keeps the path the user actually sees. Both inputs are already-open
or user-picked paths, so trying the literal one grants nothing new.

Signed-off-by: Marc Went <marc@went.io>

* fix(app): cap the automation fetch and chain a caller's abort signal

`openBrowserFileFromURL` replaced a caller-supplied `signal` with the one the
size cap needs, so a caller could no longer cancel its own request. The two
are chained instead: the caller's abort aborts the cap's controller, and an
already-aborted signal is honoured before the fetch goes out.

`handleOpenFile` in the automation bridge was the last fetch buffering an
unbounded body. It reads a document the same way, so it gets the same 64 MiB
ceiling, counted off the stream and aborted on overflow. Its relative-path
resolution and its lack of a format assert are unchanged.

`path_matches_suffix` runs `async`, so `canonicalize` cannot block the main
thread on a stale network mount, and it now refuses an absolute or
`..`-bearing suffix: `parse_open_url` already does, but this is the comparison
every caller funnels through and an absolute suffix would otherwise match on
its segments alone. The command itself gained tests over a real temp tree —
exact match, the platform case rule, the symlinked trailing directory that
motivated the literal fallback, a missing file, and the refusals.

The docs and the module header claimed an opened file always joins the recent
files list, in the same breath as saying an already open tab is focused
without re-reading it. Only the former opens anything, so only the former
touches the list.

Signed-off-by: Marc Went <marc@went.io>

* docs(changelog): note the 64 MiB ceiling on the automation bridge openFile

Signed-off-by: Marc Went <marc@went.io>

* fix(app): deliver cold-start deep links through the deep-link path

macOS hands a launch `openpencil://` link to the app as `RunEvent::Opened`
before the app's `setup` closure runs. Traced on a cold `open`:
`RunEvent::Opened` at T+0.085 s, `setup` at T+0.342 s, and `on_open_url` never
fired. The plugin's `deep-link://new-url` emit therefore reached no listener
and the URL survived only in the plugin's `current`, which was drained under
`#[cfg(any(windows, target_os = "linux"))]` on the assumption that macOS was
unaffected. It is not: a cold link launched the app to an empty tab with no
picker, no toast and no log line, while the same link fired at a running app
worked. The drain now runs on every desktop platform; `register_all` stays
gated, macOS does not support it.

Nothing is queued twice. `RunEvent::Opened` is dispatched on the thread that
runs `setup`, so a link cannot arrive between registering `on_open_url` and
reading `current`, and anything later is no longer in `current`. A cold
double-clicked document is unaffected: `current` now also yields its `file://`
URL, and the `scheme == "openpencil"` filter in `queue_deep_links` drops it,
leaving `queue_open_paths` the only producer for that path.

The pending-open routing moves out of `WorkspaceView.vue` into
`app/document/io/pending-open.ts`, so which entry reaches the deep-link
resolver and which reaches the plain opener is unit-testable without mounting
the view. A drain that fails wholesale — the `take_pending_open` invoke, the
event binding — now raises a toast instead of only a console line; per-entry
failures were already toasted.

Signed-off-by: Marc Went <marc@went.io>

* refactor(app): share one bounded body reader

readBodyWithLimit reimplemented the chunked cap that vectorize's
readBoundedResponse already applied, and it lived in the menu module while
the automation bridge imported it from there.

Move the reader to the browser document-io owner as readBoundedBody,
returning bytes with an optional overflow hook and error message, and have
both the document fetch and the vectorize providers use it. The automation
bridge now opens a browser file through openBrowserFileFromURL instead of
re-inlining fetch, cap and tab creation, so it also gets the same format
check as the Tauri path, and the caller's abort signal is combined with the
cap's controller through AbortSignal.any.

* fix(app): report a failed tab activation

activateTabForPath returned true after calling switchTab, but switchTab
silently does nothing when the tab is gone. A tab that closed while the
identity lookup awaited therefore looked focused, and the caller skipped
opening the file, so the link did nothing at all.

Return whether a tab was actually activated.

* fix(app): translate the document link notices

The four notices added for document links existed only in the English
defaults, so a localized build showed English toasts. check:i18n does not
cover the app-level notification catalog, which is why nothing caught it.

Also correct the docs: a `.` segment is refused along with `..`, matching
the matcher.

* fix(desktop): refuse a dot segment in deep links

The parser accepted `web/./design.pen` while path_ends_with_segments
refuses `.`, so such a link was queued and could then never match an open
tab or a picked file — it failed silently after asking the user to locate
the file.

Refuse `.` alongside `..` in the parser and drop the whitespace-only line
left in the capability file.

* refactor(app): tidy the document link plumbing

Four smaller things from review:

- A dismissed file picker is not a wrong file, so it no longer reports
  "expected a file ending in …", which named a file the user never chose.
- Reuse es-toolkit's omit for stripping the link params, as the MCP
  settings form already does.
- Drop the openDesignFileBatch re-export from menu/use.ts; nothing
  imports it from there.
- Move the exact-name lookup out of the view: selectNodesByName lives with
  the other selection helpers and walks the graph directly, instead of
  building a whole FigmaAPI facade from the automation bridge to answer
  one query.

* refactor(app): centralize focusing nodes

The name lookup was a link-shaped helper in the selection domain, and it
baked one strategy into the action. Split it into the two things a caller
actually needs: focusNodes(ids) is the select-and-zoom primitive that
share and collaboration references want, and focusNodesByName resolves an
exact name on the current page first.

The store dependency is a narrow interface, as with the viewport actions,
so the action is unit-testable and stale ids can be ignored instead of
selected.

---------

Signed-off-by: Marc Went <marc@went.io>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-18 14:45:49 +03:00
Danila Poyarkov 9d2b97e679
fix: protect unsaved documents and defer credential access (#713)
* fix(app): protect unsaved documents when closing

Mark tabs with unsaved content updates and ask whether to save before
closing them. The prompt now covers tab closes, the desktop window close
button, and the application Quit action, which previously discarded work
when autosave had no writable target.

Track a content revision separately from scene and recovery versions so a
save only clears the indicator when it wrote the revision it captured.
Cancelled pickers, failed writes, and edits made during a save keep the
document open. Desktop uses the platform alert; the browser keeps the
styled dialog.

The desktop menu replaces the predefined Quit item so the accelerator and
Dock-independent quit path request confirmation instead of exiting.

* fix(ai): resolve credentials only when used

Opening a document, creating a chat, or browsing chat history connected
the provider and read saved secrets, which triggered system credential
prompts without user intent.

Startup now reads credential status only, migration runs inside the first
explicit resolution, and the chat panel initializes local history without
creating a transport. Stock-photo keys resolve per search instead of at
settings refresh, and credentials still marked legacy count as configured
so upgrading does not appear to lose them.

* refactor(ai): export diagnostics from Settings only

Chat kept its own debug log, copied mixed app-wide usage into a
conversation export, and reported a missing cache rate as zero. Remove
that surface and record AI requests, model steps, and tool activity as
correlated diagnostic events instead.

Settings remains the single export location, usage summaries can now
distinguish unreported telemetry from zero, and transcript or tool
payloads are no longer part of the export.

* fix(ai): clear legacy credentials for real

Clearing a Pexels, Unsplash, or provider key only removed the current
store entry. A value that still lived in legacy storage kept the key
configured, so a later search migrated and used the credential the user
had just removed.

Migrate before mutating so clearing also removes the legacy value, and
share one in-flight migration so the media and provider paths cannot
migrate the same plaintext twice.

* fix(ai): scope credential migration per source

Sharing one migration promise process-wide let a second storage return
the first migration's result, leaving its own legacy keys unmigrated
while reporting success. Track in-flight migrations per storage and
serialize them, because every migration writes to the same store and
concurrent runs could overwrite each other.

* fix(app): destroy the window after a confirmed close

Tauri's onCloseRequested helper destroys the window itself when a handler
returns without preventing the event. Approving a close therefore invoked
plugin:window|destroy, which the capability set did not grant, so the
window stayed open with a permission error after saving.

Always intercept the request and destroy the window explicitly once the
choice is confirmed, and grant core🪟allow-destroy in place of the
now-unused close permission.

* fix(app): show a filled dot for unsaved tabs

The unsaved indicator used a stroked Lucide circle whose fill attribute
kept it an empty outline, reading as a disabled control. Draw the
indicator as a filled accent dot matching the status dots used elsewhere
in the app.

* refactor(app): focus the unsaved prompt with VueUse

Replace the manual watcher, nextTick, and component $el focus with
useFocus, which focuses the Save button when the dialog mounts. Assert the
focus in the close-protection test so the Return-saves behavior stays
covered.

* refactor(app): route Quit through the shared menu channel

The Quit item emitted a bespoke app:request-exit event and the close
module listened for it, while every other native item travels as a
menu-event id dispatched by the shell and editor menu composables.

Emit menu-event "quit" for both the Quit item and the platform exit
request, handle it in useShellMenu beside check-updates, and share one
confirmAppExit so window closes and app exits agree on a single approval.

* refactor(app): generate the macOS app menu entries

The application menu hardcoded its labels and the Quit accelerator in
Rust while every other menu entry is generated from APP_MENU_SCHEMA.
Move the custom app entries (About, Check for Updates, Quit) into
APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id
so the native builder cannot silently drop a label.

Placement stays in Rust because the OS-predefined items sit between them,
and the menu title now comes from the packaged product name.

* build(tauri-menu): check generated menus against the schema

The generated menu files are committed but nothing verified them, so a
schema edit could silently leave desktop/generated stale until the next
release build regenerated it.

Split the renderers from the write step, register the tool as a workspace
so its dependencies resolve, and compare the committed files with the
schema in a test that runs with the other tool checks.

* fix(app): serialize exit confirmations

The window close handler and the Quit item both call confirmAppExit, and
the per-handler closing flag does not cover the two paths. Both could run
close preparation, so an unsaved document could be prompted twice.

Share one in-flight confirmation and clear it when it settles, so a
cancelled or failed attempt still prompts again on the next request.
2026-09-17 15:25:15 +03:00
Danila Poyarkov 62ba072b26
refactor(ai): ship Harness as optional companion (#561)
* Revert "feat(ai): add HarnessAgent sidecar foundation (#560)"

This reverts commit 83a5ea1b42.

* Revert "Revert "feat(ai): add HarnessAgent sidecar foundation (#560)""

This reverts commit 0d8c03515888c62dc47186d4a3b0b7b04e78f8af.

* refactor(ai): ship Harness as optional companion

* fix(ai): support Harness companion on Windows

* test(ai): restore navigator after Harness Windows test
2026-08-19 20:04:35 +03:00
Danila Poyarkov 83a5ea1b42
feat(ai): add HarnessAgent sidecar foundation (#560)
* feat(ai): add HarnessAgent sidecar foundation

* feat(ai): integrate Pi as a Harness provider

* fix(ai): harden Harness provider integration

* fix(ai): address Harness integration review

* fix(ai): align Harness code with project conventions

* fix(ai): preserve Harness sessions on cancellation

* test(ai): cover Harness JSONL framing

* refactor(ai): generalize Harness sidecar adapters

* fix(ci): build Harness sidecar for native checks
2026-08-19 17:41:10 +03:00
Danila Poyarkov a2161aef63 fix(desktop): narrow MCP discovery permissions
- Limit text reads and existence checks to platform discovery files
- Drop unused create and open capabilities
- Document restored desktop automation reconnection
2026-08-19 16:49:21 +03:00
xemc 3f6cb88b5e fix(desktop): grant missing fs-plugin permissions for exists/read_text_file/create/open
The default capability only granted read_file/write_file/mkdir/remove/watch,
but the app also calls exists(), readTextFile(), create(), and open() from
@tauri-apps/plugin-fs. Each fs-plugin command requires its own explicit
permission identifier in Tauri v2 — these were never added.

The most visible symptom: EditorView's automation startup calls
discoveryFileExists()/readDiscoveryToken() (spawn.ts) to reuse an already-
running MCP server. With allow-exists and allow-read-text-file missing, both
calls silently fail (the fs-plugin IPC rejection is caught and treated as
"file not found"), so the app always falls through to spawning a redundant
MCP server process and registers with a freshly generated auth token instead
of the real one — which the already-running server then rejects, leaving
automation permanently disconnected from any already-running MCP server.
2026-08-18 06:15:12 +10:00
Danila Poyarkov b5665afab5 fix(tauri): restore desktop clipboard access 2026-07-01 10:40:29 +03:00
Danila Poyarkov dac6f171e3 feat(app): add font cache management hooks 2026-05-05 21:50:31 +03:00
Danila Poyarkov b248e0c53a feat(tauri): add signed updater 2026-05-01 13:00:50 +03:00
Danila Poyarkov 8c643298f3 Revert "debug: add MCP spawn logging, revert cmd approach"
This reverts commit 663e1018e7.
2026-04-23 14:05:19 +03:00
Danila Poyarkov 663e1018e7 debug: add MCP spawn logging, revert cmd approach 2026-04-23 13:58:53 +03:00
Danila Poyarkov fbf582a493 fix(tauri): fix MCP server spawn on Windows and PATH on macOS
- Windows: npm installs .cmd wrappers, not direct executables. Spawn
  via 'cmd /c openpencil-mcp-http' on Windows to resolve .cmd files.
- macOS/Linux: add fix-path-env-rs to inherit shell PATH in GUI apps.
- Add 'cmd' to shell spawn capabilities for Windows.

Fixes #226
2026-04-23 13:20:58 +03:00
Danila Poyarkov b502b43c4d feat(mcp): add open_file, new_document tools and disk export via path param
- open_file: opens a .fig/.pen from disk into a new tab via MCP
- new_document: creates an empty doc with optional save path
- export_image, export_svg, get_jsx: optional path param writes output
  to disk instead of returning base64/string (avoids flooding AI context)
- OPENPENCIL_MCP_ROOT env var scopes all file paths (defaults to cwd)
- setPlannedFilePath + startWatchingCurrentFile on editor store
- Extract openFileFromPath helper in use-menu.ts
- Add fs:allow-mkdir and scoped fs:allow-watch Tauri capabilities
- Better 'app not connected' error in stdio — instructs agent to stop

Co-authored-by: Jais Pedersen <jais@pedersens.net>
2026-04-22 17:05:54 +03:00
Danila Poyarkov cd4e1a2271 feat(mcp): add stdio transport for Claude Code / Cursor
- Add packages/mcp/src/stdio.ts — proper stdio MCP server that
  connects to the running app via WebSocket
- openpencil-mcp bin now points to stdio entry (was HTTP)
- openpencil-mcp-http bin added for the HTTP server
- Extract registerTools() from server.ts to share between transports
- Move index.ts banner output to stderr (prevents stdout corruption)
- Update Tauri shell spawn to use openpencil-mcp-http
- Update all docs (EN + 6 translations): correct binary names, source
  paths, port number

Fixes #194
2026-04-13 11:51:01 +03:00
Anton A S a76d20b27b Fix ACP agent commands: claude-agent-acp, remove stale ts-expect-error 2026-03-15 17:06:57 +03:00
Anton A S 583b03eb3b Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 16:49:43 +03:00
Danila Poyarkov ff78d8d57b Bridge MCP server to live editor via automation WebSocket
ACP agents now get an MCP server at http://127.0.0.1:7600/mcp that
proxies tool calls through the automation WebSocket to the browser,
where they execute against the live editor store. No separate
subprocess, no stale npm package — tools operate on the open canvas.

- Add /mcp endpoint to automation bridge (Streamable HTTP transport)
- Each MCP tool call → sendToBrowser({command:'tool'}) → browser executes
- Strip mcp__open-pencil__ prefix from tool names in chat UI
- Export paramToZod from MCP package for bridge reuse
2026-03-15 16:40:27 +03:00
Danila Poyarkov e79fb5ac5d Connect ACP agents to OpenPencil MCP tools
Pass openpencil-mcp as a stdio MCP server to the ACP session so
agents get all design tools (create shapes, render JSX, export
images, etc.) instead of just plain conversation.

- Add makeFigma option to createServer for external FigmaAPI injection
- Skip file lifecycle tools (open/save/new) when using external FigmaAPI
- Pass MCP server config in ACP newSession mcpServers
- Add bun to Tauri shell scope for MCP server subprocess
2026-03-15 16:40:26 +03:00
Danila Poyarkov 2109bd6605 Fix ACP shell scope config and surface connection errors
- Add scoped command definitions for claude-code-acp, codex-acp, gemini
  in Tauri shell plugin capabilities (required for Command.create)
- Show error banner in ChatPanel when transport initialization fails
  instead of silently swallowing the error
2026-03-15 16:40:23 +03:00
Danila Poyarkov 30430433c6 Add ACP client support for using external coding agents
Implement Agent Client Protocol (ACP) integration so users can use their
existing Claude Code, Codex, or Gemini CLI subscriptions directly in
OpenPencil's AI chat instead of managing separate API keys.

- Add ACPAgentDef/ACPAgentID types and ACP_AGENTS registry to core constants
- Create ACPChatTransport implementing Vercel AI SDK ChatTransport over ACP
- Spawn agent as subprocess via tauri-plugin-shell, communicate over stdio
- Map ACP session/update notifications to UIMessageChunk stream
- Update provider selector to show ACP agents alongside API-key providers
- Hide API key fields for ACP providers in setup/settings UI
- Add tauri-plugin-shell dependency and capability permissions
- ACP agents only appear in Tauri desktop builds (subprocess required)
2026-03-15 16:40:18 +03:00
Danila Poyarkov 8ced3201cf Cache effect ImageFilters and reuse layer paint
Eliminates per-frame WASM allocations for shadows and blurs:
- Reusable effectLayerPaint instead of new Paint() per effect
- ImageFilter cache keyed by params (drop shadow, blur, decal blur)
- Zero allocations during SkPicture recording for cached effects
2026-03-03 10:12:02 +03:00
Danila Poyarkov 197323162f Fix .fig export: Zstd compression, IS_TAURI constant, fs permissions
- Replace deflate with Zstd compression via zstd-wasm for .fig export
  (Figma expects Zstd, not zlib deflate)
- Extract IS_TAURI constant from repeated window check
- Fix Tauri fs permissions: use allow-write-file / allow-read-file
  with glob scope instead of bare allow-write / allow-read
2026-02-28 14:48:22 +03:00
Danila Poyarkov 8d3d3444ad Tauri native dialog for Save/Open with fs plugin 2026-02-28 13:54:21 +03:00
Danila Poyarkov 23f00189e8 Rename src-tauri → desktop, symlink for CLI compat 2026-02-28 08:10:09 +03:00