diff --git a/.github/assets/github-banner.png b/.github/assets/github-banner.png deleted file mode 100644 index 93a37e57..00000000 Binary files a/.github/assets/github-banner.png and /dev/null differ diff --git a/.opencode/openwork.json b/.opencode/openwork.json new file mode 100644 index 00000000..5cf9aba2 --- /dev/null +++ b/.opencode/openwork.json @@ -0,0 +1,5 @@ +{ + "messaging": { + "enabled": true + } +} diff --git a/AGENTS.md b/AGENTS.md index 32fc60f7..f6d042bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Design principles for hot reload: * **Session-aware**: when sessions are actively running, queue reload signals. Promote to visible reload (toast or auto-reload) only after all active sessions finish. This avoids interrupting in-flight tool calls. * **Auto-reload setting**: each workspace can opt into automatic reload via `.opencode/openwork.json` (`reload.auto`). When enabled, the engine reloads automatically once queued signals are ready and no sessions are active. * **Session continuity**: before reload, capture running session IDs, agents, and models. After reload, optionally relaunch those sessions so the user experiences seamless continuity. -* **Per-workspace isolation**: the desktop file watcher only watches the active workspace root and its `.opencode/` directory. The server reload event store is already keyed by `workspaceId`. +* **Per-workspace isolation**: the desktop file watcher only watches the runtime-connected workspace root and its `.opencode/` directory. This can differ briefly from the UI-selected workspace while the user browses another workspace. The server reload event store is already keyed by `workspaceId`. ## Technology Stack @@ -130,6 +130,19 @@ Design principles for hot reload: * Use `DESIGN-LANGUAGE.md` as the default visual reference for OpenWork app and landing work. * For OpenWork session-surface details, also reference `packages/docs/orbita-layout-style.mdx`. +## App Architecture (CUPID) + +For `apps/app/src/app/**`, use CUPID: small public surfaces, intention-revealing names, minimal dependencies, predictable ownership, and domain-based structure. + +* Organize app code by product domain and app behavior, not generic buckets like `pages`, `hooks`, `utils`, or app-wide props. +* Prefer a thin shell, domain modules, and tiny shared primitives. +* Colocate state, UI, helpers, and server/client adapters with the domain that owns the workflow. +* Treat shared utilities as a last resort; promote only after multiple real consumers exist. +* Cross-domain imports should go through a small public API, not another domain's internals. +* Keep global shell code thin and use it for routing, top-level layout, runtime wiring, and shared reload/update surfaces only. +* Domain map: shell, workspace, session, connections, automations, cloud, app-settings, and kernel. +* When changing app architecture, moving ownership, or editing hot spots like `app.tsx`, `pages/dashboard.tsx`, `pages/session.tsx`, or `pages/settings.tsx`, consult the workspace-root skill at `../../.opencode/skills/cupid-app-architecture/SKILL.md` first. + ## Dev Debugging * If you change `apps/server/src`, rebuild the OpenWork server binary (`pnpm --filter openwork-server build:bin`) because `openwork` (openwork-orchestrator) runs the compiled server, not the TS sources. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b135864..7a2d4663 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,6 +70,9 @@ Agents, skills, and commands should model the following as OpenWork server behav - workspace creation and initialization - writes to `.opencode/`, `opencode.json`, and `opencode.jsonc` - OpenWork workspace config writes (`.opencode/openwork.json`) +- workspace template export/import, including shareable `.opencode/**` files and `opencode.json` state +- workspace template starter-session materialization from portable blueprint config (not copied runtime session history) +- share-bundle publish/fetch flows used by OpenWork template links - reload event generation after config or capability changes - other filesystem-backed capability changes that must work across desktop host mode and remote clients @@ -82,6 +85,33 @@ Tauri or other native shell behavior remains the fallback or shell boundary for: If an agent needs one of the server-owned behaviors above and only a Tauri path exists, treat that as an architecture gap to close rather than a parallel capability surface to preserve. +## Reload-required flow + +OpenWork uses a single reload-required flow for changes that only take effect when OpenCode restarts. + +Key pieces: + +- `createSystemState()` owns the raw queued-reload state. +- `reloadPending()` means a reload is currently queued for the active workspace. +- `markReloadRequired(reason, trigger)` queues the reload and records the source that caused it. +- `app.tsx` exposes `reloadRequired(...sources)` as a small helper for UI filtering. It is used to decide whether the shared reload popup should show for a given trigger type. + +Use this flow when a change mutates startup-loaded OpenCode inputs, for example: + +- `opencode.json` +- `.opencode/skills/**` +- `.opencode/agents/**` +- `.opencode/commands/**` +- MCP definitions or plugin lists that OpenCode only loads at startup + +Do not invent a separate reload banner per feature. New UI that needs restart semantics should: + +1. perform the config or filesystem mutation +2. call `markReloadRequired(...)` +3. rely on the shared reload popup to explain and execute the restart path + +Current examples that should use this shared flow include MCP changes, auto context compaction, default model changes, authorized folder updates, plugin changes, and other `opencode.json` writes. + ## opencode primitives how to pick the right extension abstraction for @opencode @@ -359,7 +389,7 @@ workspace C ----/ +-------------------------------+ | opencode-router | - | root: active workspace | + | root: runtime active workspace| | clients: many dirs under root | +-------------------------------+ ``` @@ -370,6 +400,22 @@ Practical consequences: - If workspaces live in unrelated roots, directories outside the active router root are rejected. - OpenWork server is already multi-workspace aware. - Desktop router management is still effectively single-root at a time. +- On desktop, the file watcher follows the runtime-connected workspace root, not just the workspace currently selected in the UI. + +Terminology clarification: + +- `selected workspace` is a UI concept: the workspace the user is currently viewing and where compose/config actions should target. +- `runtime active workspace` is a backend concept: the workspace the local server/orchestrator currently reports as active. +- `watched workspace` is the desktop-host/runtime concept for which workspace root local file watching is currently attached to. +- These states must be treated separately. UI selection can change without implying that the backend has switched roots yet. +- In practice, `selected workspace` and `runtime active workspace` often converge once the user sends work, but they are allowed to diverge briefly while the UI is browsing another workspace. + +Desktop local OpenWork server ports: + +- Desktop-hosted local OpenWork server instances do not assume a fixed `8787` port. +- Each workspace gets a persistent preferred localhost port in the `48000-51000` range. +- On restart, desktop tries to reuse that workspace's saved port first. +- If that port is unavailable, desktop picks another free port in the same range and avoids ports already reserved by other known workspaces. ```text Shared-root case diff --git a/DESIGN-LANGUAGE.md b/DESIGN-LANGUAGE.md index b9a7476f..2e063742 100644 --- a/DESIGN-LANGUAGE.md +++ b/DESIGN-LANGUAGE.md @@ -1,99 +1,871 @@ # OpenWork Design Language -This is the definitive visual language for OpenWork product and marketing work. +This is the definitive visual system for OpenWork product and landing work. -The goal is calm, premium, operational software: clear, flat, slightly futuristic, and trustworthy enough for real work. It is an application, not a marketing page. Readability, state clarity, and keyboard/mouse efficiency matter more than visual theater. +OpenWork should feel like a premium work tool: calm, useful, technical, and trustworthy. The design should read as software first, not a flashy marketing site. The goal is clarity with taste, not visual noise. -## Core Directives +--- -1. **No Glassmorphism:** Never use `backdrop-blur`, heavy drop shadows, or frosted glass (`bg-white/70`, `bg-white/94`) on core application UI surfaces. -2. **No Extraneous Chrome:** Do not add decorative counters, pills, lines, or badges unless they map directly to functional state. -3. **No Aggressive Gradients:** Do not use radial gradients or linear background washes behind application panels. -4. **Flat Hierarchy:** The app relies on very soft, low-contrast separation, mostly handled by `1px` subtle borders and flat semantic backgrounds (`bg-gray-1`, `bg-gray-2`, `bg-gray-3`). -5. **Preserve the Anchor:** Never hide or truncate a primary label (like a workspace name) just to reveal hover actions. Hover actions must sit in reserved space or overlay without pushing text. +## 1. Core Design Position -## Shared DNA +OpenWork design is: -### Brand Mood +- quiet +- premium +- operational +- flat-first +- structured by typography, spacing, and borders +- atmospheric only in controlled places -* Calm, technical, premium, useful. -* More "precision tool with atmosphere" than "consumer toy". -* Friendly, but never cute. -* Futuristic through restraint, flat surfacing, and structural cleanliness—not chrome overload. +OpenWork design is **not**: -### Core Palette +- glossy +- glassy +- beige +- aggressively gradient-heavy +- border-heavy +- shadow-led +- decorative for its own sake -The application runs on a tight monochrome grayscale with intentional accent colors. +The basic rule: -* Base background: `bg-dls-sidebar` or `bg-gray-1` -* Primary ink: `text-gray-12` -* Secondary ink: `text-gray-10` or `text-gray-11` -* Subtle borders: `border-dls-border` or `border-gray-6` -* Soft panels: `bg-gray-1`, `bg-gray-2/60`, `bg-gray-3/70` +> Use structure before effects. -### Geometry +If something needs emphasis, prefer this order: -* Panels and large modals: `rounded-2xl` or `rounded-[28px]` (do not use `rounded-[2rem]` for utility panels). -* Lists, tabs, and small cards: `rounded-xl`. -* Badges and accents: `rounded-full` or `rounded-md`. -* Avoid overly pill-shaped geometry outside of primary buttons and badges. +1. layout +2. spacing +3. typography +4. opacity +5. background tint +6. border +7. shadow -### Typography +Shadow should almost never be the first tool. -* Primary UI type: a clean sans like Inter. -* Monospace: use for commands, file paths, versions, code snippets, and system tokens. -* Default hierarchy: - * Eyebrows: uppercase, tracked (`tracking-[0.18em]`), small (`text-[11px]`), muted (`text-gray-8`). - * Headlines: medium or semibold weight, tight tracking, moderate scale (`text-lg` or `text-[1.35rem]`). - * Body: relaxed line-height, soft gray (`text-gray-10`), high legibility. - * List Items: `text-[13px]`, not overly large. +--- -## Application Surfaces +## 2. The OpenWork Mood -### Panels & Cards +The product should feel like: -* Instead of floating cards, use structured boundaries. -* A major settings panel should use `bg-dls-surface` or `bg-gray-1/40` with a subtle `border-dls-border` (see `_repos/openwork/apps/app/src/app/pages/settings.tsx`). -* Secondary interior groupings should use `bg-gray-1/40` or `bg-gray-2/30` with `rounded-2xl` and a `1px` border. +- a serious desktop tool +- a clean command center +- a modern open-source alternative to Claude Cowork +- something you would trust with real workflows, team sharing, and remote workers -### Interactive Rows & Lists (The Landing Pattern) +Tone: -Lists (like sessions or active configurations) should mimic the clean, flat rhythm seen in the landing demo panels (see `_repos/openwork/ee/apps/landing/components/landing-app-demo-panel.tsx`). +- polished, but restrained +- modern, but not trendy +- friendly, but not cute +- futuristic through discipline, not chrome -* **Container:** `flex items-center justify-between rounded-xl px-3 py-1.5 text-left text-[13px] transition-colors` (see `_repos/openwork/apps/app/src/app/components/session/workspace-session-list.tsx` for implementation details). -* **Selected State:** Use a solid, clear gray tint like `bg-gray-3` or `bg-gray-3/80` with a stronger font weight (`font-medium`). Do *not* use a white card with drop shadow. -* **Hover State:** Use a slightly lighter tint than the selected state, e.g., `hover:bg-gray-2/60`. -* **Timestamps/Metadata:** Keep them quiet. Right-aligned `text-[11px] text-gray-8` or `text-gray-9`. Do not brighten them excessively on hover. +--- -### Navigation Rails +## 3. Color + Surface Rules -* Use flat, unadorned rectangles for tabs (see left rail in `_repos/openwork/apps/app/src/app/pages/settings.tsx`). -* Active state: `bg-dls-surface text-dls-text shadow-sm` (keep the shadow minimal). -* Hover state: `hover:bg-dls-surface/50`. -* Do not use heavy floating dots, massive padding, or glowing active states. +### Base page color -### Hover Actions +- Default page/background base: very light cool neutral (`#f6f9fc` or equivalent) +- Prefer white and near-white surfaces over tinted beige panels +- Avoid warm paper/beige backgrounds unless there is a very strong reason -* Row-level actions (like `...` or `+`) should appear on hover (`group-hover:flex`). -* **Crucial:** Do not use `opacity-0 group-hover:opacity-100` if it causes the primary text of the row to truncate early or jump. Prefer `hidden group-hover:flex` to naturally replace space, but ensure the title has enough room. +### Surface hierarchy -### Buttons & Controls +Use only a few layers: -* **Primary Button:** Dark fill (`bg-[#011627]`), white text, `rounded-full`, compact horizontal padding. -* **Secondary/Outline Button:** Transparent background, `border-dls-border`, `text-dls-text`, hover state `bg-dls-hover` or `bg-gray-2`. -* **Danger Action:** Very subtle red tint `bg-red-3/25 text-red-11 border-red-7/35`. +1. **Page background** +2. **Primary white surface** +3. **Soft secondary surface** +4. **Interactive selected state** -## OpenWork Landing +Do not create lots of micro-layers. -The landing page (`_repos/openwork/ee/apps/landing`) may use *slightly* more atmospheric elements (like soft grain or the occasional translucent shell), but the core UI components embedded within it (like `LandingAppDemoPanel` or `LandingCloudWorkersCard`) strictly obey the flat, structural rules outlined above. +### Preferred surface treatments -* The landing page is the *only* place where `landing-shell` (frosted blur) is appropriate. Do not backport these utility classes into the operational desktop application. -* When the desktop app needs to look "premium", it achieves this through tight alignment, consistent `gray-1`/`gray-2` layering, and sharp typography—not through blurs and shadows. +#### Flat app surface -## Canonical References +For most application UI: -If you need to see exactly how these rules are applied in code, consult these specific source files: +- white or near-white background +- 1px subtle border +- no visible shadow or only the smallest shadow possible -* **App Settings Panel (Ideal flat surface structure):** `_repos/openwork/apps/app/src/app/pages/settings.tsx` -* **App Left Rail (Ideal tab and session list rhythm):** `_repos/openwork/apps/app/src/app/components/session/workspace-session-list.tsx` -* **Landing Demo Shell (The origin of the clean list layout):** `_repos/openwork/ee/apps/landing/components/landing-app-demo-panel.tsx` +#### Soft shell + +Use for landing sections that need grouping but should still feel calm. + +- `landing-shell-soft` style direction +- near-white background +- subtle edge definition +- **no box shadow by default** + +This is no longer landing-only in spirit. For app surfaces like modals, package builders, +and share flows, the same shell language is often the right starting point when the surface +represents a workflow object instead of generic settings chrome. + +#### Elevated showcase shell + +Use only when a hero/demo needs one extra level of emphasis. + +- may use `landing-shell` +- still soft +- never dark or “floating card everywhere” +- should be rare, not the default wrapper for all sections + +### Background imagery + +Allowed only when all of the following are true: + +- it sits behind content, not under core text blocks directly +- it is subtle +- it fades away or is spatially constrained +- it does not compete with reading + +Pattern/background image rules: + +- top-of-page background patterns should be low-opacity and fade out down the page +- section-specific image backgrounds are allowed for showcase frames +- content cards that sit on top of image backgrounds should still be white and legible +- use images as atmosphere, not content + +--- + +## 4. Borders + +Borders are one of the main structure tools in OpenWork. + +### Border philosophy + +- prefer soft gray borders +- prefer low contrast +- prefer consistency over emphasis + +### What not to do + +- do **not** use harsh black borders for selection +- do **not** outline selected cards with strong dark strokes +- do **not** stack border + heavy shadow + tint all at once + +### Good border usage + +- `border-gray-200` +- `border-gray-300` for stronger but still soft selection +- low-alpha white borders for translucent landing shells +- soft shell borders like `#eceef1` for app sidebars and large rounded utility panels + +Do not use a dark or high-contrast outline as the main styling for a small icon tile, +badge shell, or compact decorative container. If the element is just carrying an icon, +prefer a soft filled tile over an outlined chip. + +Selection should usually feel like: + +- soft neutral fill +- darker text +- optional tiny border or tiny shadow only when needed + +not: + +- dark outline +- glow +- hard stroke + +--- + +## 5. Shadows + +Shadows must be restrained. + +### General rule + +- App UI: almost flat +- Landing UI: soft and selective +- Selection states: tiny shadow only + +### Approved shadow levels + +#### None + +Default for most grouped surfaces. + +#### Tiny control shadow + +Use for active pills and secondary buttons: + +```css +0 0 0 1px rgba(0,0,0,0.06), +0 1px 2px 0 rgba(0,0,0,0.04) +``` + +#### Light card shadow + +Use sparingly for a main demo shell or one hero card. + +#### Strong CTA shadow + +Reserved for the primary CTA only. + +### Never do + +- large ambient shadows across many cards on one page +- floaty SaaS-marketing shadows everywhere +- using shadow as the main selected-state signal +- glassmorphism blur shadows in the app + +--- + +## 6. Geometry + Radius + +OpenWork should have a small set of radii and use them consistently. + +### Radius system + +- **Pills / buttons / chips:** `rounded-full` +- **Small controls / rows / compact cards:** `rounded-xl` +- **Medium panels / embedded demos:** `rounded-2xl` +- **Large showcase wrappers:** `rounded-3xl` or `rounded-[2.5rem]` +- **Sidebar/app shell wrappers:** `rounded-[2rem]` to `rounded-[2.5rem]` + +### Rules + +- Don’t mix too many different radii in one section +- If the outer shell is very rounded, inner panels should step down cleanly +- Pills should look intentional, not bubbly + +--- + +## 7. Typography + +Typography does most of the hierarchy work. + +### General tone + +- clean sans-serif +- medium weight for important labels +- gray text for explanatory copy +- no overly stylized headings + +### Hierarchy + +#### Eyebrows + +- uppercase +- tracked +- small (`text-[11px]`) +- muted gray + +#### Headlines + +- medium weight +- tight tracking +- dark ink (`#011627` or equivalent) +- large enough to lead, not shout + +#### Body + +- `text-sm` or `text-base` +- relaxed line height +- `text-gray-500` or `text-gray-600` + +#### Active explanatory text + +If paired with an active state (like a selected workflow descriptor), the copy may move from muted gray to dark ink. + +### Avoid + +- giant type jumps +- ultra-light weights +- loud uppercase body copy +- dense paragraphs without breathing room + +--- + +## 7.5 Copy Direction + +OpenWork copy should feel as disciplined as the UI. + +### General tone + +- concise +- product-led +- operational +- calm +- confident without overselling + +### Good copy behavior + +- lead with the main user value, not the implementation detail +- prefer one clear idea per sentence +- keep interface copy shorter than marketing copy +- make support text explain utility, not restate the headline in different words + +### Avoid + +- repetitive copy that says the same thing three ways +- enterprise filler words like "provisioned setups" when a simpler phrase exists +- admin-heavy or billing-heavy framing when the main value is team workflow +- overdescribing secondary features + +### Preferred OpenWork Cloud framing + +For OpenWork Cloud, the primary story is: + +1. share setup across the team/org +2. keep everything in sync +3. background agents are secondary / alpha +4. custom LLM providers are tertiary / coming soon + +Do not make the product read like: + +- a billing page first +- a hosting toggle first +- an equal split between desktop and Cloud + +It should read like: + +- team setup sharing first +- operational consistency second +- advanced/cloud extensions after that + +### Preferred terminology + +Use: + +- **OpenWork Cloud** +- **Shared setups** +- **Shared templates** +- **Custom LLM providers** +- **Background agents** + +Prefer: + +- "Manage your team’s setup, invite teammates, and keep everything in sync." +- "Create and update shared templates your team can use right away." +- "Standardize provider access for your team." + +Avoid: + +- "Den" in user-facing copy +- "Provisioned setups" +- "Configured setups" +- "Choose how to run..." when the real goal is to explain team value + +### Hierarchy rules for product pages + +For sign-in, checkout, and dashboard copy: + +- headline should state the core team value +- subcopy should explain the workflow benefit in one sentence +- supporting bullets/cards should not compete equally with the main value +- desktop should often appear as a fallback or secondary path, not a co-equal hero choice + +### Docs CTA language + +When linking to supporting documentation, prefer short utility labels: + +- **Learn how** +- **How sharing works** +- **Read the guide** + +These should feel like helpful follow-through, not a second headline. + +--- + +## 8. Buttons + +There are only a few button families in OpenWork. + +### 8.1 Primary button + +Use for the main action only. + +Characteristics: + +- dark fill (`#011627`) +- white text +- fully rounded pill +- slightly stronger shadow than the rest of the system +- feels decisive but still clean + +Canonical pattern: `doc-button` + +Use for: + +- Download +- Run task +- other main conversion/action moments + +### 8.2 Secondary button + +Use for support actions. + +Characteristics: + +- white fill +- no hard border +- tiny ring + small shadow +- black/dark text +- fully rounded pill + +Canonical pattern: `secondary-button` + +This is also the reference style for: + +- active segmented controls +- selected pills inside a track + +### 8.3 Tertiary / text actions + +Use for less important actions. + +Characteristics: + +- no heavy box treatment +- rely on text color and hover only + +### Button rules + +- Do not invent many new button styles +- Reuse the primary and secondary button logic whenever possible +- If a selector pill is active, it should usually resemble the secondary button family + +--- + +## 9. Selectors, Tabs, and Pills + +This is now one of the clearest OpenWork patterns. + +### Track pattern + +Use a soft segmented track: + +- light border +- subtle gray background +- full pill radius +- tiny inset padding + +Example structure: + +- track: `border border-gray-200 bg-gray-50/50 rounded-full p-1` +- active item: white pill + tiny shadow +- inactive item: muted text only + +### Active state + +Active tab/pill should look like: + +- white pill +- soft ring/shadow +- dark text + +### Inactive state + +Inactive tab/pill should look like: + +- no card chrome +- muted gray text +- stronger text on hover + +### Do not + +- use harsh dark borders for selection +- create heavy segmented controls with thick strokes +- use loud fills for tabs + +### Flat selected row pattern + +For app navigation, especially dashboard sidebars: + +- selected state should usually be a soft gray fill (`bg-gray-100` / `bg-slate-100` family) +- selected items should not default to white floating pills inside a white or near-white shell +- rely on fill + text weight before adding chrome +- hover state should usually be one step lighter than selected, not a different visual language + +--- + +## 10. Lists and Row Systems + +OpenWork has two primary list patterns. + +### 10.1 Operational row list + +Use for sessions, workspaces, activity rows, and compact app lists. + +Pattern: + +- flat container +- rounded-xl row +- light hover tint +- selected row uses a subtle fill and stronger text +- metadata remains quiet + +Good signals: + +- `font-medium` +- subtle background tint +- tiny status accent if needed +- rounded-2xl row inside a softer outer shell when the list is acting as a primary sidebar + +Bad signals: + +- white card floating above white page +- hard selected outline +- large shadows on list rows + +### 10.1a Sidebar shell pattern + +Use for app/dashboard sidebars when the sidebar itself should feel like a calm standalone object. + +Pattern: + +- outer shell uses a near-white neutral background, not pure white +- shell gets a large radius (`rounded-[2rem]` range) +- shell uses a faint border, often enough without any visible shadow +- internal rows stay flatter than the outer shell +- selected row uses a soft gray fill, not a stronger border treatment +- footer actions may appear as floating white pills/cards inside the shell if they need separation + +This is the right pattern for: + +- workspace sidebars +- Cloud dashboard sidebars +- utility navigation that should feel product-like rather than admin-like + +### 10.2 Text-led preview list + +Use when a list controls a larger preview panel to the right. + +Pattern: + +- no boxed cards for each item +- text blocks stacked vertically +- inactive items use lower opacity +- active item uses full opacity and darker copy + +This is the right pattern for: + +- feature explanation lists next to a demo panel +- “build / import / ready” style narratives + +--- + +## 11. Cards and Section Layouts + +### Explanatory cards + +Use only when the card itself is the unit of information. + +Should be: + +- simple +- lightly bordered +- white +- softly rounded + +### When not to use cards + +If the user is just choosing between three conceptual options, don’t force every option into a boxed card. Use: + +- pill selector +- text-only list +- opacity-driven stacked copy + +### Product object cards + +Use when the UI is presenting a reusable worker, template, integration, or packaged setup. + +Pattern: + +- soft shell or near-white card +- generous padding +- title first +- one short supporting sentence +- compact status pill in the top-right if needed +- actions inline underneath or within the card + +These should feel like curated product objects, not admin rows. + +### Icon tiles inside cards + +When a card uses an icon block: + +- use a soft filled tile (`bg-slate-50` / similar) +- prefer no visible border by default +- let size, radius, and fill define the tile +- if a muted version is needed, use a quieter fill rather than an outline + +Do not: + +- put a dark stroke around the icon tile +- make the icon tile look like a separate outlined button unless it actually is one +- introduce standalone black/ink borders for decorative icon wrappers + +### Section composition + +Most sections should follow one of these layouts: + +1. **Headline + supporting copy + CTA** +2. **Selector on left + live descriptor on right** +3. **Text list on left + preview/demo on right** +4. **Three-column summary cards** + +Do not mix too many interaction models in one section. + +--- + +## 12. Demo and Mockup Styling + +Embedded product demos should feel like software, not like illustrations. + +### Demo shell rules + +- white inner content area +- subtle chrome +- soft border +- restrained shadow +- clear spacing + +### If the outer frame is atmospheric + +Then the inner mockup must become simpler. + +Meaning: + +- image/pattern on outer background is okay +- inner card should stay clean and white +- do not combine colorful outer frame with complex inner effects + +### Content in demos + +- use real-looking interaction states +- keep labels readable +- emphasize utility over visual flourish + +### Packaged workflow surfaces + +When showing a workflow like share/package/export: + +- prefer a soft shell over default modal chrome +- make the core object the hero (template, worker, integration, package) +- reduce the number of nested bordered panels +- use one or two strong cards, then flatter supporting sections +- present actions as intentional product actions, not generic form controls + +--- + +## 13. Selection States + +Selection should usually be shown through one or more of: + +- darker text +- stronger opacity +- soft neutral fill +- soft gray border +- tiny shadow + +Selection should **not** usually be shown through: + +- black outline +- bright accent fill +- glow +- thick stroke + +OpenWork selection should feel confident, not loud. + +When a selected item sits inside a soft app shell, prefer: + +- tinted gray fill first +- then weight and text color +- then at most a tiny white badge or tiny control shadow for supporting UI + +Avoid making the selected state look like a separate floating card unless the interface is explicitly using segmented pills. + +--- + +## 13.5 Modal Surfaces + +Not every modal should look like a system dialog. + +For workflow modals (share, package, connect, publish, save to team): + +- use a large soft shell with a near-white background +- keep the header airy and typographic +- avoid harsh header separators unless they add real structure +- prefer one scrollable content region inside the shell +- use soft cards for major choices +- reduce mini-panels and stacked utility boxes + +Good modal direction: + +- feels like a product surface +- can contain object cards and actions +- uses soft hierarchy and breathing room + +Bad modal direction: + +- dense settings sheet +- too many small bordered sub-panels +- generic dialog chrome with no product feel + +--- + +## 14. Motion + +Motion should be tight and purposeful. + +### Allowed motion + +- pill transitions with spring +- short opacity transitions +- tiny translateY on primary CTA hover +- soft content crossfades + +### Avoid + +- floaty delayed animations everywhere +- scale-heavy hover effects +- decorative motion on non-interactive surfaces + +### Timing + +- interactions should feel immediate +- most transitions should live around `150ms–300ms` +- spring motion should be controlled, not bouncy + +--- + +## 15. OpenWork App vs Landing + +The app and the landing share one system, but not the same degree of atmosphere. + +### App + +- flatter +- more structural +- almost no decorative shadow +- almost no background texture +- strong emphasis on state clarity and density + +### Landing + +- may use soft shells +- may use one atmospheric background image/pattern in a controlled region +- may use more spacing and larger radii +- still must obey the same button, border, and selection rules + +Landing should feel like the same product family, not a separate visual brand. + +--- + +## 16. Anti-Patterns + +Do not introduce these: + +- beige canvases as default backgrounds +- harsh black selected borders +- random glassmorphism +- multiple heavy shadow systems on one screen +- over-rounded cards everywhere +- boxed selectors when text or pills would be clearer +- giant gradients behind readable text +- decorative badges/counters with no functional meaning +- hiding anchor labels just to show hover actions +- outlined icon chips that read darker than the card they sit inside + +If something looks “designed” before it looks “useful,” it is probably wrong. + +--- + +## 17. Canonical Component Patterns + +### Primary CTA + +- dark pill +- white text +- slight elevation + +### Secondary CTA / active segmented pill + +- white pill +- tiny ring + tiny shadow +- dark text + +### Selector track + +- light gray border +- soft neutral background +- internal padding +- active item is white + +### Text-led feature list + +- no cards +- stacked copy +- inactive items at reduced opacity +- active item at full opacity + +### Operational list row + +- rounded-xl +- subtle hover tint +- selected row uses fill/weight, not loud chrome + +### App sidebar shell + +- large rounded outer shell +- faint neutral background +- subtle border +- flat internal rows +- selected row uses soft gray fill +- floating footer action can be white if it needs separation from the shell + +### Share/package modal + +- soft shell modal +- object cards for reusable templates or integrations +- compact status pills +- strong dark primary CTA +- white secondary CTA with tiny ring/shadow +- avoid form-heavy utility styling unless the step is truly form-driven + +### Landing shell + +- reserved for hero/showcase moments +- use sparingly + +### Landing soft shell + +- flat, near-white, subtle border +- no shadow by default + +--- + +## 18. Design Decision Tests + +Before shipping a UI change, ask: + +1. Is this relying on layout and typography first, or on effects first? +2. Is the selected state soft and obvious, rather than harsh? +3. Are we reusing the existing primary/secondary button language? +4. Does this section need cards, or would pills / text / opacity be cleaner? +5. Is the shadow doing real work, or is it just decoration? +6. Would this still feel like OpenWork if all colors were muted? +7. Does this feel like one coherent product across app and landing? + +If the answer to those is not clearly yes, simplify. + +--- + +## 19. Canonical References in This Repo + +Use these as implementation references: + +- Landing button + shell primitives: `_repos/openwork/ee/apps/landing/app/globals.css` +- Landing hero and selector patterns: `_repos/openwork/ee/apps/landing/components/landing-home.tsx` +- Landing demo list rhythm: `_repos/openwork/ee/apps/landing/components/landing-app-demo-panel.tsx` +- Cloud dashboard sidebar shell + selected state: `_repos/openwork/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx` +- Share/package modal direction: `_repos/openwork/apps/app/src/app/components/share-workspace-modal.tsx` +- App workspace/session list rhythm: `_repos/openwork/apps/app/src/app/components/session/workspace-session-list.tsx` + +When in doubt, prefer the calmer version. diff --git a/DESIGN-SYSTEM.md b/DESIGN-SYSTEM.md new file mode 100644 index 00000000..efc25270 --- /dev/null +++ b/DESIGN-SYSTEM.md @@ -0,0 +1,474 @@ +# OpenWork Design System + +This document turns the visual direction in `DESIGN-LANGUAGE.md` into an implementation system that can unify: + +- `apps/app` (OpenWork app) +- `ee/apps/den-web` (OpenWork Cloud / Den web surfaces) +- `ee/apps/landing` (marketing + product storytelling) + +The goal is not to create three similar styles. The goal is one OpenWork design system with a few environment-specific expressions. + +--- + +## 1. Why this exists + +Today the product already has the beginnings of a system, but it is split across: + +- app-specific CSS variables in `apps/app/src/app/index.css` +- Tailwind theme setup in `apps/app/tailwind.config.ts` +- Radix color tokens in `apps/app/src/styles/colors.css` +- repeated utility-class decisions across app, Cloud, and landing + +That creates three problems: + +1. the app and Cloud can feel related but not identical +2. visual decisions are made at the screen level instead of the system level +3. tokens, primitives, and page composition rules are not clearly separated + +This file defines the missing structure. + +--- + +## 2. System model + +OpenWork should use a three-layer design system: + +### Layer 1: Foundations + +Raw design tokens: + +- color +- typography +- spacing +- radius +- shadow +- motion + +These are the only values components should depend on directly. + +### Layer 2: Semantic tokens + +Product-meaning tokens: + +- `surface.page` +- `surface.panel` +- `surface.sidebar` +- `text.primary` +- `text.secondary` +- `border.subtle` +- `action.primary.bg` +- `state.hover` +- `state.selected` + +These should map foundation tokens into product meaning. + +### Layer 3: Component primitives + +Reusable building blocks: + +- Button +- Card +- Input +- Modal shell +- Sidebar shell +- List row +- Status pill +- Section header +- Empty state + +Pages should mostly compose these primitives, not invent their own visual logic. + +--- + +## 3. Relationship to existing docs + +- `DESIGN-LANGUAGE.md` = visual philosophy and qualitative rules +- `DESIGN-SYSTEM.md` = implementation structure and migration plan + +If there is a conflict: + +1. `DESIGN-LANGUAGE.md` decides what the product should feel like +2. `DESIGN-SYSTEM.md` decides how to encode that in tokens and primitives + +--- + +## 4. Core principle: one system, three expressions + +OpenWork has three main UI contexts: + +1. **App expression** — denser, flatter, operational +2. **Cloud expression** — still operational, slightly more editorial and roomy +3. **Landing expression** — more atmospheric, but still clearly the same product family + +These should differ mostly in: + +- spacing density +- shell scale +- amount of atmosphere +- page composition + +They should **not** differ in: + +- brand color logic +- button language +- border philosophy +- type hierarchy +- selection behavior + +--- + +## 5. Canonical token architecture + +We should converge on a small token set that works everywhere. + +### 5.1 Foundation color tokens + +Use Radix as the raw palette source, but not as the public API for product styling. + +Raw palette source: + +- Radix gray/slate/sage for neutrals +- Radix red/amber/green/blue for semantic states + +### 5.2 Semantic color tokens + +Canonical semantic token set: + +- `--ow-color-page` +- `--ow-color-surface` +- `--ow-color-surface-subtle` +- `--ow-color-surface-sidebar` +- `--ow-color-border` +- `--ow-color-border-strong` +- `--ow-color-text` +- `--ow-color-text-muted` +- `--ow-color-text-subtle` +- `--ow-color-accent` +- `--ow-color-accent-hover` +- `--ow-color-hover` +- `--ow-color-active` +- `--ow-color-success` +- `--ow-color-warning` +- `--ow-color-danger` + +These should become the shared API across app and Cloud. + +### 5.3 Current mapping from app tokens + +Existing app tokens already point in the right direction: + +- `--dls-app-bg` -> `--ow-color-page` +- `--dls-surface` -> `--ow-color-surface` +- `--dls-sidebar` -> `--ow-color-surface-sidebar` +- `--dls-border` -> `--ow-color-border` +- `--dls-text-primary` -> `--ow-color-text` +- `--dls-text-secondary` -> `--ow-color-text-muted` +- `--dls-accent` -> `--ow-color-accent` +- `--dls-accent-hover` -> `--ow-color-accent-hover` + +We should migrate by aliasing first, not by breaking everything at once. + +--- + +## 6. Typography system + +Typography should be systemized into roles, not ad hoc text sizes. + +### Roles + +- **display** — rare marketing or hero usage +- **headline** — page and section headers +- **title** — card and object titles +- **body** — default reading text +- **meta** — labels, helper copy, secondary information +- **micro** — pills, badges, tiny metadata + +### Shared rules + +- one main sans family across product surfaces +- medium weight does the majority of hierarchy work +- muted text is the default support color +- avoid large type jumps inside the app + +--- + +## 7. Spacing system + +OpenWork should use a consistent spacing scale instead of one-off values. + +Recommended base scale: + +- 4 +- 8 +- 12 +- 16 +- 20 +- 24 +- 32 +- 40 +- 48 +- 64 + +### Usage guidance + +- micro control padding: 8–12 +- row padding: 12–16 +- card padding: 20–24 +- major section padding: 32–48 +- page rhythm: 48–64 on roomy surfaces, 24–32 in dense app surfaces + +--- + +## 8. Radius system + +Canonical radius roles: + +- `--ow-radius-control` — small controls and rows +- `--ow-radius-card` — cards and panels +- `--ow-radius-shell` — sidebars, large grouped containers, modal shells +- `--ow-radius-pill` — buttons, tabs, chips + +Suggested mapping: + +- control: 12px +- card: 16px +- shell: 24px–32px +- pill: 9999px + +--- + +## 9. Shadow system + +Shadow should be a named system with very few levels. + +- `--ow-shadow-none` +- `--ow-shadow-control` +- `--ow-shadow-card` +- `--ow-shadow-shell` + +Default behavior: + +- app: mostly `none` or `control` +- Cloud: mostly `none`, `control`, occasional `card` +- landing: selective `card` or `shell` + +--- + +## 10. Component primitive families + +We should explicitly define a small primitive set shared across product surfaces. + +### 10.1 Action primitives + +- Primary button +- Secondary button +- Ghost button +- Destructive button +- Segmented pill / tab item + +### 10.2 Structure primitives + +- Page shell +- Sidebar shell +- Card +- Quiet card +- Modal shell +- Section divider + +### 10.3 Input primitives + +- Text input +- Textarea +- Select +- Checkbox/radio treatment +- Inline field group + +### 10.4 Navigation primitives + +- Sidebar row +- List row +- Topbar item +- Breadcrumb / section tab + +### 10.5 Feedback primitives + +- Status pill +- Banner +- Empty state +- Toast + +--- + +## 11. System-first implementation rules + +### Rule 1: prefer semantic tokens over raw utility colors + +Prefer: + +- `bg-[var(--ow-color-surface)]` +- `text-[var(--ow-color-text-muted)]` + +Over: + +- `bg-white` +- `text-gray-500` + +Raw grays are still acceptable for temporary legacy usage, but new primitives should use semantic tokens. + +### Rule 2: page code should not define new visual language + +Page files can compose primitives and choose layouts. +They should not invent new button styles, new shadow rules, or new selection patterns. + +### Rule 3: Radix stays underneath the system + +Radix is the palette source. +OpenWork tokens are the product API. + +### Rule 4: app and Cloud should share primitives even if frameworks differ + +Even when implementations differ, the primitive names and behaviors should match. + +Example: + +- `Button` in app +- `Button` in den-web + +Both should resolve to the same token logic and visual rules. + +--- + +## 12. Migration strategy + +Do not redesign everything at once. +Use this sequence. + +### Phase 1: lock the foundations + +1. create canonical semantic tokens +2. alias current app tokens to the new token names +3. document primitive families and approved variants + +### Phase 2: unify the most reused primitives + +Start with: + +1. Button +2. Card +3. Input +4. Sidebar row +5. Modal shell + +These give the largest visual consistency gain. + +### Phase 3: unify shell patterns + +Standardize: + +- page background +- sidebar shell +- panel/card shell +- list row selection +- headers and section spacing + +### Phase 4: refactor high-traffic screens + +Prioritize: + +- workspace/session surfaces in `apps/app` +- Cloud dashboard shells in `ee/apps/den-web` +- share/package/connect flows in `apps/app` + +### Phase 5: remove local style drift + +As primitives stabilize: + +- reduce repeated one-off class recipes +- replace raw gray classes in repeated patterns +- collapse duplicate card/button/input styles into primitives + +--- + +## 13. Recommended initial source of truth files + +If we implement this system, the likely canonical files should be: + +- `DESIGN-LANGUAGE.md` — philosophy +- `DESIGN-SYSTEM.md` — system rules and migration plan +- `apps/app/src/app/index.css` — initial token host for app runtime +- `apps/app/tailwind.config.ts` — Tailwind token exposure +- `apps/app/src/app/components/button.tsx` — canonical action primitive start +- `apps/app/src/app/components/card.tsx` — canonical surface primitive start +- `apps/app/src/app/components/text-input.tsx` — canonical field primitive start + +Later, a shared package may make sense, but not before the token model is stable. + +--- + +## 14. Recommended file plan for the next step + +The smallest safe implementation path is: + +### Step A + +Introduce canonical `--ow-*` aliases in `apps/app/src/app/index.css` without removing `--dls-*` yet. + +### Step B + +Refactor `Button`, `Card`, and `TextInput` to consume shared semantic tokens. + +### Step C + +Use the Den dashboard shell as the reference for: + +- sidebar shell +- row selection +- neutral panel rhythm + +### Step D + +Restyle one OpenWork app screen fully using the system to prove the direction. + +Recommended pilot screens: + +- `apps/app/src/app/pages/settings.tsx` +- session/workspace sidebar surfaces +- share workspace modal + +--- + +## 15. What a successful system looks like + +We will know this is working when: + +1. app, Cloud, and landing feel obviously from the same product family +2. a new screen can be built mostly from existing primitives +3. visual changes happen by adjusting tokens or primitives, not by editing many pages +4. selection, buttons, cards, and inputs behave consistently everywhere +5. raw color classes become uncommon outside truly local exceptions + +--- + +## 16. Anti-goals + +This system should not: + +- introduce a trendy visual reboot disconnected from the current product +- replace the OpenWork mood described in `DESIGN-LANGUAGE.md` +- depend on a large new dependency just to manage styling +- force a shared package too early +- block incremental improvements until a perfect system exists + +The correct approach is a strong design system built through small, boring, compounding steps. + +--- + +## 17. Immediate next recommendation + +If continuing from this doc, the best next change is: + +1. add `--ow-*` semantic token aliases in `apps/app/src/app/index.css` +2. standardize `Button`, `Card`, and `TextInput` +3. then restyle one app shell to match the calmer Den dashboard direction + +That gives a real system foothold without a broad rewrite. diff --git a/PRODUCT.md b/PRODUCT.md index de12daa3..630f6c65 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,265 +1,49 @@ -# OpenWork Product +## Product -## Target Users +OpenWork helps individual create, consume, and maintain their agentic workflows. -> Bob the IT guy. -Bob might already use opencode, he can setup agents and workflows and share them with his team. The only thing he needs is a way to share this. The way he does is by using OpenWork and creating "workpaces". +OpenWork helps companies share their agentic workflows and provision their entire team. -> Susan in accounting +The chat interfaces is where people consume the workflows. -Susan in accounting doesn't use opencode. She certaintly doesn't paly aorund to create workflow create agents. She wants something that works. -Openwork should be given to give her a good taste of what she can do. -We should also eventually guide ther to: -- creating her own skills -- adding custom MCP / login into mcp oauth servers through ui) -- adding skills from a list of skills -- adding plugins from a list of plugins -- create her own commands +Interfaces for consuming workflows: +- Desktop app +- Slack +- Telegram +What is a "agentic workflow": +- LLM providers +- Skills +- MCP +- Agents +- Plugins +- Tools +- Background Agents +Where are workflows created: +- Desktop app (using slash commands like `/create-skills`) +- Web App +- [We need better places for this to happen[ -1. **knowledge worker**: "Do this for me" workflows with guardrails. -2. **Mobile-first user**: start/monitor tasks from phone. -3. **Power user**: wants UI parity + speed + inspection. -4. **Admin/host**: manages a shared machine + profiles. +Where are workflows maintain: +- In OpenWork Cloud (internal name is Den). -## Success Metrics +Where are workflow hosted: +- Local Machine +- Remote via a OpenWork Host (CLI or desktop) +- Remote on OpenWork Cloud (via Den sandbox workers) -- < 5 minutes to first successful task on fresh install. -- > 80% task success without terminal fallback. -- Permission prompts understood/accepted (low confusion + low deny-by-accident). -- UI performance: 60fps; <100ms interaction latency; no jank. +## Actors +Bob IT guy makes the config. +Susan the accountant consumes the config. -## Product Primitives (What OpenWork Exposes) +Constraints: +- We use standards were possible +- We use opencode where possible +- We stay platform agnostic -OpenWork must feel like "OpenCode, but for everyone." -### 1) Tasks - -- A Task = a user-described outcome. -- A Run = an OpenCode session + event stream. - -### 2) Plans / Todo Lists - -OpenWork provides a first-class plan UI: - -- Plan is generated before execution (editable). -- Plan is updated during execution (step status + timestamps). -- Plan is stored as a structured artifact attached to the session (JSON) so it's reconstructable. - -Implementation detail: - -- The plan is represented in OpenCode as structured `parts` (or a dedicated "plan message") and mirrored in OpenWork. - -### 3) Steps - -- Each tool call becomes a step row with: - - tool name - - arguments summary - - permission state - - start/end time - - output preview - -### 4) Artifacts - -Artifacts are user-visible outputs: - -- files created/modified -- generated documents/spreadsheets/presentations -- exported logs and summaries - -OpenWork lists artifacts per run and supports open/share/download. - -### 5) Audit Log - -Every run provides an exportable audit log: - -- prompts -- plan -- tool calls -- permission decisions -- outputs - -## UI/UX Requirements (Slick as a Core Goal) - -### Design Targets - -- premium, calm, high-contrast -- subtle motion, springy transitions -- zero "developer vibes" in default mode - -### Performance Targets - -- 60fps animations -- <100ms input-to-feedback -- no blocking spinners (always show progress state) - -### Mobile-first Interaction - -- bottom navigation -- swipe gestures (dismiss, approve, cancel) -- haptics for major events -- adaptive layouts (phone/tablet) - -### Accessibility - -- WCAG 2.1 AA -- reduced motion mode -- screen-reader labels for steps + permissions - -## Design Reference - -use the design from ./design.ts that is your core reference for building the entire ui - -## Functional Requirements - -### Onboarding - -- Host vs Client selection -- workspace selection (Host) -- connect to host (Client) -- provider/model setup -- first-run "hello world" task - -### Cloud Worker Onboarding (Current) - -- Sign in (or sign up) on OpenWork cloud control surface. -- Launch worker with a human-readable name. -- If needed, complete checkout and return. -- Select launched worker from list/detail shell. -- Connect from OpenWork app via `Add a worker` -> `Connect remote`. -- Prefer one-click deep link when available; always provide manual URL + token fallback. - -### Task Execution - -- create task -- plan preview and edit -- run with streaming updates -- pause/resume/cancel -- show artifacts and summaries - -### Permissions - -- clear prompts with "why" -- allow once/session -- audit of decisions - -### Commands - -- save a task as a command -- arguments + quick run - -### Scheduling (Future) - -- schedule command runs -- notify on completion - -## User Flow Map (Exhaustive) - -### 0. Install & Launch - -1. User installs OpenWork. -2. App launches. -3. App shows "Choose mode: Host / Client". -4. Host: start local OpenCode via SDK. -5. Client: connect flow to an existing host. - -### 1. First-Run Onboarding (Host) - -1. Welcome + safety overview. -2. Workspace folder selection. -3. Allowed folders selection (can be multiple). -4. Provider/model configuration. -5. `global.health()` check. -6. Run a test session using `session.create()` + `session.prompt()`. -7. Success + sample commands. - -### 2. Pairing Onboarding (Client / Mobile) - -1. User selects "Client". -2. UI explains it connects to a trusted host. -3. User scans QR code shown on host device. -4. Client verifies connection with `global.health()`. -5. Client can now list sessions and monitor runs. - -### 3. Runtime Health & Recovery - -1. UI pings `global.health()`. -2. If unhealthy: - - Host: attempt restart via `createOpencode()`. - - Client: show reconnect + diagnostics. - -### 4. Quick Task Flow - -1. User types goal. -2. OpenWork generates plan (structured). -3. User approves. -4. Create session: `session.create()`. -5. Send prompt: `session.prompt()`. -6. Subscribe to events: `event.subscribe()`. -7. Render streaming output + steps. -8. Show artifacts. - -### 5. Guided Task Flow - -1. Wizard collects goal, constraints, outputs. -2. Plan preview with "risky step" highlights. -3. Run execution with progress UI. - -### 6. File-Driven Task Flow - -1. User attaches files. -2. OpenWork injects context into session. -3. Execute prompt. - -### 7. Permissions Flow (Any) - -1. Event indicates permission request. -2. UI modal shows request. -3. User chooses allow/deny. -4. UI calls `client.permission.reply({ requestID, reply })`. -5. Run continues or fails gracefully. - -### 8. Cancel / Abort - -1. User clicks "Stop". -2. UI calls `client.session.abort({ sessionID })`. -3. UI marks run stopped. - -### 9. Summarize - -1. User taps "Summarize". -2. UI calls `client.session.summarize({ sessionID })`. -3. Summary displayed as an artifact. - -### 10. Run History - -1. UI calls `session.list()`. -2. Tap a session to load `session.messages()`. -3. UI reconstructs plan and steps. - -### 11. File Explorer + Search - -1. User searches: `find.text()`. -2. Open file: `file.read()`. -3. Show changed files: `file.status()`. - -### 12. Commands - -1. Save a plan + prompt as a command. -2. Re-run command creates a new session. - -### 13. Multi-user (Future) - -- separate profiles -- separate allowed folders -- separate providers/keys - -### 14. Hosted Cloud Worker Connect Flow (Current) - -1. User opens OpenWork cloud control page and authenticates. -2. User launches a worker (with optional checkout). -3. UI polls until worker is healthy. -4. UI resolves workspace-scoped connect URL (`/w/ws_*`) and access token. -5. User clicks `Open in OpenWork` or copies manual credentials. -6. In OpenWork app, user uses `Add a worker` -> `Connect remote` and starts working. +How to decide if OpenWork should do something: +- Does it help Bob share config more easily? +- Does it help Susan consume automations more easily? +- Is this something that is coding specific? diff --git a/STATS.md b/STATS.md index 9a9ad399..72484a4e 100644 --- a/STATS.md +++ b/STATS.md @@ -66,3 +66,9 @@ Legacy cumulative release-asset totals. For classified v2 buckets, see `STATS_V2 | 2026-03-22 | 184,744 (+588) | 184,744 (+588) | | 2026-03-23 | 185,371 (+627) | 185,371 (+627) | | 2026-03-24 | 186,649 (+1,278) | 186,649 (+1,278) | +| 2026-03-25 | 187,746 (+1,097) | 187,746 (+1,097) | +| 2026-03-26 | 193,858 (+6,112) | 193,858 (+6,112) | +| 2026-03-27 | 200,722 (+6,864) | 200,722 (+6,864) | +| 2026-03-28 | 206,754 (+6,032) | 206,754 (+6,032) | +| 2026-03-29 | 211,210 (+4,456) | 211,210 (+4,456) | +| 2026-03-30 | 217,507 (+6,297) | 217,507 (+6,297) | diff --git a/STATS_V2.md b/STATS_V2.md index 018260ca..c98a2c98 100644 --- a/STATS_V2.md +++ b/STATS_V2.md @@ -22,3 +22,9 @@ Classified GitHub release asset snapshots. `Manual installs` counts installer do | 2026-03-22 | 60,687 (+129) | 106,219 (+380) | 17,838 (+79) | 184,744 (+588) | | 2026-03-23 | 60,848 (+161) | 106,545 (+326) | 17,978 (+140) | 185,371 (+627) | | 2026-03-24 | 61,247 (+399) | 107,230 (+685) | 18,172 (+194) | 186,649 (+1,278) | +| 2026-03-25 | 61,477 (+230) | 107,957 (+727) | 18,312 (+140) | 187,746 (+1,097) | +| 2026-03-26 | 63,032 (+1,555) | 112,084 (+4,127) | 18,742 (+430) | 193,858 (+6,112) | +| 2026-03-27 | 64,244 (+1,212) | 117,236 (+5,152) | 19,242 (+500) | 200,722 (+6,864) | +| 2026-03-28 | 65,441 (+1,197) | 121,574 (+4,338) | 19,739 (+497) | 206,754 (+6,032) | +| 2026-03-29 | 66,202 (+761) | 125,041 (+3,467) | 19,967 (+228) | 211,210 (+4,456) | +| 2026-03-30 | 67,249 (+1,047) | 129,987 (+4,946) | 20,271 (+304) | 217,507 (+6,297) | diff --git a/apps/app/package.json b/apps/app/package.json index 35f6af1c..faee2e60 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@openwork/app", "private": true, - "version": "0.11.186", + "version": "0.11.196", "type": "module", "scripts": { "dev": "OPENWORK_DEV_MODE=1 vite", diff --git a/apps/app/pr-issue-777-greeting-smoke.png b/apps/app/pr-issue-777-greeting-smoke.png deleted file mode 100644 index fda4959f..00000000 Binary files a/apps/app/pr-issue-777-greeting-smoke.png and /dev/null differ diff --git a/apps/app/progress.json b/apps/app/progress.json deleted file mode 100644 index 8521a214..00000000 --- a/apps/app/progress.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "project": "openwork", - "target": "v0.3", - "milestones": { - "v0.1": { - "description": "Engine + client", - "items": [ - { "id": "v0.1-1", "title": "Tauri app shell", "status": "completed" }, - { - "id": "v0.1-2", - "title": "Start/stop OpenCode server (host mode)", - "status": "completed" - }, - { "id": "v0.1-3", "title": "Connect client and show health", "status": "completed" }, - { "id": "v0.1-4", "title": "List sessions", "status": "completed" } - ] - }, - "v0.2": { - "description": "Full run loop", - "items": [ - { "id": "v0.2-1", "title": "Create session", "status": "completed" }, - { "id": "v0.2-2", "title": "Send prompt", "status": "completed" }, - { "id": "v0.2-3", "title": "Subscribe to SSE events", "status": "completed" }, - { "id": "v0.2-4", "title": "Render step/tool timeline", "status": "completed" }, - { "id": "v0.2-5", "title": "Surface permission requests + respond", "status": "completed" } - ] - }, - "v0.3": { - "description": "Premium UX", - "items": [ - { "id": "v0.3-1", "title": "Design-driven UI (design.ts)", "status": "completed" }, - { "id": "v0.3-2", "title": "Mobile navigation + responsive layouts", "status": "completed" }, - { "id": "v0.3-3", "title": "Templates (create/save/run)", "status": "completed" }, - { "id": "v0.3-4", "title": "Skills manager (list/install/import)", "status": "completed" }, - { "id": "v0.3-5", "title": "Folder picker (native dialog)", "status": "completed" }, - { "id": "v0.3-6", "title": "No reasoning/tool metadata leaks", "status": "completed" } - ] - } - }, - "notes": [ - "Frontend uses @opencode-ai/sdk/v2/client to avoid bundling Node-only server code.", - "Engine spawns `opencode serve` from Rust with a per-run working directory.", - "`cargo check` runs successfully; a placeholder icon exists at `packages/desktop/src-tauri/icons/icon.png`.", - "OpenCode mirror cloned at `vendor/opencode` (gitignored).", - "UI follows `design.ts` (ported to Solid + Tailwind) and is wired to real OpenCode v2 sessions/messages/todos/permissions.", - "Folder picking uses Tauri dialog plugin (no manual path required in Host mode).", - "Templates UI exists (create/save/run) backed by localStorage.", - "Skills UI exists: list installed `.opencode/skill` and install/import via OpenPackage + local folder import (Host mode).", - "Redacts sensitive metadata keys (e.g. reasoningEncryptedContent) from UI to prevent leaks.", - "`pnpm typecheck` and `pnpm build:ui` succeed.", - "Permissions entrypoint exists, but permission prompts may not appear without agent-driven tool calls." - ], - "lastUpdated": "2026-01-14" -} diff --git a/apps/app/scripts/_util.mjs b/apps/app/scripts/_util.mjs index 920b13b0..aa6bcc09 100644 --- a/apps/app/scripts/_util.mjs +++ b/apps/app/scripts/_util.mjs @@ -6,10 +6,20 @@ import { realpathSync, statSync } from "node:fs"; import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; +function resolveBasicAuthHeader() { + const password = process.env.OPENCODE_SERVER_PASSWORD?.trim() ?? ""; + if (!password) return undefined; + const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || "opencode"; + const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); + return `Basic ${encoded}`; +} + export function makeClient({ baseUrl, directory }) { + const authorization = resolveBasicAuthHeader(); return createOpencodeClient({ baseUrl, directory, + headers: authorization ? { Authorization: authorization } : undefined, responseStyle: "data", throwOnError: true, }); diff --git a/apps/app/scripts/bundle-url-policy.ts b/apps/app/scripts/bundle-url-policy.ts new file mode 100644 index 00000000..112eb8b2 --- /dev/null +++ b/apps/app/scripts/bundle-url-policy.ts @@ -0,0 +1,45 @@ +import { strict as assert } from "node:assert"; + +import { describeBundleUrlTrust, isConfiguredBundlePublisherUrl } from "../src/app/bundles/url-policy"; + +const trusted = describeBundleUrlTrust( + "https://share.openworklabs.com/b/01ARZ3NDEKTSV4RRFFQ69G5FAV", + "https://share.openworklabs.com", +); + +assert.deepEqual(trusted, { + trusted: true, + bundleId: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + actualOrigin: "https://share.openworklabs.com", + configuredOrigin: "https://share.openworklabs.com", +}); + +const untrusted = describeBundleUrlTrust( + "https://evil.example/b/01ARZ3NDEKTSV4RRFFQ69G5FAV", + "https://share.openworklabs.com", +); + +assert.deepEqual(untrusted, { + trusted: false, + bundleId: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + actualOrigin: "https://evil.example", + configuredOrigin: "https://share.openworklabs.com", +}); + +assert.equal( + isConfiguredBundlePublisherUrl( + "https://share.openworklabs.com/b/01ARZ3NDEKTSV4RRFFQ69G5FAV", + "https://share.openworklabs.com", + ), + true, +); + +assert.equal( + isConfiguredBundlePublisherUrl( + "https://share.openworklabs.com/not-a-bundle", + "https://share.openworklabs.com", + ), + false, +); + +console.log("bundle-url-policy ok"); diff --git a/apps/app/src/app/app-settings/authorized-folders-panel.tsx b/apps/app/src/app/app-settings/authorized-folders-panel.tsx new file mode 100644 index 00000000..3b4c85c4 --- /dev/null +++ b/apps/app/src/app/app-settings/authorized-folders-panel.tsx @@ -0,0 +1,492 @@ +import { + For, + Show, + createEffect, + createMemo, + createSignal, + onCleanup, +} from "solid-js"; + +import { Folder, FolderLock, FolderSearch, X } from "lucide-solid"; + +import { currentLocale, t } from "../../i18n"; +import Button from "../components/button"; +import type { + OpenworkServerCapabilities, + OpenworkServerClient, + OpenworkServerStatus, +} from "../lib/openwork-server"; +import { pickDirectory } from "../lib/tauri"; +import { + isTauriRuntime, + normalizeDirectoryQueryPath, + safeStringify, +} from "../utils"; + +type AuthorizedFoldersPanelProps = { + openworkServerClient: OpenworkServerClient | null; + openworkServerStatus: OpenworkServerStatus; + openworkServerCapabilities: OpenworkServerCapabilities | null; + runtimeWorkspaceId: string | null; + selectedWorkspaceRoot: string; + activeWorkspaceType: "local" | "remote"; + onConfigUpdated: () => void; +}; + +const panelClass = "rounded-[28px] border border-dls-border bg-dls-surface p-5 md:p-6"; +const softPanelClass = "rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4"; + +const ensureRecord = (value: unknown): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +}; + +const normalizeAuthorizedFolderPath = (input: string | null | undefined) => { + const trimmed = (input ?? "").trim(); + if (!trimmed) return ""; + const withoutWildcard = trimmed.replace(/[\\/]\*+$/, ""); + return normalizeDirectoryQueryPath(withoutWildcard); +}; + +const authorizedFolderToExternalDirectoryKey = (folder: string) => { + const normalized = normalizeAuthorizedFolderPath(folder); + if (!normalized) return ""; + return normalized === "/" ? "/*" : `${normalized}/*`; +}; + +const externalDirectoryKeyToAuthorizedFolder = (key: string, value: unknown) => { + if (value !== "allow") return null; + const trimmed = key.trim(); + if (!trimmed) return null; + if (trimmed === "/*") return "/"; + if (!trimmed.endsWith("/*")) return null; + return normalizeAuthorizedFolderPath(trimmed.slice(0, -2)); +}; + +const readAuthorizedFoldersFromConfig = (opencodeConfig: Record) => { + const permission = ensureRecord(opencodeConfig.permission); + const externalDirectory = ensureRecord(permission.external_directory); + const folders: string[] = []; + const hiddenEntries: Record = {}; + const seen = new Set(); + + for (const [key, value] of Object.entries(externalDirectory)) { + const folder = externalDirectoryKeyToAuthorizedFolder(key, value); + if (!folder) { + hiddenEntries[key] = value; + continue; + } + if (seen.has(folder)) continue; + seen.add(folder); + folders.push(folder); + } + + return { folders, hiddenEntries }; +}; + +const buildAuthorizedFoldersStatus = (preservedCount: number, action?: string) => { + const preservedLabel = + preservedCount > 0 + ? `Preserving ${preservedCount} non-folder permission ${preservedCount === 1 ? "entry" : "entries"}.` + : null; + if (action && preservedLabel) return `${action} ${preservedLabel}`; + return action ?? preservedLabel; +}; + +const mergeAuthorizedFoldersIntoExternalDirectory = ( + folders: string[], + hiddenEntries: Record, +): Record | undefined => { + const next: Record = { ...hiddenEntries }; + for (const folder of folders) { + const key = authorizedFolderToExternalDirectoryKey(folder); + if (!key) continue; + next[key] = "allow"; + } + return Object.keys(next).length ? next : undefined; +}; + +export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProps) { + const [authorizedFolders, setAuthorizedFolders] = createSignal([]); + const [authorizedFolderDraft, setAuthorizedFolderDraft] = createSignal(""); + const [authorizedFoldersLoading, setAuthorizedFoldersLoading] = createSignal(false); + const [authorizedFoldersSaving, setAuthorizedFoldersSaving] = createSignal(false); + const [authorizedFoldersStatus, setAuthorizedFoldersStatus] = createSignal(null); + const [authorizedFoldersError, setAuthorizedFoldersError] = createSignal(null); + + const openworkServerReady = createMemo( + () => props.openworkServerStatus === "connected", + ); + const openworkServerWorkspaceReady = createMemo( + () => Boolean(props.runtimeWorkspaceId), + ); + const canReadConfig = createMemo( + () => + openworkServerReady() && + openworkServerWorkspaceReady() && + (props.openworkServerCapabilities?.config?.read ?? false), + ); + const canWriteConfig = createMemo( + () => + openworkServerReady() && + openworkServerWorkspaceReady() && + (props.openworkServerCapabilities?.config?.write ?? false), + ); + const authorizedFoldersHint = createMemo(() => { + if (!openworkServerReady()) return "OpenWork server is disconnected."; + if (!openworkServerWorkspaceReady()) return "No active server workspace is selected."; + if (!canReadConfig()) { + return "OpenWork server config access is unavailable for this workspace."; + } + if (!canWriteConfig()) { + return "OpenWork server is connected read-only for workspace config."; + } + return null; + }); + const canPickAuthorizedFolder = createMemo( + () => isTauriRuntime() && canWriteConfig() && props.activeWorkspaceType === "local", + ); + const workspaceRootFolder = createMemo(() => props.selectedWorkspaceRoot.trim()); + const visibleAuthorizedFolders = createMemo(() => { + const root = workspaceRootFolder(); + return root ? [root, ...authorizedFolders()] : authorizedFolders(); + }); + + createEffect(() => { + const openworkClient = props.openworkServerClient; + const openworkWorkspaceId = props.runtimeWorkspaceId; + const readable = canReadConfig(); + + if (!openworkClient || !openworkWorkspaceId || !readable) { + setAuthorizedFolders([]); + setAuthorizedFolderDraft(""); + setAuthorizedFoldersLoading(false); + setAuthorizedFoldersSaving(false); + setAuthorizedFoldersStatus(null); + setAuthorizedFoldersError(null); + return; + } + + let cancelled = false; + setAuthorizedFolderDraft(""); + setAuthorizedFoldersLoading(true); + setAuthorizedFoldersError(null); + setAuthorizedFoldersStatus(null); + + const loadAuthorizedFolders = async () => { + try { + const config = await openworkClient.getConfig(openworkWorkspaceId); + if (cancelled) return; + const next = readAuthorizedFoldersFromConfig(ensureRecord(config.opencode)); + setAuthorizedFolders(next.folders); + setAuthorizedFoldersStatus( + buildAuthorizedFoldersStatus(Object.keys(next.hiddenEntries).length), + ); + } catch (error) { + if (cancelled) return; + const message = error instanceof Error ? error.message : safeStringify(error); + setAuthorizedFolders([]); + setAuthorizedFoldersError(message); + } finally { + if (!cancelled) { + setAuthorizedFoldersLoading(false); + } + } + }; + + void loadAuthorizedFolders(); + + onCleanup(() => { + cancelled = true; + }); + }); + + const persistAuthorizedFolders = async (nextFolders: string[]) => { + const openworkClient = props.openworkServerClient; + const openworkWorkspaceId = props.runtimeWorkspaceId; + if (!openworkClient || !openworkWorkspaceId || !canWriteConfig()) { + setAuthorizedFoldersError( + "A writable OpenWork server workspace is required to update authorized folders.", + ); + return false; + } + + setAuthorizedFoldersSaving(true); + setAuthorizedFoldersError(null); + setAuthorizedFoldersStatus("Saving authorized folders..."); + + try { + const currentConfig = await openworkClient.getConfig(openworkWorkspaceId); + const currentAuthorizedFolders = readAuthorizedFoldersFromConfig( + ensureRecord(currentConfig.opencode), + ); + const nextExternalDirectory = mergeAuthorizedFoldersIntoExternalDirectory( + nextFolders, + currentAuthorizedFolders.hiddenEntries, + ); + + await openworkClient.patchConfig(openworkWorkspaceId, { + opencode: { + permission: { + external_directory: nextExternalDirectory, + }, + }, + }); + setAuthorizedFolders(nextFolders); + setAuthorizedFoldersStatus( + buildAuthorizedFoldersStatus( + Object.keys(currentAuthorizedFolders.hiddenEntries).length, + "Authorized folders updated.", + ), + ); + props.onConfigUpdated(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + setAuthorizedFoldersError(message); + setAuthorizedFoldersStatus(null); + return false; + } finally { + setAuthorizedFoldersSaving(false); + } + }; + + const addAuthorizedFolder = async () => { + const normalized = normalizeAuthorizedFolderPath(authorizedFolderDraft()); + const workspaceRoot = normalizeAuthorizedFolderPath(workspaceRootFolder()); + if (!normalized) return; + if (workspaceRoot && normalized === workspaceRoot) { + setAuthorizedFolderDraft(""); + setAuthorizedFoldersStatus("Workspace root is already available."); + setAuthorizedFoldersError(null); + return; + } + if (authorizedFolders().includes(normalized)) { + setAuthorizedFolderDraft(""); + setAuthorizedFoldersStatus("Folder is already authorized."); + setAuthorizedFoldersError(null); + return; + } + + const ok = await persistAuthorizedFolders([...authorizedFolders(), normalized]); + if (ok) { + setAuthorizedFolderDraft(""); + } + }; + + const removeAuthorizedFolder = async (folder: string) => { + const nextFolders = authorizedFolders().filter((entry) => entry !== folder); + await persistAuthorizedFolders(nextFolders); + }; + + const pickAuthorizedFolder = async () => { + if (!isTauriRuntime()) return; + try { + const selection = await pickDirectory({ + title: t("onboarding.authorize_folder", currentLocale()), + }); + const folder = + typeof selection === "string" + ? selection + : Array.isArray(selection) + ? selection[0] + : null; + const normalized = normalizeAuthorizedFolderPath(folder); + const workspaceRoot = normalizeAuthorizedFolderPath(workspaceRootFolder()); + if (!normalized) return; + setAuthorizedFolderDraft(normalized); + if (workspaceRoot && normalized === workspaceRoot) { + setAuthorizedFolderDraft(""); + setAuthorizedFoldersStatus("Workspace root is already available."); + setAuthorizedFoldersError(null); + return; + } + if (authorizedFolders().includes(normalized)) { + setAuthorizedFoldersStatus("Folder is already authorized."); + setAuthorizedFoldersError(null); + return; + } + const ok = await persistAuthorizedFolders([...authorizedFolders(), normalized]); + if (ok) { + setAuthorizedFolderDraft(""); + } + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + setAuthorizedFoldersError(message); + } + }; + + return ( +
+
+
+ + Authorized folders +
+
+ Grant this workspace access to read and edit files in directories outside of its root. +
+
+ + + {authorizedFoldersHint() ?? + "Connect to a writable OpenWork server workspace to edit authorized folders."} +
+ } + > +
+ + {(hint) => ( +
+ {hint()} +
+ )} +
+ + 0} + fallback={ +
+
+ +
+
No external folders authorized
+
+ Add a folder to let this workspace read and edit files outside its root directory. +
+
+ } + > +
+ + {(folder) => { + const isWorkspaceRoot = folder === workspaceRootFolder(); + const folderName = folder.split(/[\/\\]/).filter(Boolean).pop() || folder; + return ( +
+
+
+ +
+
+
+ {folderName} + + + Workspace root + + +
+ {folder} +
+
+ + Always available + + } + > + + +
+ ); + }} +
+
+
+ + + {(status) => ( +
+ {status()} +
+ )} +
+ + {(error) => ( +
+ {error()} +
+ )} +
+ +
{ + event.preventDefault(); + void addAuthorizedFolder(); + }} + > +
+ setAuthorizedFolderDraft(event.currentTarget.value)} + onPaste={(event) => { + event.preventDefault(); + }} + placeholder="Type a folder path to authorize..." + disabled={ + authorizedFoldersLoading() || + authorizedFoldersSaving() || + !canWriteConfig() + } + /> +
+ + + + + + +
+
+ + + ); +} diff --git a/apps/app/src/app/app-settings/model-controls-provider.tsx b/apps/app/src/app/app-settings/model-controls-provider.tsx new file mode 100644 index 00000000..b55c76ac --- /dev/null +++ b/apps/app/src/app/app-settings/model-controls-provider.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +import type { ModelControlsStore } from "./model-controls-store"; + +const ModelControlsContext = createContext(); + +export function ModelControlsProvider(props: ParentProps<{ store: ModelControlsStore }>) { + return ( + + {props.children} + + ); +} + +export function useModelControls() { + const context = useContext(ModelControlsContext); + if (!context) { + throw new Error("useModelControls must be used within a ModelControlsProvider"); + } + return context; +} diff --git a/apps/app/src/app/app-settings/model-controls-store.ts b/apps/app/src/app/app-settings/model-controls-store.ts new file mode 100644 index 00000000..9d76247d --- /dev/null +++ b/apps/app/src/app/app-settings/model-controls-store.ts @@ -0,0 +1,24 @@ +import type { Accessor } from "solid-js"; + +export type ModelBehaviorOption = { value: string | null; label: string }; + +export type ModelControlsStore = ReturnType; + +export function createModelControlsStore(options: { + selectedSessionModelLabel: Accessor; + openSessionModelPicker: (options?: { returnFocusTarget?: "none" | "composer" }) => void; + sessionModelVariantLabel: Accessor; + sessionModelVariant: Accessor; + sessionModelBehaviorOptions: Accessor; + setSessionModelVariant: (value: string | null) => void; + defaultModelLabel: Accessor; + defaultModelRef: Accessor; + openDefaultModelPicker: () => void; + autoCompactContext: Accessor; + toggleAutoCompactContext: () => void; + autoCompactContextBusy: Accessor; + defaultModelVariantLabel: Accessor; + editDefaultModelVariant: () => void; +}) { + return options; +} diff --git a/apps/app/src/app/app-settings/session-display-preferences.ts b/apps/app/src/app/app-settings/session-display-preferences.ts new file mode 100644 index 00000000..ad17360a --- /dev/null +++ b/apps/app/src/app/app-settings/session-display-preferences.ts @@ -0,0 +1,30 @@ +import { useLocal } from "../context/local"; + +type BooleanUpdater = boolean | ((current: boolean) => boolean); + +export function useSessionDisplayPreferences() { + const { prefs, setPrefs } = useLocal(); + + const showThinking = () => prefs.showThinking; + + const setShowThinking = (value: BooleanUpdater) => { + setPrefs("showThinking", (current) => + typeof value === "function" ? value(current) : value, + ); + }; + + const toggleShowThinking = () => { + setShowThinking((current) => !current); + }; + + const resetSessionDisplayPreferences = () => { + setShowThinking(false); + }; + + return { + showThinking, + setShowThinking, + toggleShowThinking, + resetSessionDisplayPreferences, + }; +} diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index 330fd379..36105c7f 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -1,6 +1,5 @@ import { Match, - Show, Switch, createEffect, createMemo, @@ -12,136 +11,70 @@ import { import { useLocation, useNavigate } from "@solidjs/router"; -import type { - Agent, - Part, - ProviderAuthAuthorization, - Session, - TextPartInput, - FilePartInput, - AgentPartInput, - SubtaskPartInput, -} from "@opencode-ai/sdk/v2/client"; +import type { Session } from "@opencode-ai/sdk/v2/client"; import { getVersion } from "@tauri-apps/api/app"; -import { homeDir } from "@tauri-apps/api/path"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { parse } from "jsonc-parser"; - import ModelPickerModal from "./components/model-picker-modal"; +import ConfirmModal from "./components/confirm-modal"; import ResetModal from "./components/reset-modal"; -import WorkspaceSwitchOverlay from "./components/workspace-switch-overlay"; -import CreateRemoteWorkspaceModal from "./components/create-remote-workspace-modal"; -import CreateWorkspaceModal from "./components/create-workspace-modal"; -import SharedSkillDestinationModal from "./components/shared-skill-destination-modal"; -import SharedBundleImportModal from "./components/shared-bundle-import-modal"; +import SkillDestinationModal from "./bundles/skill-destination-modal"; +import BundleImportModal from "./bundles/import-modal"; +import BundleStartModal from "./bundles/start-modal"; import RenameWorkspaceModal from "./components/rename-workspace-modal"; -import McpAuthModal from "./components/mcp-auth-modal"; -import StatusToast from "./components/status-toast"; -import OnboardingView from "./pages/onboarding"; -import DashboardView from "./pages/dashboard"; -import SessionView from "./pages/session"; -import ProtoWorkspacesView from "./pages/proto-workspaces"; -import ProtoV1UxView from "./pages/proto-v1-ux"; -import { createClient, unwrap, waitForHealthy, type OpencodeAuth } from "./lib/opencode"; -import { createDenClient, normalizeDenBaseUrl, writeDenSettings, DEFAULT_DEN_BASE_URL } from "./lib/den"; +import ConnectionsModals from "./connections/modals"; +import { OpenworkServerProvider } from "./connections/openwork-server-provider"; +import { createOpenworkServerStore } from "./connections/openwork-server-store"; +import { ConnectionsProvider } from "./connections/provider"; +import { ExtensionsProvider } from "./extensions/provider"; +import { AutomationsProvider } from "./automations/provider"; +import { SessionActionsProvider } from "./session/actions-provider"; +import { createSessionActionsStore } from "./session/actions-store"; +import BootShell from "./shell/boot-shell"; +import { createDeepLinksController } from "./shell/deep-links"; +import SettingsShell from "./shell/settings-shell"; +import TopRightNotifications from "./shell/top-right-notifications"; +import { createStatusToastsStore, StatusToastsProvider } from "./shell/status-toasts"; import { - abortSession as abortSessionTyped, - abortSessionSafe, - compactSession as compactSessionTyped, - revertSession, - unrevertSession, - shellInSession, - listCommands as listCommandsTyped, -} from "./lib/opencode-session"; + CreateRemoteWorkspaceModal, + CreateWorkspaceModal, +} from "./workspace"; +import SessionView from "./pages/session"; +import { unwrap } from "./lib/opencode"; import { clearPerfLogs, finishPerf, perfNow, recordPerfLog } from "./lib/perf-log"; import { deepLinkBridgeEvent, drainPendingDeepLinks, type DeepLinkBridgeDetail } from "./lib/deep-link-bridge"; import { - AUTO_COMPACT_CONTEXT_PREF_KEY, - CHROME_DEVTOOLS_MCP_ID, - DEFAULT_MODEL, HIDE_TITLEBAR_PREF_KEY, - MCP_QUICK_CONNECT, - MODEL_PREF_KEY, - SESSION_MODEL_PREF_KEY, SUGGESTED_PLUGINS, - THINKING_PREF_KEY, - VARIANT_PREF_KEY, } from "./constants"; -import { - parseMcpServersFromContent, - removeMcpFromConfig, - usesChromeDevtoolsAutoConnect, - validateMcpServerName, -} from "./mcp"; -import { - compareProviders, - mapConfigProvidersToList, - providerPriorityRank, -} from "./utils/providers"; -import { - buildDefaultWorkspaceBlueprint, - normalizeWorkspaceOpenworkConfig, -} from "./lib/workspace-blueprints"; -import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "./types"; import type { Client, - DashboardTab, - MessageWithParts, - PlaceholderAssistantMessage, StartupPreference, EngineRuntime, - ModelOption, - ModelRef, OnboardingStep, - PluginScope, ReloadReason, ReloadTrigger, - ResetOpenworkMode, SettingsTab, - SkillCard, - SidebarSessionItem, - TodoItem, View, - WorkspaceSessionGroup, WorkspaceDisplay, - McpServerEntry, - McpStatusMap, - ComposerAttachment, + WorkspaceSessionGroup, ComposerDraft, - ComposerPart, ProviderListItem, - SessionErrorTurn, - UpdateHandle, OpencodeConnectStatus, - ScheduledJob, - WorkspacePreset, - WorkspaceOpenworkConfig, } from "./types"; import { clearStartupPreference, deriveArtifacts, deriveWorkingFiles, - formatBytes, - formatModelLabel, - formatModelRef, - formatRelativeTime, - groupMessageParts, - isVisibleTextPart, isTauriRuntime, - modelEquals, - normalizeDirectoryQueryPath, normalizeDirectoryPath, } from "./utils"; -import { currentLocale, setLocale, t, type Language } from "../i18n"; +import { currentLocale, setLocale, t } from "../i18n"; import { isWindowsPlatform, lastUserModelFromMessages, - // normalizeDirectoryPath, - parseModelRef, readStartupPreference, safeStringify, - summarizeStep, addOpencodeCacheHint, } from "./utils"; import { @@ -152,50 +85,29 @@ import { type ThemeMode, } from "./theme"; import { createSystemState } from "./system-state"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { createSessionStore } from "./context/session"; import { - formatGenericBehaviorLabel, - getModelBehaviorSummary, - normalizeModelBehaviorValue, - sanitizeModelBehaviorValue, -} from "./lib/model-behavior"; + createModelConfigStore, +} from "./context/model-config"; +import { createProvidersStore } from "./context/providers"; +import { ModelControlsProvider } from "./app-settings/model-controls-provider"; +import { createModelControlsStore } from "./app-settings/model-controls-store"; +import { useSessionDisplayPreferences } from "./app-settings/session-display-preferences"; import { describeDirectoryScope, - shouldApplyScopedSessionLoad, shouldRedirectMissingSessionAfterScopedLoad, - toSessionTransportDirectory, } from "./lib/session-scope"; - -const fileToDataUrl = (file: File) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(new Error(`Failed to read attachment: ${file.name}`)); - reader.onload = () => { - const result = typeof reader.result === "string" ? reader.result : ""; - resolve(result); - }; - reader.readAsDataURL(file); - }); import { createExtensionsStore } from "./context/extensions"; +import { createConnectionsStore } from "./connections/store"; +import { createAutomationsStore } from "./context/automations"; +import { createSidebarSessionsStore } from "./context/sidebar-sessions"; import { useGlobalSync } from "./context/global-sync"; import { createWorkspaceStore } from "./context/workspace"; import { updaterEnvironment, readOpencodeConfig, writeOpencodeConfig, - schedulerDeleteJob, - schedulerListJobs, - openworkServerInfo, - orchestratorStatus, - opencodeRouterInfo, - pickDirectory, setWindowDecorations, - type OrchestratorStatus, - type OpenworkServerInfo, - type OpenCodeRouterInfo, - type WorkspaceInfo, } from "./lib/tauri"; import { FONT_ZOOM_STEP, @@ -208,628 +120,35 @@ import { } from "./lib/font-zoom"; import { parseOpenworkWorkspaceIdFromUrl, - readOpenworkBundleInviteFromSearch, readOpenworkConnectInviteFromSearch, - stripOpenworkBundleInviteFromUrl, stripOpenworkConnectInviteFromUrl, - createOpenworkServerClient, hydrateOpenworkServerSettingsFromEnv, normalizeOpenworkServerUrl, readOpenworkServerSettings, writeOpenworkServerSettings, - clearOpenworkServerSettings, - type OpenworkAuditEntry, - type OpenworkServerCapabilities, type OpenworkServerDiagnostics, - type OpenworkServerStatus, type OpenworkServerSettings, - type OpenworkWorkspaceExport, - OpenworkServerError, } from "./lib/openwork-server"; - -type RemoteWorkspaceDefaults = { - openworkHostUrl?: string | null; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; - autoConnect?: boolean; -}; - -type SharedSkillItem = { - name: string; - description?: string; - content: string; - trigger?: string; -}; - -type SharedSkillBundleV1 = { - schemaVersion: 1; - type: "skill"; - name: string; - description?: string; - trigger?: string; - content: string; -}; - -type SharedSkillsSetBundleV1 = { - schemaVersion: 1; - type: "skills-set"; - name: string; - description?: string; - skills: SharedSkillItem[]; -}; - -type SharedWorkspaceProfileBundleV1 = { - schemaVersion: 1; - type: "workspace-profile"; - name: string; - description?: string; - workspace: OpenworkWorkspaceExport; -}; - -type SharedBundleV1 = - | SharedSkillBundleV1 - | SharedSkillsSetBundleV1 - | SharedWorkspaceProfileBundleV1; - -type SharedBundleImportIntent = "new_worker" | "import_current"; - -type SharedBundleDeepLink = { - bundleUrl: string; - intent: SharedBundleImportIntent; - source?: string; - orgId?: string; - label?: string; -}; - -type SharedBundleCreateWorkerRequest = { - request: SharedBundleDeepLink; - bundle: SharedBundleV1; - defaultPreset: WorkspacePreset; -}; - -type SharedSkillDestinationRequest = { - request: SharedBundleDeepLink; - bundle: SharedSkillBundleV1; -}; - -type SharedSkillSuccessToast = { - title: string; - description: string; -}; - -type SharedBundleImportTarget = { - workspaceId?: string | null; - localRoot?: string | null; - directoryHint?: string | null; -}; - -type SharedBundleImportChoice = { - request: SharedBundleDeepLink; - bundle: SharedBundleV1; -}; +import { + parseBundleDeepLink, + stripBundleQuery, +} from "./bundles"; +import { createBundlesStore } from "./bundles/store"; type SettingsReturnTarget = { view: View; - tab: DashboardTab; + tab: SettingsTab; sessionId: string | null; }; -function normalizeSharedBundleImportIntent(value: string | null | undefined): SharedBundleImportIntent { - const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") { - return "new_worker"; - } - return "import_current"; -} - -function isSupportedDeepLinkProtocol(protocol: string): boolean { - const normalized = protocol.toLowerCase(); - return ( - normalized === "openwork:" || - normalized === "openwork-dev:" || - normalized === "https:" || - normalized === "http:" - ); -} - -function describeSharedBundleImport(bundle: SharedBundleV1): { title: string; description: string; items: string[] } { - if (bundle.type === "skill") { - return { - title: "Import 1 skill", - description: bundle.description?.trim() || `Add \`${bundle.name}\` to an existing worker or create a new one for it.`, - items: [bundle.name], - }; - } - - if (bundle.type === "skills-set") { - const count = bundle.skills.length; - return { - title: `Import ${count} skill${count === 1 ? "" : "s"}`, - description: - bundle.description?.trim() || - `${bundle.name || "Shared skills"} is ready to import into an existing worker or a new worker.`, - items: bundle.skills.map((skill) => skill.name), - }; - } - - return { - title: "Import workspace bundle", - description: - bundle.description?.trim() || - `Create a new worker to import ${bundle.name || "this shared workspace bundle"}.`, - items: Array.isArray(bundle.workspace.skills) ? bundle.workspace.skills.map((skill) => skill.name) : [], - }; -} - -function readRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record; -} - -function readSkillItem(value: unknown): SharedSkillItem | null { - const record = readRecord(value); - if (!record) return null; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const content = typeof record.content === "string" ? record.content : ""; - if (!name || !content) return null; - return { - name, - description: typeof record.description === "string" ? record.description : undefined, - trigger: typeof record.trigger === "string" ? record.trigger : undefined, - content, - }; -} - -function parseSharedBundle(value: unknown): SharedBundleV1 { - const record = readRecord(value); - if (!record) { - throw new Error("Invalid shared bundle payload."); - } - - const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : null; - const type = typeof record.type === "string" ? record.type.trim() : ""; - const name = typeof record.name === "string" ? record.name.trim() : ""; - - if (schemaVersion !== 1) { - throw new Error("Unsupported bundle schema version."); - } - - if (type === "skill") { - const content = typeof record.content === "string" ? record.content : ""; - if (!name || !content) { - throw new Error("Invalid skill bundle payload."); - } - return { - schemaVersion: 1, - type: "skill", - name, - description: typeof record.description === "string" ? record.description : undefined, - trigger: typeof record.trigger === "string" ? record.trigger : undefined, - content, - }; - } - - if (type === "skills-set") { - const skills = Array.isArray(record.skills) - ? record.skills.map(readSkillItem).filter((item): item is SharedSkillItem => Boolean(item)) - : []; - if (!skills.length) { - throw new Error("Skills set bundle has no importable skills."); - } - return { - schemaVersion: 1, - type: "skills-set", - name: name || "Shared skills", - description: typeof record.description === "string" ? record.description : undefined, - skills, - }; - } - - if (type === "workspace-profile") { - const workspace = readRecord(record.workspace); - if (!workspace) { - throw new Error("Workspace profile bundle is missing workspace payload."); - } - return { - schemaVersion: 1, - type: "workspace-profile", - name: name || "Shared workspace profile", - description: typeof record.description === "string" ? record.description : undefined, - workspace: workspace as OpenworkWorkspaceExport, - }; - } - - throw new Error(`Unsupported bundle type: ${type || "unknown"}`); -} - -async function fetchSharedBundle(bundleUrl: string): Promise { - let targetUrl: URL; - try { - targetUrl = new URL(bundleUrl); - } catch { - throw new Error("Invalid shared bundle URL."); - } - - if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") { - throw new Error("Shared bundle URL must use http(s)."); - } - - if (!targetUrl.searchParams.has("format")) { - targetUrl.searchParams.set("format", "json"); - } - - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 15_000); - - try { - let response: Response; - try { - response = isTauriRuntime() - ? await tauriFetch(targetUrl.toString(), { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }) - : await fetch(targetUrl.toString(), { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - throw new Error(`Failed to load shared bundle from ${targetUrl.toString()}: ${message}`); - } - if (!response.ok) { - const details = (await response.text()).trim(); - const suffix = details ? `: ${details}` : ""; - throw new Error(`Failed to fetch bundle from ${targetUrl.toString()} (${response.status})${suffix}`); - } - return parseSharedBundle(await response.json()); - } finally { - window.clearTimeout(timeout); - } -} - -function buildImportPayloadFromBundle(bundle: SharedBundleV1): { - payload: Record; - importedSkillsCount: number; -} { - if (bundle.type === "skill") { - return { - payload: { - mode: { skills: "merge" }, - skills: [ - { - name: bundle.name, - description: bundle.description, - trigger: bundle.trigger, - content: bundle.content, - }, - ], - }, - importedSkillsCount: 1, - }; - } - - if (bundle.type === "skills-set") { - return { - payload: { - mode: { skills: "merge" }, - skills: bundle.skills.map((skill) => ({ - name: skill.name, - description: skill.description, - trigger: skill.trigger, - content: skill.content, - })), - }, - importedSkillsCount: bundle.skills.length, - }; - } - - const workspace = bundle.workspace; - const payload: Record = { - mode: { - opencode: "merge", - openwork: "merge", - skills: "merge", - commands: "merge", - }, - }; - if (workspace.opencode && typeof workspace.opencode === "object") payload.opencode = workspace.opencode; - if (workspace.openwork && typeof workspace.openwork === "object") payload.openwork = workspace.openwork; - if (Array.isArray(workspace.skills) && workspace.skills.length) payload.skills = workspace.skills; - if (Array.isArray(workspace.commands) && workspace.commands.length) payload.commands = workspace.commands; - - const importedSkillsCount = Array.isArray(workspace.skills) ? workspace.skills.length : 0; - return { payload, importedSkillsCount }; -} - -function parseSharedBundleDeepLink(rawUrl: string): SharedBundleDeepLink | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - const protocol = url.protocol.toLowerCase(); - if (!isSupportedDeepLinkProtocol(protocol)) { - return null; - } - - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - const looksLikeImportRoute = - routeHost === "import-bundle" || - routePath === "import-bundle" || - routeTail === "import-bundle"; - - const rawBundleUrl = - url.searchParams.get("ow_bundle") ?? - url.searchParams.get("bundleUrl") ?? - ""; - - if (!looksLikeImportRoute && !rawBundleUrl.trim()) { - return null; - } - - try { - if ((protocol === "https:" || protocol === "http:") && !rawBundleUrl.trim()) { - const host = url.hostname.toLowerCase(); - const path = url.pathname.replace(/^\/+/, ""); - const segments = path.split("/").filter(Boolean); - if ((host === "share.openwork.software" || host.endsWith(".openwork.software")) && segments[0] === "b" && segments[1]) { - const intent = normalizeSharedBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent")); - const source = url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? ""; - const orgId = url.searchParams.get("ow_org")?.trim() ?? ""; - const label = url.searchParams.get("ow_label")?.trim() ?? url.searchParams.get("label")?.trim() ?? ""; - return { - bundleUrl: url.toString(), - intent, - source: source || undefined, - orgId: orgId || undefined, - label: label || undefined, - }; - } - } - - const parsedBundleUrl = new URL(rawBundleUrl.trim()); - if (parsedBundleUrl.protocol !== "https:" && parsedBundleUrl.protocol !== "http:") { - return null; - } - const intent = normalizeSharedBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent")); - const source = url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? ""; - const orgId = url.searchParams.get("ow_org")?.trim() ?? ""; - const label = url.searchParams.get("ow_label")?.trim() ?? ""; - return { - bundleUrl: parsedBundleUrl.toString(), - intent, - source: source || undefined, - orgId: orgId || undefined, - label: label || undefined, - }; - } catch { - return null; - } -} - -function stripSharedBundleQuery(rawUrl: string): string | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - let changed = false; - for (const key of ["ow_bundle", "bundleUrl", "ow_intent", "intent", "ow_source", "source", "ow_org", "ow_label"]) { - if (url.searchParams.has(key)) { - url.searchParams.delete(key); - changed = true; - } - } - - if (!changed) { - return null; - } - - const search = url.searchParams.toString(); - return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; -} - -function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefaults | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - const protocol = url.protocol.toLowerCase(); - if (!isSupportedDeepLinkProtocol(protocol)) { - return null; - } - - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - if (routeHost !== "connect-remote" && routePath !== "connect-remote" && routeTail !== "connect-remote") { - return null; - } - - const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; - const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; - const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); - const token = tokenRaw.trim(); - if (!normalizedHostUrl || !token) { - return null; - } - - const workerName = url.searchParams.get("workerName")?.trim() ?? ""; - const workerId = url.searchParams.get("workerId")?.trim() ?? ""; - const displayName = workerName || (workerId ? `Worker ${workerId.slice(0, 8)}` : ""); - const autoConnectRaw = - url.searchParams.get("autoConnect") ?? - url.searchParams.get("bypassModal") ?? - url.searchParams.get("bypassAddWorkerModal") ?? - ""; - const autoConnect = ["1", "true", "yes", "on"].includes(autoConnectRaw.trim().toLowerCase()); - - return { - openworkHostUrl: normalizedHostUrl, - openworkToken: token, - directory: null, - displayName: displayName || null, - autoConnect, - }; -} - -type DenAuthDeepLink = { - grant: string; - denBaseUrl: string; +type PendingInitialSessionSelection = { + workspaceId: string; + title: string | null; + readyAt: number; }; -function parseDenAuthDeepLink(rawUrl: string): DenAuthDeepLink | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - const protocol = url.protocol.toLowerCase(); - if (!isSupportedDeepLinkProtocol(protocol)) { - return null; - } - - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - if (routeHost !== "den-auth" && routePath !== "den-auth" && routeTail !== "den-auth") { - return null; - } - - const grant = url.searchParams.get("grant")?.trim() ?? ""; - const denBaseUrl = normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? DEFAULT_DEN_BASE_URL; - if (!grant) { - return null; - } - - return { grant, denBaseUrl }; -} - -function normalizeDebugDeepLinkInput(rawValue: string): string { - const trimmed = rawValue.trim(); - if (!trimmed) return ""; - - const directMatch = trimmed.match(/(?:openwork-dev|openwork|https?):\/\/[^\s"'<>]+/i); - if (directMatch) return directMatch[0]; - - const bareShareMatch = trimmed.match(/share\.openwork\.software\/b\/[^\s"'<>]+/i); - if (bareShareMatch) return `https://${bareShareMatch[0]}`; - - return trimmed; -} - -function parseDebugDeepLinkInput(rawValue: string): - | { kind: "bundle"; link: SharedBundleDeepLink } - | { kind: "remote"; link: RemoteWorkspaceDefaults } - | { kind: "auth"; link: DenAuthDeepLink } - | null { - const normalized = normalizeDebugDeepLinkInput(rawValue); - if (!normalized) return null; - - const denAuthLink = parseDenAuthDeepLink(normalized); - if (denAuthLink) { - return { kind: "auth", link: denAuthLink }; - } - - const sharedBundleLink = parseSharedBundleDeepLink(normalized); - if (sharedBundleLink) { - return { kind: "bundle", link: sharedBundleLink }; - } - - const remoteConnectLink = parseRemoteConnectDeepLink(normalized); - if (remoteConnectLink) { - return { kind: "remote", link: remoteConnectLink }; - } - - const bundleMatch = normalized.match(/ow_bundle=([^&\s]+)/i); - if (bundleMatch?.[1]) { - try { - const bundleUrl = decodeURIComponent(bundleMatch[1]); - const intentMatch = normalized.match(/(?:ow_intent|intent)=([^&\s]+)/i); - const labelMatch = normalized.match(/ow_label=([^&\s]+)/i); - const sourceMatch = normalized.match(/(?:ow_source|source)=([^&\s]+)/i); - return { - kind: "bundle", - link: { - bundleUrl, - intent: normalizeSharedBundleImportIntent(intentMatch?.[1] ? decodeURIComponent(intentMatch[1]) : undefined), - label: labelMatch?.[1] ? decodeURIComponent(labelMatch[1]) : undefined, - source: sourceMatch?.[1] ? decodeURIComponent(sourceMatch[1]) : undefined, - }, - }; - } catch { - // ignore fallback parsing errors - } - } - - const shareIdMatch = normalized.match(/share\.openwork\.software\/b\/([^\s/?#"'<>]+)/i); - if (shareIdMatch?.[1]) { - return { - kind: "bundle", - link: { - bundleUrl: `https://share.openwork.software/b/${shareIdMatch[1]}`, - intent: "new_worker", - }, - }; - } - - return null; -} - -function stripRemoteConnectQuery(rawUrl: string): string | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - let changed = false; - for (const key of [ - "openworkHostUrl", - "openworkUrl", - "openworkToken", - "accessToken", - "workerId", - "workerName", - "autoConnect", - "bypassModal", - "bypassAddWorkerModal", - "source", - ]) { - if (url.searchParams.has(key)) { - url.searchParams.delete(key); - changed = true; - } - } - - if (!changed) { - return null; - } - - const search = url.searchParams.toString(); - return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; -} - export default function App() { + const { resetSessionDisplayPreferences } = useSessionDisplayPreferences(); const envOpenworkWorkspaceId = typeof import.meta.env?.VITE_OPENWORK_WORKSPACE_ID === "string" ? import.meta.env.VITE_OPENWORK_WORKSPACE_ID.trim() || null @@ -851,61 +170,39 @@ export default function App() { // ignore } }; - type ProviderAuthMethod = { - type: "oauth" | "api"; - label: string; - methodIndex?: number; - }; - type ProviderOAuthStartResult = { - methodIndex: number; - authorization: ProviderAuthAuthorization; - }; - const location = useLocation(); const navigate = useNavigate(); const [creatingSession, setCreatingSession] = createSignal(false); - const [sessionViewLockUntil, setSessionViewLockUntil] = createSignal(0); + const [sessionViewLockUntil] = createSignal(0); const currentView = createMemo(() => { const path = location.pathname.toLowerCase(); - if (path.startsWith("/onboarding")) return "onboarding"; if (path.startsWith("/session")) return "session"; - if (path.startsWith("/proto")) return "proto"; - return "dashboard"; + return "settings"; }); - const isProtoV1Ux = createMemo(() => - location.pathname.toLowerCase().startsWith("/proto-v1-ux") - ); - const [tab, setTabState] = createSignal("scheduled"); - const [settingsTab, setSettingsTab] = createSignal("general"); + const [settingsTab, setSettingsTabState] = createSignal("general"); + const [pendingInitialSessionSelection, setPendingInitialSessionSelection] = + createSignal(null); - const goToDashboard = (nextTab: DashboardTab, options?: { replace?: boolean }) => { - setTabState(nextTab); - navigate(`/dashboard/${nextTab}`, options); + const goToSettings = (nextTab: SettingsTab, options?: { replace?: boolean }) => { + setSettingsTabState(nextTab); + navigate(`/settings/${nextTab}`, options); }; - const setTab = (nextTab: DashboardTab) => { - if (currentView() === "dashboard") { - goToDashboard(nextTab); + const setSettingsTab = (nextTab: SettingsTab) => { + if (currentView() === "settings") { + goToSettings(nextTab); return; } - setTabState(nextTab); + setSettingsTabState(nextTab); }; const setView = (next: View, sessionId?: string) => { - if (next === "dashboard" && creatingSession()) { + if (next === "settings" && creatingSession()) { return; } - if (next === "dashboard" && Date.now() < sessionViewLockUntil()) { - return; - } - if (next === "proto") { - navigate("/proto/workspaces"); - return; - } - if (next === "onboarding") { - navigate("/onboarding"); + if (next === "settings" && Date.now() < sessionViewLockUntil()) { return; } if (next === "session") { @@ -916,7 +213,7 @@ export default function App() { navigate("/session"); return; } - goToDashboard(tab()); + goToSettings(settingsTab()); }; const goToSession = (sessionId: string, options?: { replace?: boolean }) => { @@ -946,77 +243,13 @@ export default function App() { const [baseUrl, setBaseUrl] = createSignal("http://127.0.0.1:4096"); const [clientDirectory, setClientDirectory] = createSignal(""); - const [openworkServerSettings, setOpenworkServerSettings] = createSignal({}); - const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false); - const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal(null); - const [openworkServerUrl, setOpenworkServerUrl] = createSignal(""); - const [openworkServerStatus, setOpenworkServerStatus] = createSignal("disconnected"); - const [openworkServerCapabilities, setOpenworkServerCapabilities] = createSignal(null); - const [openworkServerCheckedAt, setOpenworkServerCheckedAt] = createSignal(null); - const [openworkServerWorkspaceId, setOpenworkServerWorkspaceId] = createSignal(null); - const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal(null); - const [openworkServerDiagnostics, setOpenworkServerDiagnostics] = createSignal(null); - const [openworkReconnectBusy, setOpenworkReconnectBusy] = createSignal(false); - const [opencodeRouterInfoState, setOpenCodeRouterInfoState] = createSignal(null); - const [orchestratorStatusState, setOrchestratorStatusState] = createSignal(null); - const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal([]); - const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle"); - const [openworkAuditError, setOpenworkAuditError] = createSignal(null); - const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal(null); - const [activeWorkspaceServerConfig, setActiveWorkspaceServerConfig] = - createSignal(null); - - const openworkServerBaseUrl = createMemo(() => { - const pref = startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; - - if (pref === "local") return hostInfo?.baseUrl ?? ""; - if (pref === "server") return settingsUrl; - return hostInfo?.baseUrl ?? settingsUrl; - }); - - const openworkServerAuth = createMemo( - () => { - const pref = startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settingsToken = openworkServerSettings().token?.trim() ?? ""; - const clientToken = hostInfo?.clientToken?.trim() ?? ""; - const hostToken = hostInfo?.hostToken?.trim() ?? ""; - - if (pref === "local") { - return { token: clientToken || undefined, hostToken: hostToken || undefined }; - } - if (pref === "server") { - return { token: settingsToken || undefined, hostToken: undefined }; - } - if (hostInfo?.baseUrl) { - return { token: clientToken || undefined, hostToken: hostToken || undefined }; - } - return { token: settingsToken || undefined, hostToken: undefined }; - }, - undefined, - { - equals: (prev, next) => prev?.token === next.token && prev?.hostToken === next.hostToken, - }, - ); - - const openworkServerClient = createMemo(() => { - const baseUrl = openworkServerBaseUrl().trim(); - if (!baseUrl) return null; - const auth = openworkServerAuth(); - return createOpenworkServerClient({ baseUrl, token: auth.token, hostToken: auth.hostToken }); - }); - - const devtoolsOpenworkClient = createMemo(() => openworkServerClient()); - createEffect(() => { if (typeof window === "undefined") return; hydrateOpenworkServerSettingsFromEnv(); const stored = readOpenworkServerSettings(); const invite = readOpenworkConnectInviteFromSearch(window.location.search); - const bundleInvite = readOpenworkBundleInviteFromSearch(window.location.search); + const bundleInvite = parseBundleDeepLink(window.location.href); if (!invite) { setOpenworkServerSettings(stored); @@ -1037,18 +270,11 @@ export default function App() { } if (bundleInvite?.bundleUrl) { - setPendingSharedBundleInvite({ - bundleUrl: bundleInvite.bundleUrl, - intent: normalizeSharedBundleImportIntent(bundleInvite.intent), - source: bundleInvite.source, - orgId: bundleInvite.orgId, - label: bundleInvite.label, - }); - setSharedBundleNoticeShown(false); + bundlesStore.queueBundleLink(window.location.href); } if (invite?.autoConnect) { - setPendingRemoteConnectDeepLink({ + deepLinks.queueRemoteConnectDefaults({ openworkHostUrl: invite.url, openworkToken: invite.token ?? null, directory: null, @@ -1058,7 +284,7 @@ export default function App() { } const cleanedConnect = stripOpenworkConnectInviteFromUrl(window.location.href); - const cleaned = stripOpenworkBundleInviteFromUrl(cleanedConnect); + const cleaned = stripBundleQuery(cleanedConnect) ?? cleanedConnect; if (cleaned !== window.location.href) { window.history.replaceState(window.history.state ?? null, "", cleaned); } @@ -1118,163 +344,6 @@ export default function App() { onCleanup(() => window.removeEventListener("keydown", handleZoomShortcut, true)); }); - createEffect(() => { - const pref = startupPreference(); - const info = openworkServerHostInfo(); - const hostUrl = info?.connectUrl ?? info?.lanUrl ?? info?.mdnsUrl ?? info?.baseUrl ?? ""; - const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; - - if (pref === "local") { - setOpenworkServerUrl(hostUrl); - return; - } - if (pref === "server") { - setOpenworkServerUrl(settingsUrl); - return; - } - setOpenworkServerUrl(hostUrl || settingsUrl); - }); - - const checkOpenworkServer = async (url: string, token?: string, hostToken?: string) => { - const client = createOpenworkServerClient({ baseUrl: url, token, hostToken }); - try { - await client.health(); - } catch (error) { - if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; - } - - if (!token) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - - try { - const caps = await client.capabilities(); - return { status: "connected" as OpenworkServerStatus, capabilities: caps }; - } catch (error) { - if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; - } - }; - - createEffect(() => { - if (typeof window === "undefined") return; - if (!documentVisible()) return; - const url = openworkServerBaseUrl().trim(); - const auth = openworkServerAuth(); - const token = auth.token; - const hostToken = auth.hostToken; - - if (!url) { - setOpenworkServerStatus("disconnected"); - setOpenworkServerCapabilities(null); - setOpenworkServerCheckedAt(Date.now()); - return; - } - - let active = true; - let busy = false; - let timeoutId: number | undefined; - let delayMs = 10_000; - - const scheduleNext = () => { - if (!active) return; - timeoutId = window.setTimeout(run, delayMs); - }; - - const run = async () => { - if (busy) return; - busy = true; - try { - const result = await checkOpenworkServer(url, token, hostToken); - if (!active) return; - setOpenworkServerStatus(result.status); - setOpenworkServerCapabilities(result.capabilities); - delayMs = - result.status === "connected" || result.status === "limited" - ? 10_000 - : Math.min(delayMs * 2, 60_000); - } catch { - delayMs = Math.min(delayMs * 2, 60_000); - } finally { - if (!active) return; - setOpenworkServerCheckedAt(Date.now()); - busy = false; - scheduleNext(); - } - }; - - run(); - onCleanup(() => { - active = false; - if (timeoutId) window.clearTimeout(timeoutId); - }); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!documentVisible()) return; - let active = true; - - const run = async () => { - try { - const info = await openworkServerInfo(); - if (active) setOpenworkServerHostInfo(info); - } catch { - if (active) setOpenworkServerHostInfo(null); - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!documentVisible()) return; - if (!developerMode()) { - setOpenworkServerDiagnostics(null); - return; - } - - const client = openworkServerClient(); - if (!client || openworkServerStatus() === "disconnected") { - setOpenworkServerDiagnostics(null); - return; - } - - let active = true; - let busy = false; - - const run = async () => { - if (busy) return; - busy = true; - try { - const status = await client.status(); - if (active) setOpenworkServerDiagnostics(status); - } catch { - if (active) setOpenworkServerDiagnostics(null); - } finally { - busy = false; - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - createEffect(() => { if (!isTauriRuntime()) return; if (!developerMode()) return; @@ -1299,60 +368,6 @@ export default function App() { }); }); - createEffect(() => { - if (!isTauriRuntime()) return; - if (!developerMode()) { - setOpenCodeRouterInfoState(null); - return; - } - if (!documentVisible()) return; - - let active = true; - - const run = async () => { - try { - const info = await opencodeRouterInfo(); - if (active) setOpenCodeRouterInfoState(info); - } catch { - if (active) setOpenCodeRouterInfoState(null); - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!developerMode()) { - setOrchestratorStatusState(null); - return; - } - if (!documentVisible()) return; - - let active = true; - - const run = async () => { - try { - const status = await orchestratorStatus(); - if (active) setOrchestratorStatusState(status); - } catch { - if (active) setOrchestratorStatusState(null); - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - const [client, setClient] = createSignal(null); const [connectedVersion, setConnectedVersion] = createSignal( null @@ -1365,8 +380,7 @@ export default function App() { const [error, setError] = createSignal(null); const [opencodeConnectStatus, setOpencodeConnectStatus] = createSignal(null); const [booting, setBooting] = createSignal(true); - const mountTime = Date.now(); - const [lastKnownConfigSnapshot, setLastKnownConfigSnapshot] = createSignal(""); + const [, setLastKnownConfigSnapshot] = createSignal(""); const [developerMode, setDeveloperMode] = createSignal(false); const [documentVisible, setDocumentVisible] = createSignal(true); @@ -1375,12 +389,11 @@ export default function App() { clearPerfLogs(); }); - const [selectedSessionId, setSelectedSessionId] = createSignal( - null - ); + const [selectedSessionId, setSelectedSessionId] = createSignal(null); + const [prompt, setPrompt] = createSignal(""); const [settingsReturnTarget, setSettingsReturnTarget] = createSignal({ - view: "dashboard", - tab: "scheduled", + view: "settings", + tab: "general", sessionId: null, }); const SESSION_BY_WORKSPACE_KEY = "openwork.workspace-last-session.v1"; @@ -1404,32 +417,51 @@ export default function App() { // ignore } }; - const [sessionModelOverrideById, setSessionModelOverrideById] = createSignal< - Record - >({}); - const [sessionModelById, setSessionModelById] = createSignal< - Record - >({}); - const [pendingSessionModel, setPendingSessionModel] = createSignal(null); - const [sessionModelOverridesReady, setSessionModelOverridesReady] = createSignal(false); - const [workspaceDefaultModelReady, setWorkspaceDefaultModelReady] = createSignal(false); - const [legacyDefaultModel, setLegacyDefaultModel] = createSignal(DEFAULT_MODEL); - const [defaultModelExplicit, setDefaultModelExplicit] = createSignal(false); - type PromptFocusReturnTarget = "none" | "composer"; - const [sessionAgentById, setSessionAgentById] = createSignal>({}); - const [providerAuthModalOpen, setProviderAuthModalOpen] = createSignal(false); - const [providerAuthBusy, setProviderAuthBusy] = createSignal(false); - const [providerAuthError, setProviderAuthError] = createSignal(null); - const [providerAuthMethods, setProviderAuthMethods] = createSignal>({}); - const [providerAuthPreferredProviderId, setProviderAuthPreferredProviderId] = createSignal(null); - const [providerAuthReturnFocusTarget, setProviderAuthReturnFocusTarget] = - createSignal("none"); + const globalSync = useGlobalSync(); + const providers = createMemo(() => globalSync.data.provider.all ?? []); + const providerDefaults = createMemo(() => globalSync.data.provider.default ?? {}); + const providerConnectedIds = createMemo(() => globalSync.data.provider.connected ?? []); + const setProviders = (value: ProviderListItem[]) => { + globalSync.set("provider", "all", value); + }; + const setProviderDefaults = (value: Record) => { + globalSync.set("provider", "default", value); + }; + const setProviderConnectedIds = (value: string[]) => { + globalSync.set("provider", "connected", value); + }; + + let workspaceStore!: ReturnType; + let sessionStore!: ReturnType; + let openworkServerStore!: ReturnType; + + const modelConfig = createModelConfigStore({ + client, + selectedSessionId, + messages: () => sessionStore?.messages?.() ?? [], + providers, + providerDefaults, + providerConnectedIds, + selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", + selectedWorkspaceDisplay: () => + workspaceStore?.selectedWorkspaceDisplay?.() ?? ({ workspaceType: "local" } as WorkspaceDisplay), + selectedWorkspacePath: () => workspaceStore?.selectedWorkspacePath?.() ?? "", + openworkServerClient: () => openworkServerStore?.openworkServerClient?.() ?? null, + openworkServerStatus: () => openworkServerStore?.openworkServerStatus?.() ?? "disconnected", + openworkServerCapabilities: () => openworkServerStore?.openworkServerCapabilities?.() ?? null, + runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, + focusSessionPromptSoon: () => focusSessionPromptSoon(), + setError, + setLastKnownConfigSnapshot, + markOpencodeConfigReloadRequired: () => + markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }), + }); createEffect(() => { const view = currentView(); - const currentTab = tab(); - if (view === "dashboard" && currentTab === "settings") return; + const currentTab = settingsTab(); + if (view === "settings") return; setSettingsReturnTarget({ view, tab: currentTab, @@ -1447,25 +479,36 @@ export default function App() { navigate("/session"); return; } - if (target.view === "onboarding") { - navigate("/onboarding"); - return; - } - if (target.view === "proto") { - navigate("/proto/workspaces"); - return; - } - goToDashboard(target.tab); + goToSettings(target.tab); }; const toggleSettingsView = (nextTab: SettingsTab = "general") => { - const settingsOpen = currentView() === "dashboard" && tab() === "settings"; + const settingsOpen = currentView() === "settings"; if (settingsOpen) { restoreSettingsReturnTarget(); return; } setSettingsTab(nextTab); - goToDashboard("settings"); + goToSettings(nextTab); + }; + + const mapLegacySurfaceToSettingsTab = (surface: string): SettingsTab => { + switch (surface) { + case "scheduled": + return "automations"; + case "skills": + return "skills"; + case "plugins": + case "mcp": + return "extensions"; + case "identities": + return "messaging"; + case "config": + return "advanced"; + case "settings": + default: + return "general"; + } }; let markReloadRequiredHandler: ((reason: ReloadReason, trigger?: ReloadTrigger) => void) | undefined; @@ -1473,24 +516,14 @@ export default function App() { markReloadRequiredHandler?.(reason, trigger); }; - const sessionStore = createSessionStore({ + sessionStore = createSessionStore({ client, - activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot().trim(), + selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot().trim(), selectedSessionId, setSelectedSessionId, - sessionModelState: () => ({ - overrides: sessionModelOverrideById(), - resolved: sessionModelById(), - }), - setSessionModelState: (updater) => { - const next = updater({ - overrides: sessionModelOverrideById(), - resolved: sessionModelById(), - }); - setSessionModelOverrideById(next.overrides); - setSessionModelById(next.resolved); - return next; - }, + setPrompt, + sessionModelState: modelConfig.sessionModelState, + setSessionModelState: modelConfig.setSessionModelState, lastUserModelFromMessages, developerMode, setError, @@ -1508,14 +541,16 @@ export default function App() { loadedScopeRoot: loadedSessionScopeRoot, sessionById, sessionStatusById, + messageIdFromInfo, selectedSession, selectedSessionStatus, + selectedSessionCompactionState, messages, + visibleMessages, messagesBySessionId, todos, pendingPermissions, permissionReplyBusy, - pendingQuestions, activeQuestion, questionReplyBusy, events, @@ -1523,12 +558,14 @@ export default function App() { loadSessions, ensureSessionLoaded, refreshPendingPermissions, - refreshPendingQuestions, selectSession, loadEarlierMessages, renameSession, respondPermission, respondQuestion, + restorePromptFromUserMessage, + upsertLocalSession, + setBlueprintSeedMessagesBySessionId, setSessions, setSessionStatusById, setMessages, @@ -1544,24 +581,19 @@ export default function App() { deriveArtifacts(messages(), { maxMessages: ARTIFACT_SCAN_MESSAGE_WINDOW }), ); const workingFiles = createMemo(() => deriveWorkingFiles(artifacts())); - const activeSessionId = createMemo(() => selectedSessionId()); - const activeSessions = createMemo(() => sessions()); + const activeSessionId = createMemo(() => { + const path = location.pathname.trim(); + const [, sessionSegment, idSegment] = path.split("/"); + if (sessionSegment?.toLowerCase() === "session") { + const routeId = (idSegment ?? "").trim(); + if (routeId) return routeId; + } + return selectedSessionId(); + }); const activeSessionStatusById = createMemo(() => sessionStatusById()); - const activeMessages = createMemo(() => messages()); const activeTodos = createMemo(() => todos()); const activeWorkingFiles = createMemo(() => workingFiles()); - const sessionActivity = (session: Session) => - session.time?.updated ?? session.time?.created ?? 0; - const sortSessionsByActivity = (list: Session[]) => - list - .slice() - .sort((a, b) => { - const delta = sessionActivity(b) - sessionActivity(a); - if (delta !== 0) return delta; - return a.id.localeCompare(b.id); - }); - const [sessionsLoaded, setSessionsLoaded] = createSignal(false); const loadSessionsWithReady = async (scopeRoot?: string) => { await loadSessions(scopeRoot); @@ -1574,1019 +606,16 @@ export default function App() { } }); - const [prompt, setPrompt] = createSignal(""); - const [lastPromptSent, setLastPromptSent] = createSignal(""); - - type PartInput = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput; - - const attachmentToFilePart = async (attachment: ComposerAttachment): Promise => ({ - type: "file", - url: await fileToDataUrl(attachment.file), - filename: attachment.name, - mime: attachment.mimeType, - }); - - const buildPromptParts = async (draft: ComposerDraft): Promise => { - const parts: PartInput[] = []; - const text = draft.resolvedText ?? draft.text; - parts.push({ type: "text", text } as TextPartInput); - - const root = workspaceProjectDir().trim(); - const toAbsolutePath = (path: string) => { - const trimmed = path.trim(); - if (!trimmed) return ""; - if (trimmed.startsWith("/")) return trimmed; - // Windows absolute path, e.g. C:\foo\bar - if (/^[a-zA-Z]:\\/.test(trimmed)) return trimmed; - // Without a workspace root, we cannot safely resolve relative paths. - // Returning "" avoids emitting invalid file:// URLs. - if (!root) return ""; - return (root + "/" + trimmed).replace("//", "/"); - }; - const filenameFromPath = (path: string) => { - const normalized = path.replace(/\\/g, "/"); - const segments = normalized.split("/").filter(Boolean); - return segments[segments.length - 1] ?? "file"; - }; - - for (const part of draft.parts) { - if (part.type === "agent") { - parts.push({ type: "agent", name: part.name } as AgentPartInput); - continue; - } - if (part.type === "file") { - const absolute = toAbsolutePath(part.path); - if (!absolute) continue; - parts.push({ - type: "file", - mime: "text/plain", - url: `file://${absolute}`, - filename: filenameFromPath(part.path), - } as FilePartInput); - } + const ensureSelectedWorkspaceRuntime = async () => { + const workspaceId = workspaceStore.selectedWorkspaceId().trim(); + if (!workspaceId) return false; + const ready = await workspaceStore.switchWorkspace(workspaceId); + if (ready) { + await refreshSidebarWorkspaceSessions(workspaceId).catch(() => undefined); } - - parts.push(...(await Promise.all(draft.attachments.map(attachmentToFilePart)))); - - return parts; + return ready; }; - const buildCommandFileParts = async (draft: ComposerDraft): Promise => { - const parts: FilePartInput[] = []; - const root = workspaceProjectDir().trim(); - - const toAbsolutePath = (path: string) => { - const trimmed = path.trim(); - if (!trimmed) return ""; - if (trimmed.startsWith("/")) return trimmed; - if (/^[a-zA-Z]:\\/.test(trimmed)) return trimmed; - if (!root) return ""; - return (root + "/" + trimmed).replace("//", "/"); - }; - - const filenameFromPath = (path: string) => { - const normalized = path.replace(/\\/g, "/"); - const segments = normalized.split("/").filter(Boolean); - return segments[segments.length - 1] ?? "file"; - }; - - for (const part of draft.parts) { - if (part.type !== "file") continue; - const absolute = toAbsolutePath(part.path); - if (!absolute) continue; - parts.push({ - type: "file", - mime: "text/plain", - url: `file://${absolute}`, - filename: filenameFromPath(part.path), - } as FilePartInput); - } - - parts.push(...(await Promise.all(draft.attachments.map(attachmentToFilePart)))); - - return parts; - }; - - const assertNoClientError = (result: unknown) => { - const maybe = result as { error?: unknown } | null | undefined; - if (!maybe || maybe.error === undefined) return; - throw new Error(describeProviderError(maybe.error, "Request failed")); - }; - - const describeProviderError = (error: unknown, fallback: string) => { - const readString = (value: unknown, max = 700) => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.length <= max) return trimmed; - return `${trimmed.slice(0, Math.max(0, max - 3))}...`; - }; - - const records: Record[] = []; - const root = error && typeof error === "object" ? (error as Record) : null; - if (root) { - records.push(root); - if (root.data && typeof root.data === "object") records.push(root.data as Record); - if (root.cause && typeof root.cause === "object") { - const cause = root.cause as Record; - records.push(cause); - if (cause.data && typeof cause.data === "object") records.push(cause.data as Record); - } - } - - const firstString = (keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = readString(record[key]); - if (value) return value; - } - } - return null; - }; - - const firstNumber = (keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value)) return value; - } - } - return null; - }; - - const status = firstNumber(["statusCode", "status"]); - const provider = firstString(["providerID", "providerId", "provider"]); - const code = firstString(["code", "errorCode"]); - const response = firstString(["responseBody", "body", "response"]); - const raw = - (error instanceof Error ? readString(error.message) : null) || - firstString(["message", "detail", "reason", "error"]) || - (typeof error === "string" ? readString(error) : null); - - const generic = raw && /^unknown\s+error$/i.test(raw); - const heading = (() => { - if (status === 401 || status === 403) return "Authentication failed"; - if (status === 429) return "Rate limit exceeded"; - if (provider) return `Provider error (${provider})`; - return fallback; - })(); - - const lines = [heading]; - if (raw && !generic && raw !== heading) lines.push(raw); - if (status && !heading.includes(String(status))) lines.push(`Status: ${status}`); - if (provider && !heading.includes(provider)) lines.push(`Provider: ${provider}`); - if (code) lines.push(`Code: ${code}`); - if (response) lines.push(`Response: ${response}`); - if (lines.length > 1) return lines.join("\n"); - - if (raw && !generic) return raw; - if (error && typeof error === "object") { - const serialized = safeStringify(error); - if (serialized && serialized !== "{}") return serialized; - } - return fallback; - }; - - async function sendPrompt(draft?: ComposerDraft) { - const hasExplicitDraft = Boolean(draft); - const fallbackText = prompt().trim(); - const resolvedDraft: ComposerDraft = draft ?? { - mode: "prompt", - parts: fallbackText ? [{ type: "text", text: fallbackText } as ComposerPart] : [], - attachments: [] as ComposerAttachment[], - text: fallbackText, - }; - const content = (resolvedDraft.resolvedText ?? resolvedDraft.text).trim(); - if (!content && !resolvedDraft.attachments.length) return; - - const c = client(); - if (!c) return; - - const compactShortcut = /^\/compact(?:\s+.*)?$/i.test(content); - const compactCommand = resolvedDraft.command?.name === "compact" || compactShortcut; - const commandName = compactCommand ? "compact" : (resolvedDraft.command?.name ?? null); - if (compactCommand && !selectedSessionId()) { - setError("Select a session with messages before running /compact."); - return; - } - - let sessionID = selectedSessionId(); - if (!sessionID) { - await createSessionAndOpen(); - sessionID = selectedSessionId(); - } - if (!sessionID) return; - - setBusy(true); - setBusyLabel("status.running"); - setBusyStartedAt(Date.now()); - setError(null); - - const perfEnabled = developerMode(); - const startedAt = perfNow(); - const visible = messages(); - const visibleParts = visible.reduce((total, message) => total + message.parts.length, 0); - recordPerfLog(perfEnabled, "session.prompt", "start", { - sessionID, - mode: resolvedDraft.mode, - command: commandName, - charCount: content.length, - attachmentCount: resolvedDraft.attachments.length, - messageCount: visible.length, - partCount: visibleParts, - }); - - try { - if (!compactCommand) { - setLastPromptSent(content); - } - if (!hasExplicitDraft) { - setPrompt(""); - } - - const model = selectedSessionModel(); - const agent = selectedSessionAgent(); - const parts = await buildPromptParts(resolvedDraft); - const selectedVariant = sanitizeModelVariantForRef(model, getVariantFor(model)) ?? undefined; - const reasoningEffort = resolveCodexReasoningEffort(model.modelID, selectedVariant ?? null); - const requestVariant = reasoningEffort ? undefined : selectedVariant; - const promptOverrides = reasoningEffort - ? ({ reasoning_effort: reasoningEffort } as const) - : undefined; - - if (resolvedDraft.mode === "shell") { - await shellInSession(c, sessionID, content); - } else if (resolvedDraft.command || compactCommand) { - if (compactCommand) { - await compactCurrentSession(sessionID); - finishPerf(perfEnabled, "session.prompt", "done", startedAt, { - sessionID, - mode: resolvedDraft.mode, - command: commandName, - }); - return; - } - - const command = resolvedDraft.command; - if (!command) { - throw new Error("Command was not resolved."); - } - - // Slash command: route through session.command() API - const modelString = `${model.providerID}/${model.modelID}`; - const files = await buildCommandFileParts(resolvedDraft); - - // session.command() expects `model` as a provider/model string and only supports file parts. - unwrap( - await c.session.command({ - sessionID, - command: command.name, - arguments: command.arguments, - agent: agent ?? undefined, - model: modelString, - variant: requestVariant, - ...(promptOverrides ?? {}), - parts: files.length ? files : undefined, - }), - ); - - } else { - const result = await c.session.promptAsync({ - sessionID, - model, - agent: agent ?? undefined, - variant: requestVariant, - ...(promptOverrides ?? {}), - parts, - }); - assertNoClientError(result); - - setSessionModelById((current) => ({ - ...current, - [sessionID]: model, - })); - - setSessionModelOverrideById((current) => { - if (!current[sessionID]) return current; - const copy = { ...current }; - delete copy[sessionID]; - return copy; - }); - } - - finishPerf(perfEnabled, "session.prompt", "done", startedAt, { - sessionID, - mode: resolvedDraft.mode, - command: commandName, - }); - } catch (e) { - finishPerf(perfEnabled, "session.prompt", "error", startedAt, { - sessionID, - mode: resolvedDraft.mode, - command: commandName, - error: e instanceof Error ? e.message : safeStringify(e), - }); - const message = e instanceof Error ? e.message : safeStringify(e); - sessionStore.appendSessionErrorTurn(sessionID, addOpencodeCacheHint(message)); - } finally { - setBusy(false); - setBusyLabel(null); - setBusyStartedAt(null); - } - } - - async function abortSession(sessionID?: string) { - const c = client(); - if (!c) return; - const id = (sessionID ?? selectedSessionId() ?? "").trim(); - if (!id) return; - // OpenCode exposes session.abort which interrupts the active prompt/run. - // We intentionally don't mutate global busy state here; the SessionView - // provides local UX (button disabled + toast) for cancellation. - await abortSessionTyped(c, id); - } - - function retryLastPrompt() { - const text = lastPromptSent().trim(); - if (!text) return; - void sendPrompt({ - mode: "prompt", - text, - parts: [{ type: "text", text }], - attachments: [], - }); - } - - async function compactCurrentSession(sessionIdOverride?: string) { - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const sessionID = (sessionIdOverride ?? selectedSessionId() ?? "").trim(); - if (!sessionID) { - throw new Error("Select a session before compacting."); - } - - const visible = messages(); - if (!visible.length) { - throw new Error("Nothing to compact yet."); - } - - const model = selectedSessionModel(); - const startedAt = perfNow(); - const modelLabel = `${model.providerID}/${model.modelID}`; - recordPerfLog(developerMode(), "session.compact", "start", { - sessionID, - messageCount: visible.length, - model: modelLabel, - variant: sanitizeModelVariantForRef(model, getVariantFor(model)) ?? null, - }); - - try { - await compactSessionTyped(c, sessionID, model, { - directory: workspaceProjectDir().trim() || undefined, - }); - finishPerf(developerMode(), "session.compact", "done", startedAt, { - sessionID, - messageCount: visible.length, - model: modelLabel, - }); - } catch (error) { - finishPerf(developerMode(), "session.compact", "error", startedAt, { - sessionID, - messageCount: visible.length, - model: modelLabel, - error: error instanceof Error ? error.message : safeStringify(error), - }); - throw error; - } - } - - const triggerAutoCompaction = async (sessionID: string) => { - if (!autoCompactContext()) return; - if (autoCompactingSessionId() === sessionID) return; - - setAutoCompactingSessionId(sessionID); - try { - await compactCurrentSession(sessionID); - } catch { - // ignore auto-compaction failures; manual compact remains available - } finally { - setAutoCompactingSessionId((current) => (current === sessionID ? null : current)); - } - }; - - const [lastSessionStatus, setLastSessionStatus] = createSignal(null); - createEffect(() => { - const sessionID = selectedSessionId(); - const status = sessionID ? sessionStatusById()[sessionID] ?? null : null; - const previous = lastSessionStatus(); - setLastSessionStatus(status); - - if (!sessionID) return; - if (!autoCompactContext()) return; - if (status !== "idle") return; - if (!previous || previous === "idle") return; - void triggerAutoCompaction(sessionID); - }); - - const messageIdFromInfo = (message: MessageWithParts) => { - const id = (message.info as { id?: string | number }).id; - if (typeof id === "string") return id; - if (typeof id === "number") return String(id); - return ""; - }; - - const createSyntheticSessionErrorMessage = ( - sessionID: string, - errorTurn: SessionErrorTurn, - ): MessageWithParts => { - const info: PlaceholderAssistantMessage = { - id: errorTurn.id, - sessionID, - role: "assistant", - time: { created: errorTurn.time, completed: errorTurn.time }, - parentID: errorTurn.afterMessageID ?? "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }; - - return { - info, - parts: [ - { - id: `${errorTurn.id}:text`, - sessionID, - messageID: errorTurn.id, - type: "text", - text: errorTurn.text, - } as Part, - ], - }; - }; - - const insertSyntheticSessionErrors = ( - list: MessageWithParts[], - sessionID: string | null, - errorTurns: SessionErrorTurn[], - ) => { - if (!sessionID || errorTurns.length === 0) return list; - - const next = list.slice(); - errorTurns.forEach((errorTurn) => { - if (next.some((message) => messageIdFromInfo(message) === errorTurn.id)) return; - const syntheticMessage = createSyntheticSessionErrorMessage(sessionID, errorTurn); - const anchorIndex = errorTurn.afterMessageID - ? next.findIndex((message) => messageIdFromInfo(message) === errorTurn.afterMessageID) - : -1; - - if (anchorIndex === -1) { - next.push(syntheticMessage); - return; - } - - next.splice(anchorIndex + 1, 0, syntheticMessage); - }); - - return next; - }; - - const upsertLocalSession = (next: Session | null | undefined) => { - const id = (next as { id?: string } | null)?.id ?? ""; - if (!id) return; - - const current = sessions(); - const index = current.findIndex((session) => session.id === id); - if (index === -1) { - setSessions([...current, next as Session]); - return; - } - const copy = current.slice(); - copy[index] = next as Session; - setSessions(copy); - }; - - // OpenCode keeps reverted messages in the log and uses `session.revert.messageID` - // as the visibility boundary. OpenWork mirrors that behavior by filtering the - // displayed transcript. - const visibleMessages = createMemo(() => { - const sessionID = selectedSessionId(); - const errorTurns = sessionStore.selectedSessionErrorTurns(); - const list = messages().filter((message) => { - const id = messageIdFromInfo(message); - return !id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX); - }); - const revert = selectedSession()?.revert?.messageID ?? null; - const visible = !revert ? list : list.filter((message) => { - const id = messageIdFromInfo(message); - return Boolean(id) && id < revert; - }); - return insertSyntheticSessionErrors(visible, sessionID, errorTurns); - }); - - const restorePromptFromUserMessage = (message: MessageWithParts) => { - const text = message.parts - .filter(isVisibleTextPart) - .map((part) => String((part as { text?: string }).text ?? "")) - .join(""); - setPrompt(text); - }; - - async function undoLastUserMessage() { - const c = client(); - const sessionID = (selectedSessionId() ?? "").trim(); - if (!c || !sessionID) return; - - // Revert is rejected while the session is busy. We *usually* have an accurate - // session status via SSE, but to be resilient to transient desync we attempt - // an abort even when we think we're idle. - await abortSessionSafe(c, sessionID); - - const revertMessageID = selectedSession()?.revert?.messageID ?? null; - const users = messages().filter((message) => { - const role = (message.info as { role?: string }).role; - return role === "user"; - }); - - let target: MessageWithParts | null = null; - for (let idx = users.length - 1; idx >= 0; idx -= 1) { - const candidate = users[idx]; - const id = messageIdFromInfo(candidate); - if (!id) continue; - if (!revertMessageID || id < revertMessageID) { - target = candidate; - break; - } - } - - if (!target) return; - const messageID = messageIdFromInfo(target); - if (!messageID) return; - - const next = await revertSession(c, sessionID, messageID); - upsertLocalSession(next); - restorePromptFromUserMessage(target); - } - - async function redoLastUserMessage() { - const c = client(); - const sessionID = (selectedSessionId() ?? "").trim(); - if (!c || !sessionID) return; - - await abortSessionSafe(c, sessionID); - - const revertMessageID = selectedSession()?.revert?.messageID ?? null; - if (!revertMessageID) return; - - const users = messages().filter((message) => { - const role = (message.info as { role?: string }).role; - return role === "user"; - }); - - const next = users.find((message) => { - const id = messageIdFromInfo(message); - return Boolean(id) && id > revertMessageID; - }); - - if (!next) { - const session = await unrevertSession(c, sessionID); - upsertLocalSession(session); - setPrompt(""); - return; - } - - const messageID = messageIdFromInfo(next); - if (!messageID) return; - - const nextSession = await revertSession(c, sessionID, messageID); - upsertLocalSession(nextSession); - - let prior: MessageWithParts | null = null; - for (let idx = users.length - 1; idx >= 0; idx -= 1) { - const candidate = users[idx]; - const id = messageIdFromInfo(candidate); - if (id && id < messageID) { - prior = candidate; - break; - } - } - - if (prior) { - restorePromptFromUserMessage(prior); - return; - } - - setPrompt(""); - } - - async function renameSessionTitle(sessionID: string, title: string) { - const trimmed = title.trim(); - if (!trimmed) { - throw new Error("Session name is required"); - } - - await renameSession(sessionID, trimmed); - await refreshSidebarWorkspaceSessions(workspaceStore.activeWorkspaceId()).catch(() => undefined); - } - - async function deleteSessionById(sessionID: string) { - const trimmed = sessionID.trim(); - if (!trimmed) return; - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const root = workspaceStore.activeWorkspaceRoot().trim(); - const directory = toSessionTransportDirectory(root); - const params = directory ? { sessionID: trimmed, directory } : { sessionID: trimmed }; - unwrap(await c.session.delete(params)); - - // Remove the deleted session from the store and sidebar locally. - // SSE will handle any further sync — calling loadSessions/refreshSidebarWorkspaceSessions - // here races with SSE and can wipe unrelated sessions from the store. - setSessions(sessions().filter((s) => s.id !== trimmed)); - const activeWsId = workspaceStore.activeWorkspaceId(); - setSidebarSessionsByWorkspaceId((prev) => ({ - ...prev, - [activeWsId]: (prev[activeWsId] ?? []).filter((s) => s.id !== trimmed), - })); - - // If we're currently routed to the deleted session, navigate away immediately. - // (Otherwise the route effect can try to re-select a session that no longer exists.) - try { - const path = location.pathname.toLowerCase(); - if (path === `/session/${trimmed.toLowerCase()}`) { - navigate("/session", { replace: true }); - } - } catch { - // ignore - } - - // If the deleted session was selected, clear selection so routing can fall back cleanly. - if (selectedSessionId() === trimmed) { - setSelectedSessionId(null); - const activeWorkspace = workspaceStore.activeWorkspaceId().trim(); - if (activeWorkspace) { - const map = readSessionByWorkspace(); - if (map[activeWorkspace] === trimmed) { - const next = { ...map }; - delete next[activeWorkspace]; - writeSessionByWorkspace(next); - } - } - } - - const nextStatus = { ...sessionStatusById() }; - if (nextStatus[trimmed]) { - delete nextStatus[trimmed]; - setSessionStatusById(nextStatus); - } - } - - - async function listAgents(): Promise { - const c = client(); - if (!c) return []; - const list = unwrap(await c.app.agents()); - return list.filter((agent) => !agent.hidden && agent.mode !== "subagent"); - } - - const BUILTIN_COMPACT_COMMAND = { - id: "builtin:compact", - name: "compact", - description: "Summarize this session to reduce context size.", - source: "command" as const, - }; - - async function listCommands(): Promise<{ id: string; name: string; description?: string; source?: "command" | "mcp" | "skill" }[]> { - const c = client(); - if (!c) return []; - const list = await listCommandsTyped(c, workspaceStore.activeWorkspaceRoot().trim() || undefined); - if (list.some((entry) => entry.name === "compact")) { - return list; - } - return [BUILTIN_COMPACT_COMMAND, ...list]; - } - - function setSessionAgent(sessionID: string, agent: string | null) { - const trimmed = agent?.trim() ?? ""; - setSessionAgentById((current) => { - const next = { ...current }; - if (!trimmed) { - delete next[sessionID]; - return next; - } - next[sessionID] = trimmed; - return next; - }); - } - - const buildProviderAuthMethods = ( - methods: Record, - availableProviders: ProviderListItem[], - workerType: "local" | "remote", - ) => { - const merged = Object.fromEntries( - Object.entries(methods ?? {}).map(([id, providerMethods]) => [ - id, - (providerMethods ?? []).map((method, methodIndex) => ({ - ...method, - methodIndex, - })), - ]), - ) as Record; - for (const provider of availableProviders ?? []) { - const id = provider.id?.trim(); - if (!id || id === "opencode") continue; - if (!Array.isArray(provider.env) || provider.env.length === 0) continue; - const existing = merged[id] ?? []; - if (existing.some((method) => method.type === "api")) continue; - merged[id] = [...existing, { type: "api", label: "API key" }]; - } - for (const [id, providerMethods] of Object.entries(merged)) { - const provider = availableProviders.find((item) => item.id === id); - const normalizedId = id.trim().toLowerCase(); - const normalizedName = provider?.name?.trim().toLowerCase() ?? ""; - const isOpenAiProvider = normalizedId === "openai" || normalizedName === "openai"; - if (!isOpenAiProvider) continue; - merged[id] = providerMethods.filter((method) => { - if (method.type !== "oauth") return true; - const label = method.label.toLowerCase(); - const isHeadless = label.includes("headless") || label.includes("device"); - return workerType === "remote" ? isHeadless : !isHeadless; - }); - } - return merged; - }; - - const loadProviderAuthMethods = async (workerType: "local" | "remote") => { - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - const methods = unwrap(await c.provider.auth()); - return buildProviderAuthMethods( - methods as Record, - providers(), - workerType, - ); - }; - - async function startProviderAuth( - providerId?: string, - methodIndex?: number, - ): Promise { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - try { - const cachedMethods = providerAuthMethods(); - const workerType = activeWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; - const authMethods = Object.keys(cachedMethods).length - ? cachedMethods - : await loadProviderAuthMethods(workerType); - const providerIds = Object.keys(authMethods).sort(); - if (!providerIds.length) { - throw new Error("No providers available"); - } - - const resolved = providerId?.trim() ?? ""; - if (!resolved) { - throw new Error("Provider ID is required"); - } - - const methods = authMethods[resolved]; - if (!methods || !methods.length) { - throw new Error(`Unknown provider: ${resolved}`); - } - - const oauthIndex = - methodIndex !== undefined - ? methodIndex - : methods.find((method) => method.type === "oauth")?.methodIndex ?? -1; - if (oauthIndex === -1) { - throw new Error(`No OAuth flow available for ${resolved}. Use an API key instead.`); - } - - const selectedMethod = methods.find((method) => method.methodIndex === oauthIndex); - if (!selectedMethod || selectedMethod.type !== "oauth") { - throw new Error(`Selected auth method is not an OAuth flow for ${resolved}.`); - } - - const auth = unwrap(await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex })); - return { - methodIndex: oauthIndex, - authorization: auth, - }; - } catch (error) { - const message = describeProviderError(error, "Failed to connect provider"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - - async function refreshProviders(options?: { dispose?: boolean }) { - const c = client(); - if (!c) return null; - - if (options?.dispose) { - try { - unwrap(await c.instance.dispose()); - } catch { - // ignore dispose failures and try reading current state anyway - } - - try { - await waitForHealthy(client() ?? c, { timeoutMs: 8_000, pollMs: 250 }); - } catch { - // ignore health wait failures and still attempt provider reads - } - } - - const activeClient = client() ?? c; - try { - const updated = unwrap(await activeClient.provider.list()); - globalSync.set("provider", updated); - return updated; - } catch { - try { - const fallback = unwrap(await activeClient.config.providers()); - const mapped = mapConfigProvidersToList(fallback.providers); - const previousConnected = providerConnectedIds(); - const next = { - all: mapped, - connected: previousConnected.filter((id) => mapped.some((provider) => provider.id === id)), - default: fallback.default, - }; - globalSync.set("provider", next); - return next; - } catch { - return null; - } - } - } - - async function completeProviderAuthOAuth(providerId: string, methodIndex: number, code?: string) { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const resolved = providerId?.trim(); - if (!resolved) { - throw new Error("Provider ID is required"); - } - - if (!Number.isInteger(methodIndex) || methodIndex < 0) { - throw new Error("OAuth method is required"); - } - - const waitForProviderConnection = async (timeoutMs = 15_000, pollMs = 2_000) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - try { - const updated = await refreshProviders({ dispose: true }); - if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return true; - } - } catch { - // ignore and retry - } - await new Promise((resolve) => setTimeout(resolve, pollMs)); - } - return false; - }; - - const isPendingOauthError = (error: unknown) => { - const text = error instanceof Error ? error.message : String(error ?? ""); - return /request timed out/i.test(text) || /ProviderAuthOauthMissing/i.test(text); - }; - - try { - const trimmedCode = code?.trim(); - const result = await c.provider.oauth.callback({ - providerID: resolved, - method: methodIndex, - code: trimmedCode || undefined, - }); - assertNoClientError(result); - const updated = await refreshProviders({ dispose: true }); - const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); - if (connectedNow) { - return { connected: true, message: `Connected ${resolved}` }; - } - const connected = await waitForProviderConnection(); - if (connected) { - return { connected: true, message: `Connected ${resolved}` }; - } - return { connected: false, pending: true }; - } catch (error) { - if (isPendingOauthError(error)) { - const updated = await refreshProviders({ dispose: true }); - if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return { connected: true, message: `Connected ${resolved}` }; - } - const connected = await waitForProviderConnection(); - if (connected) { - return { connected: true, message: `Connected ${resolved}` }; - } - return { connected: false, pending: true }; - } - const message = describeProviderError(error, "Failed to complete OAuth"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - - async function submitProviderApiKey(providerId: string, apiKey: string) { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const trimmed = apiKey.trim(); - if (!trimmed) { - throw new Error("API key is required"); - } - - try { - await c.auth.set({ - providerID: providerId, - auth: { type: "api", key: trimmed }, - }); - await refreshProviders({ dispose: true }); - return `Connected ${providerId}`; - } catch (error) { - const message = describeProviderError(error, "Failed to save API key"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - - async function disconnectProvider(providerId: string) { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const resolved = providerId.trim(); - if (!resolved) { - throw new Error("Provider ID is required"); - } - - const removeProviderAuth = async () => { - const authClient = c.auth as unknown as { - remove?: (options: { providerID: string }) => Promise; - set?: (options: { providerID: string; auth: unknown }) => Promise; - }; - if (typeof authClient.remove === "function") { - const result = await authClient.remove({ providerID: resolved }); - assertNoClientError(result); - return; - } - - const rawClient = (c as unknown as { client?: { delete?: (options: { url: string }) => Promise } }) - .client; - if (rawClient?.delete) { - await rawClient.delete({ url: `/auth/${encodeURIComponent(resolved)}` }); - return; - } - - if (typeof authClient.set === "function") { - const result = await authClient.set({ providerID: resolved, auth: null }); - assertNoClientError(result); - return; - } - - throw new Error("Provider auth removal is not supported by this client."); - }; - - try { - await removeProviderAuth(); - const updated = await refreshProviders({ dispose: true }); - if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return `Removed stored credentials for ${resolved}, but the worker still reports it as connected. Clear any remaining API key or OAuth credentials and restart the worker to fully disconnect.`; - } - return `Disconnected ${resolved}`; - } catch (error) { - const message = describeProviderError(error, "Failed to disconnect provider"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - function focusSessionPromptSoon() { if (typeof window === "undefined" || currentView() !== "session") return; requestAnimationFrame(() => { @@ -2596,89 +625,6 @@ export default function App() { }); } - async function openProviderAuthModal(options?: { - returnFocusTarget?: PromptFocusReturnTarget; - preferredProviderId?: string; - }) { - const workerType = activeWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; - setProviderAuthReturnFocusTarget(options?.returnFocusTarget ?? "none"); - setProviderAuthPreferredProviderId(options?.preferredProviderId?.trim() || null); - setProviderAuthBusy(true); - setProviderAuthError(null); - try { - const methods = await loadProviderAuthMethods(workerType); - setProviderAuthMethods(methods); - setProviderAuthModalOpen(true); - } catch (error) { - setProviderAuthPreferredProviderId(null); - setProviderAuthReturnFocusTarget("none"); - const message = describeProviderError(error, "Failed to load providers"); - setProviderAuthError(message); - throw error; - } finally { - setProviderAuthBusy(false); - } - } - - function closeProviderAuthModal(options?: { restorePromptFocus?: boolean }) { - const shouldFocusPrompt = - options?.restorePromptFocus ?? - providerAuthReturnFocusTarget() === "composer"; - setProviderAuthModalOpen(false); - setProviderAuthError(null); - setProviderAuthPreferredProviderId(null); - setProviderAuthReturnFocusTarget("none"); - if (shouldFocusPrompt) { - focusSessionPromptSoon(); - } - } - - async function saveSessionExport(sessionID: string) { - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const session = unwrap(await c.session.get({ sessionID })); - const messages = unwrap(await c.session.messages({ sessionID })); - let todos: TodoItem[] = []; - try { - todos = unwrap(await c.session.todo({ sessionID })); - } catch { - // ignore - } - - const payload = { - session, - messages, - todos, - exportedAt: new Date().toISOString(), - source: "openwork", - }; - - const baseName = session.title || session.slug || session.id; - const safeName = baseName - .toLowerCase() - .replace(/[^a-z0-9\-_.]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); - const fileName = `session-${safeName || session.id}.json`; - return downloadSessionExport(payload, fileName); - } - - function downloadSessionExport(payload: unknown, fileName: string) { - const json = JSON.stringify(payload, null, 2); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - link.click(); - URL.revokeObjectURL(url); - return fileName; - } - - async function respondPermissionAndRemember( requestID: string, reply: "once" | "always" | "reject" @@ -2688,313 +634,129 @@ export default function App() { await respondPermission(requestID, reply); } - const [notionStatus, setNotionStatus] = createSignal<"disconnected" | "connecting" | "connected" | "error">( - "disconnected", - ); - const [notionStatusDetail, setNotionStatusDetail] = createSignal(null); - const [notionError, setNotionError] = createSignal(null); - const [notionBusy, setNotionBusy] = createSignal(false); - const [notionSkillInstalled, setNotionSkillInstalled] = createSignal(false); - const [tryNotionPromptVisible, setTryNotionPromptVisible] = createSignal(false); - const notionIsActive = createMemo(() => notionStatus() === "connected"); - const [mcpServers, setMcpServers] = createSignal([]); - const [mcpStatus, setMcpStatus] = createSignal(null); - const [mcpLastUpdatedAt, setMcpLastUpdatedAt] = createSignal(null); - const [mcpStatuses, setMcpStatuses] = createSignal({}); - const [mcpConnectingName, setMcpConnectingName] = createSignal(null); - const [selectedMcp, setSelectedMcp] = createSignal(null); - const [scheduledJobs, setScheduledJobs] = createSignal([]); - const [scheduledJobsStatus, setScheduledJobsStatus] = createSignal(null); - const [scheduledJobsBusy, setScheduledJobsBusy] = createSignal(false); - const [scheduledJobsUpdatedAt, setScheduledJobsUpdatedAt] = createSignal(null); + openworkServerStore = createOpenworkServerStore({ + startupPreference, + documentVisible, + developerMode, + runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, + activeClient: client, + selectedWorkspaceDisplay: () => + workspaceStore?.selectedWorkspaceDisplay?.() ?? ({ workspaceType: "local" } as WorkspaceDisplay), + restartLocalServer, + createRemoteWorkspaceFlow: async (input) => + (await workspaceStore?.createRemoteWorkspaceFlow?.(input)) ?? false, + }); - // MCP OAuth modal state - const [mcpAuthModalOpen, setMcpAuthModalOpen] = createSignal(false); - const [mcpAuthEntry, setMcpAuthEntry] = createSignal<(typeof MCP_QUICK_CONNECT)[number] | null>(null); - const [mcpAuthNeedsReload, setMcpAuthNeedsReload] = createSignal(false); + const { + openworkServerSettings, + setOpenworkServerSettings, + updateOpenworkServerSettings, + resetOpenworkServerSettings, + shareRemoteAccessBusy, + shareRemoteAccessError, + saveShareRemoteAccess, + openworkServerUrl, + openworkServerBaseUrl, + openworkServerAuth, + openworkServerClient, + openworkServerStatus, + openworkServerCapabilities, + openworkServerReady, + openworkServerWorkspaceReady, + resolvedOpenworkCapabilities, + openworkServerCanWriteSkills, + openworkServerCanWritePlugins, + openworkServerHostInfo, + openworkServerDiagnostics, + openworkReconnectBusy, + opencodeRouterInfoState, + orchestratorStatusState, + openworkAuditEntries, + openworkAuditStatus, + openworkAuditError, + testOpenworkServerConnection, + reconnectOpenworkServer, + ensureLocalOpenworkServerClient, + } = openworkServerStore; const extensionsStore = createExtensionsStore({ client, projectDir: () => workspaceProjectDir(), - activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot(), - workspaceType: () => workspaceStore.activeWorkspaceDisplay().workspaceType, - openworkServerClient, - openworkServerStatus, - openworkServerCapabilities, - openworkServerWorkspaceId, + selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", + selectedWorkspaceRoot: () => workspaceStore?.selectedWorkspaceRoot?.() ?? "", + workspaceType: () => workspaceStore?.selectedWorkspaceDisplay?.().workspaceType ?? "local", + openworkServer: openworkServerStore, + runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, setBusy, setBusyLabel, setBusyStartedAt, setError, markReloadRequired, - onNotionSkillInstalled: () => { - setNotionSkillInstalled(true); - try { - window.localStorage.setItem("openwork.notionSkillInstalled", "1"); - } catch { - // ignore - } - if (notionIsActive()) { - setTryNotionPromptVisible(true); - } - }, }); const { skills, skillsStatus, - hubSkills, - hubSkillsStatus, - hubRepo, - hubRepos, pluginScope, - setPluginScope, - pluginConfig, - pluginConfigPath, - pluginList, - pluginInput, - setPluginInput, - pluginStatus, - activePluginGuide, - setActivePluginGuide, sidebarPluginList, sidebarPluginStatus, isPluginInstalledByName, refreshSkills, refreshHubSkills, - setHubRepo, - addHubRepo, - removeHubRepo, refreshPlugins, addPlugin, - removePlugin, - importLocalSkill, - installSkillCreator, - installHubSkill, - revealSkillsFolder, - uninstallSkill, - readSkill, - saveSkill, abortRefreshes, } = extensionsStore; - const globalSync = useGlobalSync(); - const providers = createMemo(() => globalSync.data.provider.all ?? []); - const providerDefaults = createMemo(() => globalSync.data.provider.default ?? {}); - const providerConnectedIds = createMemo(() => globalSync.data.provider.connected ?? []); - const setProviders = (value: ProviderListItem[]) => { - globalSync.set("provider", "all", value); - }; - const setProviderDefaults = (value: Record) => { - globalSync.set("provider", "default", value); - }; - const setProviderConnectedIds = (value: string[]) => { - globalSync.set("provider", "connected", value); - }; + const connectionsStore = createConnectionsStore({ + client, + setClient, + projectDir: () => workspaceProjectDir(), + selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", + selectedWorkspaceRoot: () => workspaceStore?.selectedWorkspaceRoot?.() ?? "", + workspaceType: () => workspaceStore?.selectedWorkspaceDisplay?.().workspaceType ?? "local", + openworkServer: openworkServerStore, + runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, + ensureRuntimeWorkspaceId: () => workspaceStore?.ensureRuntimeWorkspaceId?.(), + setProjectDir: (value: string) => workspaceStore?.setProjectDir?.(value), + developerMode, + markReloadRequired, + }); - const [defaultModel, setDefaultModel] = createSignal(DEFAULT_MODEL); - const sessionModelOverridesKey = (workspaceId: string) => - `${SESSION_MODEL_PREF_KEY}.${workspaceId}`; + const { refreshMcpServers } = connectionsStore; - const parseSessionModelOverrides = (raw: string | null) => { - if (!raw) return {} as Record; - try { - const parsed = JSON.parse(raw) as Record; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {} as Record; - } - const next: Record = {}; - for (const [sessionId, value] of Object.entries(parsed)) { - if (typeof value === "string") { - const model = parseModelRef(value); - if (model) next[sessionId] = model; - continue; - } - if (!value || typeof value !== "object") continue; - const record = value as Record; - if (typeof record.providerID === "string" && typeof record.modelID === "string") { - next[sessionId] = { - providerID: record.providerID, - modelID: record.modelID, - }; - } - } - return next; - } catch { - return {} as Record; - } - }; - - const serializeSessionModelOverrides = (overrides: Record) => { - const entries = Object.entries(overrides); - if (!entries.length) return null; - const payload: Record = {}; - for (const [sessionId, model] of entries) { - payload[sessionId] = formatModelRef(model); - } - return JSON.stringify(payload); - }; - - const parseDefaultModelFromConfig = (content: string | null) => { - if (!content) return null; - try { - const parsed = parse(content) as Record | undefined; - const rawModel = typeof parsed?.model === "string" ? parsed.model : null; - return parseModelRef(rawModel); - } catch { - return null; - } - }; - - const formatConfigWithDefaultModel = (content: string | null, model: ModelRef) => { - let config: Record = {}; - if (content?.trim()) { - try { - const parsed = parse(content) as Record | undefined; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - config = { ...parsed }; - } - } catch { - config = {}; - } - } - - if (!config["$schema"]) { - config["$schema"] = "https://opencode.ai/config.json"; - } - - config.model = formatModelRef(model); - return `${JSON.stringify(config, null, 2)}\n`; - }; - - const getConfigSnapshot = (content: string | null) => { - if (!content?.trim()) return ""; - try { - const parsed = parse(content) as Record; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const copy = { ...parsed }; - delete copy.model; - return JSON.stringify(copy); - } - return content; - } catch { - return content; - } - }; - - const ensureRecord = (value: unknown): Record => { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - return value as Record; - }; - - const normalizeAuthorizedFolderPath = (input: string | null | undefined) => { - const trimmed = (input ?? "").trim(); - if (!trimmed) return ""; - const withoutWildcard = trimmed.replace(/[\\/]\*+$/, ""); - return normalizeDirectoryQueryPath(withoutWildcard); - }; - - const authorizedFolderToExternalDirectoryKey = (folder: string) => { - const normalized = normalizeAuthorizedFolderPath(folder); - if (!normalized) return ""; - return normalized === "/" ? "/*" : `${normalized}/*`; - }; - - const externalDirectoryKeyToAuthorizedFolder = (key: string, value: unknown) => { - if (value !== "allow") return null; - const trimmed = key.trim(); - if (!trimmed) return null; - if (trimmed === "/*") return "/"; - if (!trimmed.endsWith("/*")) return null; - return normalizeAuthorizedFolderPath(trimmed.slice(0, -2)); - }; - - const readAuthorizedFoldersFromConfig = (opencodeConfig: Record) => { - const permission = ensureRecord(opencodeConfig.permission); - const externalDirectory = ensureRecord(permission.external_directory); - const folders: string[] = []; - const hiddenEntries: Record = {}; - const seen = new Set(); - - for (const [key, value] of Object.entries(externalDirectory)) { - const folder = externalDirectoryKeyToAuthorizedFolder(key, value); - if (!folder) { - hiddenEntries[key] = value; - continue; - } - if (seen.has(folder)) continue; - seen.add(folder); - folders.push(folder); - } - - return { folders, hiddenEntries }; - }; - - const buildAuthorizedFoldersStatus = (preservedCount: number, action?: string) => { - const preservedLabel = - preservedCount > 0 - ? `Preserving ${preservedCount} non-folder permission ${preservedCount === 1 ? "entry" : "entries"}.` - : null; - if (action && preservedLabel) return `${action} ${preservedLabel}`; - return action ?? preservedLabel; - }; - - const mergeAuthorizedFoldersIntoExternalDirectory = ( - folders: string[], - hiddenEntries: Record, - ): Record | undefined => { - const next: Record = { ...hiddenEntries }; - for (const folder of folders) { - const key = authorizedFolderToExternalDirectoryKey(folder); - if (!key) continue; - next[key] = "allow"; - } - return Object.keys(next).length ? next : undefined; - }; - const [modelPickerOpen, setModelPickerOpen] = createSignal(false); - const [modelPickerTarget, setModelPickerTarget] = createSignal< - "session" | "default" - >("session"); - const [modelPickerQuery, setModelPickerQuery] = createSignal(""); - const [modelPickerReturnFocusTarget, setModelPickerReturnFocusTarget] = - createSignal("none"); - - const [showThinking, setShowThinking] = createSignal(false); const [hideTitlebar, setHideTitlebar] = createSignal(false); - const [autoCompactContext, setAutoCompactContext] = createSignal(false); - const [modelVariantMap, setModelVariantMap] = createSignal>({}); - const modelVariant = () => getVariantFor(selectedSessionModel()); - const getVariantFor = (ref: ModelRef) => modelVariantMap()[`${ref.providerID}/${ref.modelID}`] ?? null; - const updateModelVariant = (ref: ModelRef, value: string | null) => { - const key = `${ref.providerID}/${ref.modelID}`; - setModelVariantMap((prev) => { - const next = { ...prev }; - if (value) next[key] = value; - else delete next[key]; - return next; - }); - }; - const setModelVariant = (value: string | null) => updateModelVariant(selectedSessionModel(), value); - const [autoCompactingSessionId, setAutoCompactingSessionId] = createSignal(null); - const [authorizedFolders, setAuthorizedFolders] = createSignal([]); - const [authorizedFolderDraft, setAuthorizedFolderDraft] = createSignal(""); - const [, setAuthorizedFolderHiddenEntries] = createSignal>({}); - const [authorizedFoldersLoading, setAuthorizedFoldersLoading] = createSignal(false); - const [authorizedFoldersSaving, setAuthorizedFoldersSaving] = createSignal(false); - const [authorizedFoldersStatus, setAuthorizedFoldersStatus] = createSignal(null); - const [authorizedFoldersError, setAuthorizedFoldersError] = createSignal(null); + const { + defaultModel, + selectedSessionModel, + selectedSessionModelLabel, + defaultModelLabel, + defaultModelRef, + defaultModelVariantLabel, + modelVariant, + sessionModelVariantLabel, + sessionModelBehaviorOptions, + setSessionModelVariant, + sanitizeModelVariantForRef, + resolveCodexReasoningEffort, + modelPickerOpen, + modelPickerQuery, + setModelPickerQuery, + modelPickerTarget, + modelPickerCurrent, + modelOptions, + filteredModelOptions, + openSessionModelPicker, + openDefaultModelPicker, + closeModelPicker, + applyModelSelection, + setModelPickerBehavior, + autoCompactContext, + toggleAutoCompactContext, + autoCompactContextSaving, + } = modelConfig; - const resolveCodexReasoningEffort = (modelID: string, variant: string | null) => { - if (!modelID.trim().toLowerCase().includes("codex")) return undefined; - const normalized = normalizeModelBehaviorValue(variant); - if (!normalized || normalized === "none") return undefined; - if (normalized === "minimal") return "low"; - if (normalized === "xhigh" || normalized === "max") return "high"; - if (!["low", "medium", "high"].includes(normalized)) return undefined; - return normalized; - }; - - const workspaceStore = createWorkspaceStore({ + workspaceStore = createWorkspaceStore({ startupPreference, setStartupPreference, onboardingStep, @@ -3019,8 +781,14 @@ export default function App() { setOpencodeConnectStatus, loadSessions: loadSessionsWithReady, refreshPendingPermissions, + refreshWorkspaceSessions: (workspaceId: string) => refreshSidebarWorkspaceSessions(workspaceId), + sessions, + sessionsLoaded, + creatingSession, + readLastSessionByWorkspace: readSessionByWorkspace, selectedSessionId, selectSession, + setBlueprintSeedMessagesBySessionId, setSelectedSessionId, setMessages, setTodos, @@ -3035,448 +803,181 @@ export default function App() { opencodeEnableExa, setEngineSource, setView, - setTab, + setSettingsTab, isWindowsPlatform, - openworkServerSettings, - updateOpenworkServerSettings, - openworkServerClient, - openworkServerStatus, - openworkServerWorkspaceId, + openworkServer: openworkServerStore, + openworkEnvWorkspaceId: envOpenworkWorkspaceId, onEngineStable: () => {}, engineRuntime, developerMode, + pendingInitialSessionSelection, + setPendingInitialSessionSelection, }); - type SidebarWorkspaceSessionsStatus = WorkspaceSessionGroup["status"]; - const [sidebarSessionsByWorkspaceId, setSidebarSessionsByWorkspaceId] = createSignal< - Record - >({}); - const [sidebarSessionStatusByWorkspaceId, setSidebarSessionStatusByWorkspaceId] = createSignal< - Record - >({}); - const [sidebarSessionErrorByWorkspaceId, setSidebarSessionErrorByWorkspaceId] = createSignal< - Record - >({}); + const { + providerAuthModalOpen, + providerAuthBusy, + providerAuthError, + providerAuthMethods, + providerAuthPreferredProviderId, + providerAuthWorkerType, + startProviderAuth, + refreshProviders, + completeProviderAuthOAuth, + submitProviderApiKey, + disconnectProvider, + openProviderAuthModal, + closeProviderAuthModal, + } = createProvidersStore({ + client, + providers, + providerDefaults, + providerConnectedIds, + disabledProviders: () => globalSync.data.config.disabled_providers ?? [], + selectedWorkspaceDisplay: () => workspaceStore.selectedWorkspaceDisplay(), + setProviders, + setProviderDefaults, + setProviderConnectedIds, + setDisabledProviders: (value) => globalSync.set("config", "disabled_providers", value), + markOpencodeConfigReloadRequired: () => markOpencodeConfigReloadRequired(), + focusPromptSoon: focusSessionPromptSoon, + }); + + const runtimeWorkspaceId = createMemo(() => workspaceStore.runtimeWorkspaceId()); + const activeWorkspaceServerConfig = createMemo(() => workspaceStore.runtimeWorkspaceConfig()); + const statusToastsStore = createStatusToastsStore(); + const bundlesStore = createBundlesStore({ + booting, + startupPreference, + openworkServer: openworkServerStore, + runtimeWorkspaceId, + workspaceStore, + setError, + error, + setView, + setSettingsTab, + refreshActiveWorkspaceServerConfig: workspaceStore.refreshRuntimeWorkspaceConfig, + refreshSkills, + refreshHubSkills, + markReloadRequired, + showStatusToast: statusToastsStore.showToast, + }); + + const deepLinks = createDeepLinksController({ + booting, + setError, + setView, + setSettingsTab, + goToSettings, + workspaceStore, + bundlesStore, + }); const logWorkspaceScopeSnapshot = (label: string, extra?: Record) => { if (!developerMode()) return; - const activeWorkspace = workspaceStore.activeWorkspaceInfo(); - const activeWorkspaceId = workspaceStore.activeWorkspaceId().trim(); - const activeWorkspaceRoot = workspaceStore.activeWorkspaceRoot().trim(); + const activeWorkspace = workspaceStore.selectedWorkspaceInfo(); + const selectedWorkspaceId = workspaceStore.selectedWorkspaceId().trim(); + const selectedWorkspaceRoot = workspaceStore.selectedWorkspaceRoot().trim(); const engineInfo = workspaceStore.engine(); const map = readSessionByWorkspace(); wsDebug(label, { - activeWorkspaceId: activeWorkspaceId || null, + selectedWorkspaceId: selectedWorkspaceId || null, activeWorkspaceType: activeWorkspace?.workspaceType ?? null, - activeWorkspacePath: activeWorkspace?.path?.trim() ?? null, + selectedWorkspacePath: activeWorkspace?.path?.trim() ?? null, activeWorkspaceDirectory: activeWorkspace?.directory?.trim() ?? null, - activeWorkspaceRoot: activeWorkspaceRoot || null, - activeWorkspaceScope: describeDirectoryScope(activeWorkspaceRoot), + selectedWorkspaceRoot: selectedWorkspaceRoot || null, + activeWorkspaceScope: describeDirectoryScope(selectedWorkspaceRoot), clientDirectory: clientDirectory().trim() || null, clientDirectoryScope: describeDirectoryScope(clientDirectory().trim()), engineProjectDir: engineInfo?.projectDir?.trim() ?? null, engineProjectScope: describeDirectoryScope(engineInfo?.projectDir?.trim() ?? null), - lastSessionForActiveWorkspace: activeWorkspaceId ? map[activeWorkspaceId] ?? null : null, + lastSessionForActiveWorkspace: selectedWorkspaceId ? map[selectedWorkspaceId] ?? null : null, lastSessionMapKeys: Object.keys(map), ...extra, }); }; - const pruneSidebarSessionState = (workspaceIds: Set) => { - setSidebarSessionsByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, list] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = list; - } - return changed ? next : prev; - }); - setSidebarSessionStatusByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, status] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = status; - } - return changed ? next : prev; - }); - setSidebarSessionErrorByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, error] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = error; - } - return changed ? next : prev; - }); - }; - - const resolveSidebarClientConfig = (workspaceId: string) => { - const workspace = workspaceStore.workspaces().find((entry) => entry.id === workspaceId) ?? null; - if (!workspace) return null; - - if (workspace.workspaceType === "local") { - const info = workspaceStore.engine(); - const baseUrl = info?.baseUrl?.trim() ?? ""; - const directory = toSessionTransportDirectory(workspace.path?.trim() ?? ""); - const username = info?.opencodeUsername?.trim() ?? ""; - const password = info?.opencodePassword?.trim() ?? ""; - const auth: OpencodeAuth | undefined = username && password ? { username, password } : undefined; - return { - baseUrl, - directory, - auth, - }; - } - - const baseUrl = workspace.baseUrl?.trim() ?? ""; - const directory = workspace.directory?.trim() ?? ""; - if (workspace.remoteType === "openwork") { - // Sidebar session listing should be per-workspace and should not implicitly depend on - // global OpenWork server settings, otherwise switching between remotes can cause other - // workspace task lists to appear/disappear. - const token = workspace.openworkToken?.trim() ?? ""; - const auth: OpencodeAuth | undefined = token ? { token, mode: "openwork" } : undefined; - return { - baseUrl, - directory, - auth, - }; - } - return { - baseUrl, - directory, - auth: undefined as OpencodeAuth | undefined, - }; - }; - - const sidebarRefreshSeqByWorkspaceId: Record = {}; - const SIDEBAR_SESSION_LIMIT = 200; - const refreshSidebarWorkspaceSessions = async (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return; - - const config = resolveSidebarClientConfig(id); - if (!config) return; - - // For local workspaces, avoid thrashing UI with errors if the engine is offline. - if (!config.baseUrl) { - let changed = false; - setSidebarSessionStatusByWorkspaceId((prev) => { - if (prev[id] === "idle") return prev; - changed = true; - return { ...prev, [id]: "idle" }; - }); - setSidebarSessionErrorByWorkspaceId((prev) => { - if ((prev[id] ?? null) === null) return prev; - changed = true; - return { ...prev, [id]: null }; - }); - if (changed) { - wsDebug("sidebar:skip", { id, reason: "no-baseUrl" }); - } - return; - } - - sidebarRefreshSeqByWorkspaceId[id] = (sidebarRefreshSeqByWorkspaceId[id] ?? 0) + 1; - const seq = sidebarRefreshSeqByWorkspaceId[id]; - - setSidebarSessionStatusByWorkspaceId((prev) => ({ ...prev, [id]: "loading" })); - setSidebarSessionErrorByWorkspaceId((prev) => ({ ...prev, [id]: null })); - - try { - const start = Date.now(); - let directory = config.directory; - let c = createClient(config.baseUrl, directory || undefined, config.auth); - wsDebug("sidebar:list:start", { - id, - directory: directory || null, - directoryScope: describeDirectoryScope(directory), - workspacePath: workspaceStore.workspaces().find((entry) => entry.id === id)?.path?.trim() ?? null, - }); - - if (!directory) { - try { - const pathInfo = unwrap(await c.path.get()); - const discovered = toSessionTransportDirectory(pathInfo.directory ?? ""); - if (discovered) { - directory = discovered; - c = createClient(config.baseUrl, directory, config.auth); - } - } catch { - // ignore - } - } - - const queryDirectory = normalizeDirectoryQueryPath(directory) || undefined; - - // Fetch sessions scoped to the workspace directory to avoid loading the - // full global session list for every workspace. - const list = unwrap( - await c.session.list({ directory: queryDirectory, roots: false, limit: SIDEBAR_SESSION_LIMIT }), - ); - wsDebug("sidebar:list", { - id, - baseUrl: config.baseUrl, - directory: directory || null, - directoryScope: describeDirectoryScope(directory), - queryDirectory: queryDirectory ?? null, - queryScope: describeDirectoryScope(queryDirectory), - count: list.length, - ms: Date.now() - start, - sessionDirectories: list.slice(0, 10).map((session) => ({ - id: session.id, - directory: session.directory, - directoryScope: describeDirectoryScope(session.directory), - })), - }); - if (sidebarRefreshSeqByWorkspaceId[id] !== seq) return; - - // Defensive client-side filter in case upstream ignores the directory query. - const root = normalizeDirectoryPath(directory); - const filtered = root ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) : list; - - const sorted = sortSessionsByActivity(filtered); - const items: SidebarSessionItem[] = sorted.map((session) => ({ - id: session.id, - title: session.title, - slug: session.slug, - parentID: session.parentID, - time: session.time, - directory: session.directory, - })); - - setSidebarSessionsByWorkspaceId((prev) => ({ - ...prev, - [id]: items, - })); - setSidebarSessionStatusByWorkspaceId((prev) => ({ ...prev, [id]: "ready" })); - } catch (error) { - if (sidebarRefreshSeqByWorkspaceId[id] !== seq) return; - const message = error instanceof Error ? error.message : safeStringify(error); - wsDebug("sidebar:error", { id, message }); - setSidebarSessionStatusByWorkspaceId((prev) => ({ ...prev, [id]: "error" })); - setSidebarSessionErrorByWorkspaceId((prev) => ({ ...prev, [id]: message })); - } - }; - - const refreshAllSidebarWorkspaceSessions = async (prioritizeWorkspaceId?: string | null) => { - const list = workspaceStore.workspaces(); - if (!list.length) return; - const prioritize = (prioritizeWorkspaceId ?? "").trim(); - const ordered = prioritize - ? [...list.filter((ws) => ws.id === prioritize), ...list.filter((ws) => ws.id !== prioritize)] - : list; - for (const ws of ordered) { - await refreshSidebarWorkspaceSessions(ws.id); - // Yield so long refresh passes don't block UI / timers. - await new Promise((resolve) => setTimeout(resolve, 0)); - } - }; - - const refreshLocalSidebarWorkspaceSessions = async (prioritizeWorkspaceId?: string | null) => { - const list = workspaceStore.workspaces().filter((ws) => ws.workspaceType === "local"); - if (!list.length) return; - const prioritize = (prioritizeWorkspaceId ?? "").trim(); - const ordered = prioritize - ? [...list.filter((ws) => ws.id === prioritize), ...list.filter((ws) => ws.id !== prioritize)] - : list; - for (const ws of ordered) { - await refreshSidebarWorkspaceSessions(ws.id); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - }; - - let lastSidebarEngineKey = ""; - let lastSidebarWorkspaceKey = ""; - createEffect(() => { - const engineInfo = workspaceStore.engine(); - const engineBaseUrl = engineInfo?.baseUrl?.trim() ?? ""; - const engineUser = engineInfo?.opencodeUsername?.trim() ?? ""; - const enginePass = engineInfo?.opencodePassword?.trim() ?? ""; - - const engineKey = [engineBaseUrl, engineUser, enginePass].join("::"); - const workspaceKey = workspaceStore - .workspaces() - .map((ws) => { - const root = ws.workspaceType === "local" ? ws.path?.trim() ?? "" : ws.directory?.trim() ?? ""; - const base = ws.workspaceType === "local" ? "" : ws.baseUrl?.trim() ?? ""; - const remoteType = ws.workspaceType === "remote" ? (ws.remoteType ?? "") : ""; - const token = ws.remoteType === "openwork" ? (ws.openworkToken?.trim() ?? "") : ""; - return [ws.id, ws.workspaceType, remoteType, root, base, token].join("|"); - }) - .join(";"); - - // Sidebar session refreshes should only be driven by the engine auth/baseUrl or the workspace - // definitions themselves. Global OpenWork server settings are intentionally excluded so that - // connecting/activating a remote does not cause other workspace task lists to refresh (and - // potentially disappear) due to auth fallback changes. - if (engineKey === lastSidebarEngineKey && workspaceKey === lastSidebarWorkspaceKey) return; - - const engineChanged = engineKey !== lastSidebarEngineKey; - const workspacesChanged = workspaceKey !== lastSidebarWorkspaceKey; - - lastSidebarEngineKey = engineKey; - lastSidebarWorkspaceKey = workspaceKey; - - pruneSidebarSessionState(new Set(workspaceStore.workspaces().map((ws) => ws.id))); - - wsDebug("sidebar:refresh", { - engineChanged, - workspacesChanged, - activeWorkspaceId: workspaceStore.activeWorkspaceId(), - engineBaseUrl, - }); - - // Avoid refreshing remote workspace sessions when only the local engine auth/baseUrl changes. - // Remote->local switches commonly change engineBaseUrl, and refreshing every remote workspace - // at the same time can trigger large /session responses and UI hangs. - if (engineChanged && !workspacesChanged) { - void refreshLocalSidebarWorkspaceSessions(workspaceStore.activeWorkspaceId()).catch(() => undefined); - return; - } - - void refreshAllSidebarWorkspaceSessions(workspaceStore.activeWorkspaceId()).catch(() => undefined); + const sidebarSessionsStore = createSidebarSessionsStore({ + workspaces: () => workspaceStore.workspaces(), + engine: () => workspaceStore.engine(), }); - createEffect(() => { - const id = workspaceStore.activeWorkspaceId().trim(); - if (!id) return; - const status = sidebarSessionStatusByWorkspaceId()[id] ?? "idle"; - // Only auto-load once per workspace activation. - // If a remote is offline, repeated retries here can create an endless refresh loop. - if (status !== "idle") return; - refreshSidebarWorkspaceSessions(id).catch(() => undefined); + const { + workspaceGroups: rawSidebarWorkspaceGroups, + refreshWorkspaceSessions: refreshSidebarWorkspaceSessions, + } = sidebarSessionsStore; + + const sessionActionsStore = createSessionActionsStore({ + client, + baseUrl, + developerMode, + prompt, + setPrompt, + selectedSessionId, + selectedSession, + sessions, + messages, + setSessions, + sessionStatusById, + setSessionStatusById, + setBusy, + setBusyLabel, + setBusyStartedAt, + setCreatingSession, + setError, + workspaceProjectDir: () => workspaceStore.projectDir(), + selectedWorkspaceId: () => workspaceStore.selectedWorkspaceId(), + selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), + ensureSelectedWorkspaceRuntime, + selectSession, + refreshSidebarWorkspaceSessions, + abortRefreshes, + modelConfig, + selectedSessionModel: () => selectedSessionModel(), + modelVariant, + sanitizeModelVariantForRef: (ref, value) => sanitizeModelVariantForRef(ref, value), + resolveCodexReasoningEffort: (modelId, variant) => resolveCodexReasoningEffort(modelId, variant), + messageIdFromInfo, + restorePromptFromUserMessage, + upsertLocalSession, + readSessionByWorkspace, + writeSessionByWorkspace, + setSelectedSessionId, + locationPath: () => location.pathname, + navigate, + renameSession, + appendSessionErrorTurn: sessionStore.appendSessionErrorTurn, }); - createEffect(() => { - const allSessions = sessions(); // reactive dependency on session store - // When switching workers, the session store can update before the activeWorkspaceId flips. - // Use connectingWorkspaceId as the authoritative target during the switch so we don't - // accidentally overwrite another worker's sidebar sessions. - const wsId = (workspaceStore.connectingWorkspaceId() ?? workspaceStore.activeWorkspaceId()).trim(); - if (!wsId) return; - const status = sidebarSessionStatusByWorkspaceId()[wsId]; - - // Only sync if sidebar is already in 'ready' state (not during initial load) - if (status === "ready") { - const activeWorkspace = workspaceStore.workspaces().find((workspace) => workspace.id === wsId) ?? null; - const activeWorkspaceRoot = normalizeDirectoryPath( - activeWorkspace?.workspaceType === "local" - ? activeWorkspace.path - : activeWorkspace?.directory ?? activeWorkspace?.path, - ); - if ( - !shouldApplyScopedSessionLoad({ - loadedScopeRoot: loadedSessionScopeRoot(), - workspaceRoot: activeWorkspaceRoot, - }) - ) { - if (developerMode()) { - console.log("[sidebar-sync] skip stale session scope", { - wsId, - loadedScopeRoot: loadedSessionScopeRoot(), - activeWorkspaceRoot, - }); - } - return; - } - const scopedSessions = activeWorkspaceRoot - ? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot) - : allSessions; - const sorted = sortSessionsByActivity(scopedSessions); - if (developerMode()) { - console.log("[sidebar-sync] workspace session scope", { - wsId, - status, - activeWorkspace, - activeWorkspaceRoot, - allSessions: allSessions.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - parentID: session.parentID, - })), - scopedSessions: scopedSessions.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - parentID: session.parentID, - })), - }); - } - const rootItems: SidebarSessionItem[] = sorted.map((s) => ({ - id: s.id, - title: s.title, - slug: s.slug, - parentID: s.parentID, - time: s.time, - directory: s.directory, - })); - setSidebarSessionsByWorkspaceId((prev) => { - const current = prev[wsId] ?? []; - const hasCurrentChildren = current.some((item) => Boolean(item.parentID?.trim())); - const incomingAreRootsOnly = rootItems.every((item) => !item.parentID?.trim()); - if (!hasCurrentChildren || !incomingAreRootsOnly) { - return { - ...prev, - [wsId]: rootItems, - }; - } - - const byId = new Map(current.map((item) => [item.id, item] as const)); - for (const item of rootItems) { - byId.set(item.id, { - ...(byId.get(item.id) ?? {}), - ...item, - }); - } - - const rootIDs = new Set(rootItems.map((item) => item.id)); - const keepChild = (item: SidebarSessionItem, seen = new Set()) => { - const parentID = item.parentID?.trim() ?? ""; - if (!parentID) return false; - if (rootIDs.has(parentID)) return true; - if (seen.has(parentID)) return false; - const parent = byId.get(parentID); - if (!parent) return false; - seen.add(parentID); - return keepChild(parent, seen); - }; - - return { - ...prev, - [wsId]: [ - ...rootItems, - ...current.filter((item) => !rootIDs.has(item.id) && keepChild(item)), - ], - }; - }); - } - }); + const { + lastPromptSent, + selectedSessionAgent, + sessionRevertMessageId, + createSessionAndOpen, + sendPrompt, + abortSession, + retryLastPrompt, + compactCurrentSession, + undoLastUserMessage, + redoLastUserMessage, + renameSessionTitle, + deleteSessionById, + listAgents, + listCommands, + setSessionAgent, + searchWorkspaceFiles, + } = sessionActionsStore; const sidebarWorkspaceGroups = createMemo(() => { - const workspaces = workspaceStore.workspaces(); - const activeWorkspaceId = workspaceStore.activeWorkspaceId().trim(); + const groups = rawSidebarWorkspaceGroups(); + const selectedWorkspaceId = workspaceStore.selectedWorkspaceId().trim(); const connectingWorkspaceId = workspaceStore.connectingWorkspaceId()?.trim() ?? ""; - const sessionsById = sidebarSessionsByWorkspaceId(); - const statusById = sidebarSessionStatusByWorkspaceId(); - const errorById = sidebarSessionErrorByWorkspaceId(); - const dedupedWorkspaces: typeof workspaces = []; + const dedupedGroups: typeof groups = []; const dedupeKeyToIndex = new Map(); - for (const workspace of workspaces) { + for (const group of groups) { + const workspace = group.workspace; if (workspace.workspaceType !== "remote") { - dedupedWorkspaces.push(workspace); + dedupedGroups.push(group); continue; } const hostKey = @@ -3491,27 +992,28 @@ export default function App() { const directoryKey = normalizeDirectoryPath(workspace.directory?.trim() ?? workspace.path?.trim() ?? ""); const identityKey = workspaceIdKey ? `id:${workspaceIdKey}` : (directoryKey ? `dir:${directoryKey}` : ""); if (!hostKey || !identityKey) { - dedupedWorkspaces.push(workspace); + dedupedGroups.push(group); continue; } const dedupeKey = `${workspace.remoteType ?? ""}|${hostKey}|${identityKey}`; const existingIndex = dedupeKeyToIndex.get(dedupeKey); if (existingIndex === undefined) { - dedupeKeyToIndex.set(dedupeKey, dedupedWorkspaces.length); - dedupedWorkspaces.push(workspace); + dedupeKeyToIndex.set(dedupeKey, dedupedGroups.length); + dedupedGroups.push(group); continue; } - const existingWorkspace = dedupedWorkspaces[existingIndex]; + const existingWorkspace = dedupedGroups[existingIndex].workspace; const existingIsPriority = - existingWorkspace.id === activeWorkspaceId || existingWorkspace.id === connectingWorkspaceId; + existingWorkspace.id === selectedWorkspaceId || existingWorkspace.id === connectingWorkspaceId; const currentIsPriority = - workspace.id === activeWorkspaceId || workspace.id === connectingWorkspaceId; + workspace.id === selectedWorkspaceId || workspace.id === connectingWorkspaceId; if (currentIsPriority && !existingIsPriority) { - dedupedWorkspaces[existingIndex] = workspace; + dedupedGroups[existingIndex] = group; } } - return dedupedWorkspaces.map((workspace) => { - const groupSessions = sessionsById[workspace.id] ?? []; + return dedupedGroups.map((group) => { + const workspace = group.workspace; + const groupSessions = group.sessions; if (developerMode()) { console.log("[sidebar-groups] workspace group", { workspaceId: workspace.id, @@ -3531,15 +1033,15 @@ export default function App() { return { workspace, sessions: groupSessions, - status: statusById[workspace.id] ?? "idle", - error: errorById[workspace.id] ?? null, + status: group.status, + error: group.error, }; }); }); createEffect(() => { if (typeof window === "undefined") return; - const workspaceId = workspaceStore.activeWorkspaceId(); + const workspaceId = workspaceStore.selectedWorkspaceId(); const sessionId = selectedSessionId(); if (!workspaceId || !sessionId) return; const map = readSessionByWorkspace(); @@ -3548,9 +1050,62 @@ export default function App() { writeSessionByWorkspace(map); }); + createEffect(() => { + if (typeof window === "undefined") return; + const pending = pendingInitialSessionSelection(); + if (!pending) return; + const delayMs = pending.readyAt - Date.now(); + if (delayMs <= 0) return; + const timer = window.setTimeout(() => { + setPendingInitialSessionSelection((current) => + current && current.workspaceId === pending.workspaceId && current.readyAt === pending.readyAt + ? { ...current } + : current, + ); + }, delayMs); + onCleanup(() => window.clearTimeout(timer)); + }); + + createEffect(() => { + const pending = pendingInitialSessionSelection(); + if (!pending) return; + const workspaceId = workspaceStore.selectedWorkspaceId().trim(); + if (!workspaceId || pending.workspaceId !== workspaceId) return; + const path = location.pathname.trim().toLowerCase(); + if (path.startsWith("/session/") || !!selectedSessionId()) { + setPendingInitialSessionSelection(null); + } + }); + createEffect(() => { // Only auto-select on bare /session. If the URL already includes /session/:id, // let the route-driven selector own the fetch to avoid duplicate selection runs. + const pending = pendingInitialSessionSelection(); + const workspaceId = workspaceStore.selectedWorkspaceId().trim(); + if (pending && pending.workspaceId === workspaceId) { + if (Date.now() < pending.readyAt) return; + if (!sessionsLoaded()) return; + if (sessions().length === 0) return; + const workspaceRoot = normalizeDirectoryPath(workspaceStore.selectedWorkspaceRoot().trim()); + const normalizedTitle = pending.title?.trim().toLowerCase() ?? ""; + const match = normalizedTitle + ? sessions().find((session) => { + const sessionTitle = session.title?.trim().toLowerCase() ?? ""; + if (sessionTitle !== normalizedTitle) return false; + if (!workspaceRoot) return true; + const sessionRoot = normalizeDirectoryPath(typeof session.directory === "string" ? session.directory : ""); + return sessionRoot === workspaceRoot; + }) + : null; + if (match) { + goToSession(match.id, { replace: true }); + return; + } + setPendingInitialSessionSelection(null); + setView("session"); + return; + } + if (currentView() !== "session") return; const normalizedPath = location.pathname.toLowerCase().replace(/\/+$/, ""); if (normalizedPath !== "/session") return; @@ -3565,519 +1120,7 @@ export default function App() { }); createEffect(() => { - const active = workspaceStore.activeWorkspaceDisplay(); - const client = openworkServerClient(); - const openworkUrl = openworkServerUrl().trim(); - - if (!client || openworkServerStatus() !== "connected") { - setOpenworkServerWorkspaceId(null); - return; - } - - if (active.workspaceType === "remote" && active.remoteType === "openwork") { - const inferredWorkspaceId = - parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") ?? - parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") ?? - parseOpenworkWorkspaceIdFromUrl(openworkUrl); - const storedId = active.openworkWorkspaceId?.trim() || inferredWorkspaceId || envOpenworkWorkspaceId || null; - if (storedId) { - setOpenworkServerWorkspaceId(storedId); - return; - } - - let cancelled = false; - const resolveWorkspace = async () => { - try { - const response = await client.listWorkspaces(); - if (cancelled) return; - const items = Array.isArray(response.items) ? response.items : []; - const directoryHint = normalizeDirectoryPath(active.directory?.trim() ?? active.path?.trim() ?? ""); - const match = directoryHint - ? items.find((entry) => { - const entryPath = normalizeDirectoryPath((entry.opencode?.directory ?? entry.directory ?? entry.path ?? "").trim()); - return Boolean(entryPath && entryPath === directoryHint); - }) - : (response.activeId ? items.find((entry) => entry.id === response.activeId) : null) ?? items[0]; - setOpenworkServerWorkspaceId(match?.id ?? response.activeId ?? null); - } catch { - if (!cancelled) setOpenworkServerWorkspaceId(null); - } - }; - - void resolveWorkspace(); - onCleanup(() => { - cancelled = true; - }); - return; - } - - if (active.workspaceType === "local") { - const root = normalizeDirectoryPath(workspaceStore.activeWorkspaceRoot().trim()); - if (!root) { - setOpenworkServerWorkspaceId(null); - return; - } - - let cancelled = false; - const resolveWorkspace = async () => { - try { - const response = await client.listWorkspaces(); - if (cancelled) return; - const items = Array.isArray(response.items) ? response.items : []; - const match = items.find((entry) => normalizeDirectoryPath(entry.path) === root); - setOpenworkServerWorkspaceId(match?.id ?? null); - } catch { - if (!cancelled) setOpenworkServerWorkspaceId(null); - } - }; - - void resolveWorkspace(); - onCleanup(() => { - cancelled = true; - }); - return; - } - - setOpenworkServerWorkspaceId(null); - }); - - const resolveSharedBundleWorkerTarget = () => { - const pref = startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settings = openworkServerSettings(); - - const localHostUrl = normalizeOpenworkServerUrl(hostInfo?.baseUrl ?? "") ?? ""; - const localToken = hostInfo?.clientToken?.trim() ?? ""; - const serverHostUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; - const serverToken = settings.token?.trim() ?? ""; - - if (pref === "server") { - return { - hostUrl: serverHostUrl || localHostUrl, - token: serverToken || localToken, - }; - } - - if (pref === "local") { - return { - hostUrl: localHostUrl || serverHostUrl, - token: localToken || serverToken, - }; - } - - if (localHostUrl) { - return { - hostUrl: localHostUrl, - token: localToken || serverToken, - }; - } - - return { - hostUrl: serverHostUrl, - token: serverToken || localToken, - }; - }; - - const isSharedBundleImportWorkspace = (workspace: WorkspaceDisplay | WorkspaceInfo | null) => { - if (!workspace?.id?.trim()) return false; - if (workspace.workspaceType === "local") { - return Boolean(workspace.path?.trim()); - } - return Boolean( - workspace.remoteType === "openwork" || - workspace.openworkHostUrl?.trim() || - workspace.openworkWorkspaceId?.trim() - ); - }; - - const resolveSharedBundleImportTargetForWorkspace = ( - workspace: WorkspaceDisplay | WorkspaceInfo | null, - ): SharedBundleImportTarget | undefined => { - if (!workspace) return undefined; - if (workspace.workspaceType === "local") { - const localRoot = workspace.path?.trim() ?? ""; - return localRoot ? { localRoot } : undefined; - } - - const workspaceId = - workspace.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || - null; - const directoryHint = workspace.directory?.trim() || workspace.path?.trim() || null; - if (workspaceId || directoryHint) { - return { - workspaceId, - directoryHint, - }; - } - return undefined; - }; - - const findSharedBundleImportWorkspaceId = ( - items: Array<{ id: string; path?: string | null; directory?: string | null; opencode?: { directory?: string | null } }>, - target?: SharedBundleImportTarget, - ) => { - const explicitId = target?.workspaceId?.trim() ?? ""; - if (explicitId) { - const match = items.find((entry) => entry.id === explicitId); - if (match?.id) return match.id; - } - - const localRoot = normalizeDirectoryPath(target?.localRoot?.trim() ?? ""); - if (localRoot) { - const match = items.find((entry) => normalizeDirectoryPath(entry.path ?? "") === localRoot); - if (match?.id) return match.id; - } - - const directoryHint = normalizeDirectoryPath(target?.directoryHint?.trim() ?? ""); - if (directoryHint) { - const match = items.find((entry) => { - const entryPath = normalizeDirectoryPath((entry.opencode?.directory ?? entry.directory ?? entry.path ?? "").trim()); - return Boolean(entryPath && entryPath === directoryHint); - }); - if (match?.id) return match.id; - } - - return null; - }; - - const resolveActiveSharedBundleImportTarget = (): SharedBundleImportTarget => { - const active = workspaceStore.activeWorkspaceDisplay(); - if (active.workspaceType === "local") { - return { localRoot: workspaceStore.activeWorkspaceRoot().trim() }; - } - - return { - workspaceId: - active.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || - null, - directoryHint: active.directory?.trim() || active.path?.trim() || null, - }; - }; - - const waitForSharedBundleImportTarget = async (timeoutMs = 20_000, target?: SharedBundleImportTarget) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const client = openworkServerClient(); - if (client && openworkServerStatus() === "connected") { - if (target?.workspaceId?.trim() || target?.localRoot?.trim() || target?.directoryHint?.trim()) { - try { - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const matchId = findSharedBundleImportWorkspaceId(items, target); - if (matchId) { - setOpenworkServerWorkspaceId(matchId); - return { client, workspaceId: matchId }; - } - } catch { - // ignore and keep polling - } - } else { - const workspaceId = openworkServerWorkspaceId(); - if (workspaceId) { - return { client, workspaceId }; - } - } - } - await new Promise((resolve) => { - window.setTimeout(resolve, 200); - }); - } - throw new Error("OpenWork worker is not ready yet."); - }; - - const importSharedBundlePayload = async (bundle: SharedBundleV1, target?: SharedBundleImportTarget) => { - const { client, workspaceId } = await waitForSharedBundleImportTarget(20_000, target); - const { payload, importedSkillsCount } = buildImportPayloadFromBundle(bundle); - await client.importWorkspace(workspaceId, payload); - await refreshSkills({ force: true }); - await refreshHubSkills({ force: true }); - if (importedSkillsCount > 0) { - markReloadRequired("skills", { - type: "skill", - name: bundle.name?.trim() || undefined, - action: "added", - }); - console.log(`[openwork] imported ${importedSkillsCount} skills from share bundle`); - } - }; - - const importSharedBundleIntoActiveWorker = async ( - request: SharedBundleDeepLink, - target?: SharedBundleImportTarget, - bundleOverride?: SharedBundleV1, - ) => { - try { - const bundle = bundleOverride ?? (await fetchSharedBundle(request.bundleUrl)); - await importSharedBundlePayload(bundle, target); - setError(null); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - setError(addOpencodeCacheHint(message)); - return false; - } - }; - - const createWorkerForSharedBundle = async (request: SharedBundleDeepLink, bundle: SharedBundleV1) => { - const target = resolveSharedBundleWorkerTarget(); - const hostUrl = target.hostUrl.trim(); - const token = target.token.trim(); - if (!hostUrl || !token) { - throw new Error("Share link detected. Configure an OpenWork worker host and token, then open the link again."); - } - - const label = (request.label?.trim() || bundle.name?.trim() || "Shared setup").slice(0, 80); - const ok = await workspaceStore.createRemoteWorkspaceFlow({ - openworkHostUrl: hostUrl, - openworkToken: token, - directory: null, - displayName: label, - manageBusy: false, - closeModal: false, - }); - - if (!ok) { - throw new Error("Failed to create a worker from this share link."); - } - }; - - const importSharedSkillIntoWorkspace = async (workspaceId: string) => { - if (sharedSkillDestinationBusyId()) return; - const destination = sharedSkillDestinationRequest(); - if (!destination) return; - - const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (!isSharedBundleImportWorkspace(workspace)) { - setError("This worker cannot accept shared skills yet."); - return; - } - - setView("dashboard"); - setTab("scheduled"); - setError(null); - setSharedSkillDestinationBusyId(workspaceId); - - try { - const ok = await workspaceStore.activateWorkspace(workspaceId); - if (!ok) return; - - const imported = await importSharedBundleIntoActiveWorker( - destination.request, - resolveSharedBundleImportTargetForWorkspace(workspace), - destination.bundle, - ); - if (!imported) return; - - showSharedSkillSuccessToast({ - title: "Skill added", - description: `Added '${destination.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForToasts(workspace)}.`, - }); - setSharedSkillDestinationRequest(null); - setSharedBundleCreateWorkerRequest(null); - setSharedBundleNoticeShown(false); - } finally { - setSharedSkillDestinationBusyId(null); - } - }; - - const processSharedBundleInvite = async (request: SharedBundleDeepLink) => { - const bundle = await fetchSharedBundle(request.bundleUrl); - - if (bundle.type === "skill") { - setView("dashboard"); - setTab("scheduled"); - setError(null); - setSharedSkillDestinationRequest({ request, bundle }); - return { mode: "choice" as const, bundle }; - } - - if (bundle.type === "skills-set") { - setView("dashboard"); - setTab("skills"); - setError(null); - setSharedBundleImportChoice({ request, bundle }); - return { mode: "choice" as const, bundle }; - } - - if (request.intent === "new_worker" && isTauriRuntime()) { - setView("dashboard"); - setTab("scheduled"); - setError(null); - setSharedBundleCreateWorkerRequest({ - request, - bundle, - defaultPreset: "automation", - }); - workspaceStore.setCreateWorkspaceOpen(true); - return { mode: "new_worker_modal" as const, bundle }; - } - - if (request.intent === "import_current") { - const client = openworkServerClient(); - const connected = openworkServerStatus() === "connected"; - const target = resolveActiveSharedBundleImportTarget(); - const hasTargetHint = Boolean(target.workspaceId?.trim() || target.localRoot?.trim() || target.directoryHint?.trim()); - if (!client || !connected || !hasTargetHint) { - if (!sharedBundleNoticeShown()) { - setSharedBundleNoticeShown(true); - setError("Share link detected. Connect to a writable OpenWork worker to import this bundle."); - } - return { mode: "blocked_import_current" as const, bundle }; - } - } else { - const target = resolveSharedBundleWorkerTarget(); - if (!target.hostUrl.trim() || !target.token.trim()) { - if (!sharedBundleNoticeShown()) { - setSharedBundleNoticeShown(true); - setError("Share link detected. Configure an OpenWork host and token to create a new worker."); - } - return { mode: "blocked_new_worker" as const, bundle }; - } - } - - if (request.intent === "new_worker") { - await createWorkerForSharedBundle(request, bundle); - } - - await importSharedBundlePayload(bundle, resolveActiveSharedBundleImportTarget()); - setError(null); - return { mode: "imported" as const, bundle }; - }; - - createEffect(() => { - const request = pendingSharedBundleInvite(); - if (!request || booting()) { - return; - } - - if (untrack(sharedBundleImportBusy)) { - return; - } - - let cancelled = false; - setSharedBundleImportBusy(true); - - void (async () => { - try { - await processSharedBundleInvite(request); - if (cancelled) return; - } catch (error) { - if (!cancelled) { - const message = error instanceof Error ? error.message : safeStringify(error); - setError(addOpencodeCacheHint(message)); - } - } finally { - if (!cancelled) { - const nextPendingInvite = pendingSharedBundleInvite(); - const shouldClearPendingInvite = nextPendingInvite === request; - setSharedBundleImportBusy(false); - if (shouldClearPendingInvite) { - setPendingSharedBundleInvite(null); - setSharedBundleNoticeShown(false); - } else if (nextPendingInvite) { - setPendingSharedBundleInvite({ ...nextPendingInvite }); - } - } - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - if (!developerMode()) { - setDevtoolsWorkspaceId(null); - return; - } - if (!documentVisible()) return; - - const client = devtoolsOpenworkClient(); - if (!client) { - setDevtoolsWorkspaceId(null); - return; - } - - const root = normalizeDirectoryPath(workspaceStore.activeWorkspaceRoot().trim()); - let active = true; - - const run = async () => { - try { - const response = await client.listWorkspaces(); - if (!active) return; - const items = Array.isArray(response.items) ? response.items : []; - const activeMatch = response.activeId ? items.find((item) => item.id === response.activeId) : null; - const match = root ? items.find((item) => normalizeDirectoryPath(item.path) === root) : activeMatch ?? items[0]; - setDevtoolsWorkspaceId(match?.id ?? activeMatch?.id ?? null); - } catch { - if (active) setDevtoolsWorkspaceId(null); - } - }; - - run(); - const interval = window.setInterval(run, 20_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!developerMode()) { - setOpenworkAuditEntries([]); - setOpenworkAuditStatus("idle"); - setOpenworkAuditError(null); - return; - } - if (!documentVisible()) return; - - const client = devtoolsOpenworkClient(); - const workspaceId = devtoolsWorkspaceId(); - if (!client || !workspaceId) { - setOpenworkAuditEntries([]); - setOpenworkAuditStatus("idle"); - setOpenworkAuditError(null); - return; - } - - let active = true; - let busy = false; - - const run = async () => { - if (busy) return; - busy = true; - setOpenworkAuditStatus("loading"); - setOpenworkAuditError(null); - try { - const result = await client.listAudit(workspaceId, 50); - if (!active) return; - setOpenworkAuditEntries(Array.isArray(result.items) ? result.items : []); - setOpenworkAuditStatus("idle"); - } catch (error) { - if (!active) return; - setOpenworkAuditEntries([]); - setOpenworkAuditStatus("error"); - setOpenworkAuditError(error instanceof Error ? error.message : "Failed to load audit log."); - } finally { - busy = false; - } - }; - - run(); - const interval = window.setInterval(run, 15_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - const active = workspaceStore.activeWorkspaceDisplay(); + const active = workspaceStore.selectedWorkspaceDisplay(); if (active.workspaceType !== "remote" || active.remoteType !== "openwork") { return; } @@ -4095,677 +1138,10 @@ export default function App() { }); }); - const openworkServerReady = createMemo(() => openworkServerStatus() === "connected"); - const openworkServerWorkspaceReady = createMemo(() => Boolean(openworkServerWorkspaceId())); - const resolvedOpenworkCapabilities = createMemo(() => openworkServerCapabilities()); - const openworkServerCanWriteSkills = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.skills?.write ?? false), - ); - const openworkServerCanWritePlugins = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.plugins?.write ?? false), - ); - const openworkServerCanReadConfig = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.config?.read ?? false), - ); - const openworkServerCanWriteConfig = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.config?.write ?? false), - ); - const devtoolsCapabilities = createMemo(() => openworkServerCapabilities()); - - function updateOpenworkServerSettings(next: OpenworkServerSettings) { - const stored = writeOpenworkServerSettings(next); - setOpenworkServerSettings(stored); - } - - const saveShareRemoteAccess = async (enabled: boolean) => { - if (shareRemoteAccessBusy()) return; - const previous = openworkServerSettings(); - const next: OpenworkServerSettings = { - ...previous, - remoteAccessEnabled: enabled, - }; - - setShareRemoteAccessBusy(true); - setShareRemoteAccessError(null); - updateOpenworkServerSettings(next); - - try { - if (isTauriRuntime() && workspaceStore.activeWorkspaceDisplay().workspaceType === "local") { - const restarted = await restartLocalServer(); - if (!restarted) { - throw new Error("Failed to restart the local worker with the updated sharing setting."); - } - await reconnectOpenworkServer(); - } - } catch (error) { - updateOpenworkServerSettings(previous); - setShareRemoteAccessError( - error instanceof Error - ? error.message - : "Failed to update remote access.", - ); - return; - } finally { - setShareRemoteAccessBusy(false); - } - }; - - const resetOpenworkServerSettings = () => { - clearOpenworkServerSettings(); - setOpenworkServerSettings({}); - }; - - const [editRemoteWorkspaceOpen, setEditRemoteWorkspaceOpen] = createSignal(false); - const [editRemoteWorkspaceId, setEditRemoteWorkspaceId] = createSignal(null); - const [editRemoteWorkspaceError, setEditRemoteWorkspaceError] = createSignal(null); - const [deepLinkRemoteWorkspaceDefaults, setDeepLinkRemoteWorkspaceDefaults] = createSignal(null); - const [pendingRemoteConnectDeepLink, setPendingRemoteConnectDeepLink] = createSignal(null); - const [autoConnectRemoteWorkspaceOverlayOpen, setAutoConnectRemoteWorkspaceOverlayOpen] = createSignal(false); - const [pendingDenAuthDeepLink, setPendingDenAuthDeepLink] = createSignal(null); - const [processingDenAuthDeepLink, setProcessingDenAuthDeepLink] = createSignal(false); - const [pendingSharedBundleInvite, setPendingSharedBundleInvite] = createSignal(null); - const [sharedBundleCreateWorkerRequest, setSharedBundleCreateWorkerRequest] = - createSignal(null); - const [sharedSkillDestinationRequest, setSharedSkillDestinationRequest] = - createSignal(null); - const [sharedSkillDestinationBusyId, setSharedSkillDestinationBusyId] = createSignal(null); - const [sharedBundleImportChoice, setSharedBundleImportChoice] = createSignal(null); - const [sharedBundleImportBusy, setSharedBundleImportBusy] = createSignal(false); - const [sharedBundleImportError, setSharedBundleImportError] = createSignal(null); - const [sharedBundleNoticeShown, setSharedBundleNoticeShown] = createSignal(false); - const [sharedSkillSuccessToast, setSharedSkillSuccessToast] = createSignal(null); - const recentClaimedDeepLinks = new Map(); - const [renameWorkspaceOpen, setRenameWorkspaceOpen] = createSignal(false); - const [renameWorkspaceId, setRenameWorkspaceId] = createSignal(null); - const [renameWorkspaceName, setRenameWorkspaceName] = createSignal(""); - const [renameWorkspaceBusy, setRenameWorkspaceBusy] = createSignal(false); - let sharedSkillSuccessToastTimer: number | null = null; - - const clearSharedSkillSuccessToast = () => { - if (sharedSkillSuccessToastTimer) { - window.clearTimeout(sharedSkillSuccessToastTimer); - sharedSkillSuccessToastTimer = null; - } - setSharedSkillSuccessToast(null); - }; - - const showSharedSkillSuccessToast = (toast: SharedSkillSuccessToast) => { - if (sharedSkillSuccessToastTimer) { - window.clearTimeout(sharedSkillSuccessToastTimer); - } - setSharedSkillSuccessToast(toast); - sharedSkillSuccessToastTimer = window.setTimeout(() => { - sharedSkillSuccessToastTimer = null; - setSharedSkillSuccessToast(null); - }, 4200); - }; - - onCleanup(() => { - if (sharedSkillSuccessToastTimer) { - window.clearTimeout(sharedSkillSuccessToastTimer); - } - }); - - const createWorkspaceDefaultPreset = createMemo(() => - sharedBundleCreateWorkerRequest()?.defaultPreset ?? "starter" - ); - - const sharedSkillDestinationWorkspaces = createMemo(() => { - const activeId = workspaceStore.activeWorkspaceId(); - return workspaceStore - .workspaces() - .filter((workspace) => isSharedBundleImportWorkspace(workspace)) - .slice() - .sort((a, b) => { - if (a.id === activeId && b.id !== activeId) return -1; - if (b.id === activeId && a.id !== activeId) return 1; - const aLabel = - a.displayName?.trim() || - a.openworkWorkspaceName?.trim() || - a.name?.trim() || - a.directory?.trim() || - a.path?.trim() || - a.baseUrl?.trim() || - ""; - const bLabel = - b.displayName?.trim() || - b.openworkWorkspaceName?.trim() || - b.name?.trim() || - b.directory?.trim() || - b.path?.trim() || - b.baseUrl?.trim() || - ""; - return aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" }); - }); - }); - - const describeWorkspaceForToasts = (workspace: WorkspaceDisplay | WorkspaceInfo | null) => - workspace?.displayName?.trim() || - workspace?.openworkWorkspaceName?.trim() || - workspace?.name?.trim() || - workspace?.directory?.trim() || - workspace?.path?.trim() || - workspace?.baseUrl?.trim() || - "the selected worker"; - - const queueRemoteConnectDeepLink = (rawUrl: string): boolean => { - const parsed = parseRemoteConnectDeepLink(rawUrl); - if (!parsed) { - return false; - } - setPendingRemoteConnectDeepLink(parsed); - return true; - }; - - const completeRemoteConnectDeepLink = async (pending: RemoteWorkspaceDefaults) => { - const input = { - openworkHostUrl: pending.openworkHostUrl, - openworkToken: pending.openworkToken, - directory: pending.directory, - displayName: pending.displayName, - }; - - if (!pending.autoConnect) { - setDeepLinkRemoteWorkspaceDefaults(input); - workspaceStore.setCreateRemoteWorkspaceOpen(true); - return; - } - - setError(null); - setAutoConnectRemoteWorkspaceOverlayOpen(true); - try { - const ok = await workspaceStore.createRemoteWorkspaceFlow(input); - if (ok) { - setDeepLinkRemoteWorkspaceDefaults(null); - return; - } - - setDeepLinkRemoteWorkspaceDefaults(input); - workspaceStore.setCreateRemoteWorkspaceOpen(true); - } finally { - setAutoConnectRemoteWorkspaceOverlayOpen(false); - } - }; - - const queueDenAuthDeepLink = (rawUrl: string): boolean => { - const parsed = parseDenAuthDeepLink(rawUrl); - if (!parsed) { - return false; - } - setPendingDenAuthDeepLink(parsed); - return true; - }; - - const queueSharedBundleDeepLink = (rawUrl: string): boolean => { - const parsed = parseSharedBundleDeepLink(rawUrl); - if (!parsed) { - return false; - } - setPendingSharedBundleInvite(parsed); - setSharedSkillDestinationRequest(null); - setSharedSkillDestinationBusyId(null); - setSharedBundleImportChoice(null); - setSharedBundleCreateWorkerRequest(null); - setSharedBundleImportError(null); - setSharedBundleNoticeShown(false); - return true; - }; - - const stripHandledBrowserDeepLink = (rawUrl: string) => { - if (typeof window === "undefined" || isTauriRuntime()) { - return; - } - - if (window.location.href !== rawUrl) { - return; - } - - const remoteStripped = stripRemoteConnectQuery(rawUrl) ?? rawUrl; - const bundleStripped = stripSharedBundleQuery(remoteStripped) ?? remoteStripped; - if (bundleStripped !== rawUrl) { - window.history.replaceState({}, "", bundleStripped); - } - }; - - const consumeDeepLinks = (urls: readonly string[] | null | undefined) => { - if (!Array.isArray(urls)) { - return; - } - - const normalized = urls.map((url) => url.trim()).filter(Boolean); - if (normalized.length === 0) { - return; - } - - const now = Date.now(); - for (const [url, seenAt] of recentClaimedDeepLinks) { - if (now - seenAt > 1500) { - recentClaimedDeepLinks.delete(url); - } - } - - for (const url of normalized) { - const seenAt = recentClaimedDeepLinks.get(url) ?? 0; - if (now - seenAt < 1500) { - continue; - } - - const matchedDen = queueDenAuthDeepLink(url); - const matchedRemote = !matchedDen && queueRemoteConnectDeepLink(url); - const matchedBundle = !matchedDen && !matchedRemote && queueSharedBundleDeepLink(url); - const claimed = matchedDen || matchedRemote || matchedBundle; - if (!claimed) { - continue; - } - - recentClaimedDeepLinks.set(url, now); - stripHandledBrowserDeepLink(url); - break; - } - }; - - const openDebugDeepLink = async (rawUrl: string): Promise<{ ok: boolean; message: string }> => { - const parsed = parseDebugDeepLinkInput(rawUrl); - if (!parsed) { - return { ok: false, message: "That link is not a recognized OpenWork deep link or share URL." }; - } - - setError(null); - setView("dashboard"); - if (parsed.kind === "bundle") { - setPendingSharedBundleInvite(null); - setSharedBundleNoticeShown(false); - setSharedSkillDestinationRequest(null); - setSharedSkillDestinationBusyId(null); - setSharedBundleImportError(null); - setSharedBundleImportChoice(null); - setSharedBundleCreateWorkerRequest(null); - - try { - setSharedBundleImportBusy(true); - const result = await processSharedBundleInvite(parsed.link); - switch (result.mode) { - case "choice": - return { ok: true, message: "Opened the share import chooser." }; - case "new_worker_modal": - return { ok: true, message: "Opened the new worker import flow." }; - case "blocked_import_current": - case "blocked_new_worker": - return { ok: false, message: error() || "The share link needs more worker setup before it can open." }; - case "imported": - return { ok: true, message: "Imported the shared bundle into the current worker." }; - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - setError(friendly); - return { ok: false, message: friendly }; - } finally { - setSharedBundleImportBusy(false); - } - } - if (parsed.kind === "auth") { - setPendingDenAuthDeepLink(parsed.link); - return { ok: true, message: "Queued the Cloud auth deep link for OpenWork." }; - } - - setPendingRemoteConnectDeepLink(parsed.kind === "remote" ? parsed.link : null); - setTab("scheduled"); - return { ok: true, message: "Queued remote worker link. OpenWork should move into the connect flow." }; - }; - - const closeSharedBundleImportChoice = () => { - if (sharedBundleImportBusy()) return; - setSharedBundleImportChoice(null); - setSharedBundleImportError(null); - }; - - const sharedBundleImportCopy = createMemo(() => { - const choice = sharedBundleImportChoice(); - if (!choice) return null; - return describeSharedBundleImport(choice.bundle); - }); - - const sharedBundleWorkerOptions = createMemo(() => { - const activeWorkspaceId = workspaceStore.activeWorkspaceId().trim(); - const items = workspaceStore.workspaces().map((workspace) => { - let disabledReason: string | null = null; - if (!resolveSharedBundleImportTargetForWorkspace(workspace)) { - disabledReason = - workspace.workspaceType === "remote" && workspace.remoteType !== "openwork" - ? "Only OpenWork-connected workers support direct shared skill imports." - : "This worker is missing the info OpenWork needs to import the bundle."; - } - - const label = - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - workspace.path?.trim() || - "Worker"; - const badge = - workspace.workspaceType === "remote" - ? workspace.sandboxBackend === "docker" || - Boolean(workspace.sandboxRunId?.trim()) || - Boolean(workspace.sandboxContainerName?.trim()) - ? "Sandbox" - : "Remote" - : "Local"; - const detail = - workspace.workspaceType === "local" - ? workspace.path?.trim() || "Local worker" - : workspace.directory?.trim() || workspace.baseUrl?.trim() || workspace.openworkHostUrl?.trim() || "Remote worker"; - - return { - id: workspace.id, - label, - detail, - badge, - current: workspace.id === activeWorkspaceId, - disabledReason, - }; - }); - - return items.sort((a, b) => { - if (a.current !== b.current) return a.current ? -1 : 1; - return a.label.localeCompare(b.label); - }); - }); - - const openSharedBundleCreateWorkerFlow = async () => { - const choice = sharedBundleImportChoice(); - if (!choice || sharedBundleImportBusy()) return; - - setSharedBundleImportError(null); - setError(null); - - if (isTauriRuntime()) { - setView("dashboard"); - setTab("scheduled"); - setSharedBundleCreateWorkerRequest({ - request: choice.request, - bundle: choice.bundle, - defaultPreset: "starter", - }); - setSharedBundleImportChoice(null); - workspaceStore.setCreateWorkspaceOpen(true); - return; - } - - setSharedBundleImportBusy(true); - try { - await createWorkerForSharedBundle(choice.request, choice.bundle); - await importSharedBundlePayload(choice.bundle, resolveActiveSharedBundleImportTarget()); - setSharedBundleImportChoice(null); - setError(null); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - setSharedBundleImportError(friendly); - setError(friendly); - } finally { - setSharedBundleImportBusy(false); - } - }; - - const importSharedBundleIntoExistingWorkspace = async (workspaceId: string) => { - const choice = sharedBundleImportChoice(); - if (!choice || sharedBundleImportBusy()) return; - - const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace) { - setSharedBundleImportError("The selected worker is no longer available."); - return; - } - - const target = resolveSharedBundleImportTargetForWorkspace(workspace); - if (!target) { - setSharedBundleImportError("This worker cannot accept shared skill imports yet."); - return; - } - - setSharedBundleImportBusy(true); - setSharedBundleImportError(null); - setError(null); - - try { - setView("dashboard"); - setTab("skills"); - const ok = await workspaceStore.activateWorkspace(workspace.id); - if (!ok) { - throw new Error(error() || `Failed to switch to ${workspace.displayName?.trim() || workspace.name || "the selected worker"}.`); - } - await importSharedBundlePayload(choice.bundle, target); - setSharedBundleImportChoice(null); - setError(null); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - setSharedBundleImportError(friendly); - setError(friendly); - } finally { - setSharedBundleImportBusy(false); - } - }; - - createEffect(() => { - const pending = pendingDenAuthDeepLink(); - if (!pending || booting() || processingDenAuthDeepLink()) { - return; - } - - setProcessingDenAuthDeepLink(true); - setPendingDenAuthDeepLink(null); - setView("dashboard"); - setSettingsTab("den"); - goToDashboard("settings"); - - void createDenClient({ baseUrl: pending.denBaseUrl }) - .exchangeDesktopHandoff(pending.grant) - .then((result) => { - if (!result.token) { - throw new Error("Desktop sign-in completed, but Den did not return a session token."); - } - - writeDenSettings({ - baseUrl: pending.denBaseUrl, - authToken: result.token, - activeOrgId: null, - }); - - window.dispatchEvent( - new CustomEvent("openwork-den-session-updated", { - detail: { - status: "success", - email: result.user?.email ?? null, - }, - }), - ); - }) - .catch((error) => { - window.dispatchEvent( - new CustomEvent("openwork-den-session-updated", { - detail: { - status: "error", - message: error instanceof Error ? error.message : "Failed to complete OpenWork Den sign-in.", - }, - }), - ); - }) - .finally(() => { - setProcessingDenAuthDeepLink(false); - }); - }); - - createEffect(() => { - const pending = pendingRemoteConnectDeepLink(); - if (!pending || booting()) { - return; - } - - if (pending.autoConnect) { - setView("session"); - } else { - setView("dashboard"); - setTab("scheduled"); - } - setPendingRemoteConnectDeepLink(null); - void completeRemoteConnectDeepLink(pending); - }); - - createEffect(() => { - if (workspaceStore.createRemoteWorkspaceOpen()) { - return; - } - if (!deepLinkRemoteWorkspaceDefaults()) { - return; - } - setDeepLinkRemoteWorkspaceDefaults(null); - }); - - const editRemoteWorkspaceDefaults = createMemo(() => { - const workspaceId = editRemoteWorkspaceId(); - if (!workspaceId) return null; - const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace || workspace.workspaceType !== "remote") return null; - return { - openworkHostUrl: workspace.openworkHostUrl ?? workspace.baseUrl ?? "", - openworkToken: workspace.openworkToken ?? openworkServerSettings().token ?? "", - directory: workspace.directory ?? "", - displayName: workspace.displayName ?? "", - }; - }); - - const openRenameWorkspace = (workspaceId: string) => { - const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace) return; - setRenameWorkspaceId(workspaceId); - setRenameWorkspaceName( - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - "" - ); - setRenameWorkspaceOpen(true); - }; - - const closeRenameWorkspace = () => { - if (renameWorkspaceBusy()) return; - setRenameWorkspaceOpen(false); - setRenameWorkspaceId(null); - setRenameWorkspaceName(""); - }; - - const saveRenameWorkspace = async () => { - const workspaceId = renameWorkspaceId(); - if (!workspaceId) return; - const nextName = renameWorkspaceName().trim(); - if (!nextName) return; - if (renameWorkspaceBusy()) return; - - setRenameWorkspaceBusy(true); - setError(null); - try { - const ok = await workspaceStore.updateWorkspaceDisplayName(workspaceId, nextName); - if (!ok) return; - setRenameWorkspaceOpen(false); - setRenameWorkspaceId(null); - setRenameWorkspaceName(""); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - setError(addOpencodeCacheHint(message)); - } finally { - setRenameWorkspaceBusy(false); - } - }; - - const testOpenworkServerConnection = async (next: OpenworkServerSettings) => { - const derived = normalizeOpenworkServerUrl(next.urlOverride ?? ""); - if (!derived) { - setOpenworkServerStatus("disconnected"); - setOpenworkServerCapabilities(null); - setOpenworkServerCheckedAt(Date.now()); - return false; - } - const result = await checkOpenworkServer(derived, next.token, openworkServerAuth().hostToken); - setOpenworkServerStatus(result.status); - setOpenworkServerCapabilities(result.capabilities); - setOpenworkServerCheckedAt(Date.now()); - const ok = result.status === "connected" || result.status === "limited"; - if (ok && !isTauriRuntime()) { - const active = workspaceStore.activeWorkspaceDisplay(); - const shouldAttach = !client() || active.workspaceType !== "remote" || active.remoteType !== "openwork"; - if (shouldAttach) { - await workspaceStore - .createRemoteWorkspaceFlow({ - openworkHostUrl: derived, - openworkToken: next.token ?? null, - }) - .catch(() => undefined); - } - } - return ok; - }; - - const reconnectOpenworkServer = async () => { - if (openworkReconnectBusy()) return false; - setOpenworkReconnectBusy(true); - try { - let hostInfo = openworkServerHostInfo(); - if (isTauriRuntime()) { - try { - hostInfo = await openworkServerInfo(); - setOpenworkServerHostInfo(hostInfo); - } catch { - hostInfo = null; - setOpenworkServerHostInfo(null); - } - } - - // Repair stale local token state by syncing settings token from the live host. - if (hostInfo?.clientToken?.trim() && startupPreference() !== "server") { - const liveToken = hostInfo.clientToken.trim(); - const settings = openworkServerSettings(); - if ((settings.token?.trim() ?? "") !== liveToken) { - updateOpenworkServerSettings({ ...settings, token: liveToken }); - } - } - - const url = openworkServerBaseUrl().trim(); - const auth = openworkServerAuth(); - if (!url) { - setOpenworkServerStatus("disconnected"); - setOpenworkServerCapabilities(null); - setOpenworkServerCheckedAt(Date.now()); - return false; - } - - const result = await checkOpenworkServer(url, auth.token, auth.hostToken); - setOpenworkServerStatus(result.status); - setOpenworkServerCapabilities(result.capabilities); - setOpenworkServerCheckedAt(Date.now()); - return result.status === "connected" || result.status === "limited"; - } finally { - setOpenworkReconnectBusy(false); - } - }; - - const restartLocalServer = async () => { - const activeWorkspace = workspaceStore.activeWorkspaceDisplay(); + async function restartLocalServer() { + const activeWorkspace = workspaceStore.selectedWorkspaceDisplay(); const activeLocalPath = - activeWorkspace.workspaceType === "local" ? workspaceStore.activeWorkspacePath().trim() : ""; + activeWorkspace.workspaceType === "local" ? workspaceStore.selectedWorkspacePath().trim() : ""; const runningProjectDir = workspaceStore.engine()?.projectDir?.trim() ?? ""; const workspacePath = activeLocalPath || runningProjectDir; @@ -4775,33 +1151,15 @@ export default function App() { } return workspaceStore.startHost({ workspacePath, navigate: false }); - }; - - const openWorkspaceConnectionSettings = (workspaceId: string) => { - const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (workspace?.workspaceType === "remote" && workspace.remoteType === "openwork") { - setEditRemoteWorkspaceId(workspace.id); - setEditRemoteWorkspaceError(null); - setEditRemoteWorkspaceOpen(true); - return; - } - if (workspace?.workspaceType === "remote") { - setEditRemoteWorkspaceId(workspace.id); - setEditRemoteWorkspaceError(null); - setEditRemoteWorkspaceOpen(true); - return; - } - setTab("config"); - setView("dashboard"); - }; + } const canReloadLocalEngine = () => - isTauriRuntime() && workspaceStore.activeWorkspaceDisplay().workspaceType === "local"; + isTauriRuntime() && workspaceStore.selectedWorkspaceDisplay().workspaceType === "local"; const canReloadWorkspace = createMemo(() => { if (canReloadLocalEngine()) return true; - if (workspaceStore.activeWorkspaceDisplay().workspaceType !== "remote") return false; - return openworkServerStatus() === "connected" && Boolean(openworkServerClient() && openworkServerWorkspaceId()); + if (workspaceStore.selectedWorkspaceDisplay().workspaceType !== "remote") return false; + return openworkServerStatus() === "connected" && Boolean(openworkServerClient() && runtimeWorkspaceId()); }); const reloadWorkspaceEngineFromUi = async () => { @@ -4809,12 +1167,12 @@ export default function App() { return workspaceStore.reloadWorkspaceEngine(); } - if (workspaceStore.activeWorkspaceDisplay().workspaceType !== "remote") { + if (workspaceStore.selectedWorkspaceDisplay().workspaceType !== "remote") { return false; } const client = openworkServerClient(); - const workspaceId = openworkServerWorkspaceId(); + const workspaceId = runtimeWorkspaceId(); if (!client || !workspaceId || openworkServerStatus() !== "connected") { setError("Connect to this worker before applying runtime changes."); return false; @@ -4822,7 +1180,7 @@ export default function App() { try { await client.reloadEngine(workspaceId); - await workspaceStore.activateWorkspace(workspaceStore.activeWorkspaceId()); + await workspaceStore.activateWorkspace(workspaceStore.selectedWorkspaceId()); await refreshMcpServers(); return true; } catch (error) { @@ -4845,18 +1203,10 @@ export default function App() { setProviderDefaults, setProviderConnectedIds, setError, - notion: { - status: notionStatus, - setStatus: setNotionStatus, - statusDetail: notionStatusDetail, - setStatusDetail: setNotionStatusDetail, - skillInstalled: notionSkillInstalled, - setTryPromptVisible: setTryNotionPromptVisible, - }, }); const { - reloadRequired, + reloadPending, reloadCopy, reloadTrigger, reloadBusy, @@ -4876,7 +1226,6 @@ export default function App() { updateStatus, setUpdateStatus, pendingUpdate, - setPendingUpdate, updateEnv, setUpdateEnv, checkForUpdates, @@ -4885,7 +1234,6 @@ export default function App() { resetModalOpen, setResetModalOpen, resetModalMode, - setResetModalMode, resetModalText, setResetModalText, resetModalBusy, @@ -4901,36 +1249,13 @@ export default function App() { const resetAppConfigDefaults = async () => { try { - if (typeof window !== "undefined") { - try { - const sessionOverridePrefix = `${SESSION_MODEL_PREF_KEY}.`; - const keysToRemove: string[] = []; - for (let index = 0; index < window.localStorage.length; index += 1) { - const key = window.localStorage.key(index); - if (!key) continue; - if (key.startsWith(sessionOverridePrefix)) { - keysToRemove.push(key); - } - } - for (const key of keysToRemove) { - window.localStorage.removeItem(key); - } - } catch { - // ignore - } - } - setThemeMode("system"); setEngineSource(isTauriRuntime() ? "sidecar" : "path"); setEngineCustomBinPath(""); setEngineRuntime("openwork-orchestrator"); - setDefaultModel(DEFAULT_MODEL); - setLegacyDefaultModel(DEFAULT_MODEL); - setDefaultModelExplicit(false); - setShowThinking(false); + modelConfig.resetAppDefaults(); + resetSessionDisplayPreferences(); setHideTitlebar(false); - setAutoCompactContext(false); - setModelVariant(null); setUpdateAutoCheck(true); setUpdateAutoDownload(false); setUpdateStatus({ state: "idle", lastCheckedAt: null }); @@ -4940,14 +1265,7 @@ export default function App() { setStartupPreference(null); setRememberStartupChoice(false); - clearOpenworkServerSettings(); - setOpenworkServerSettings(readOpenworkServerSettings()); - - setNotionStatus("disconnected"); - setNotionStatusDetail(null); - setNotionError(null); - setNotionSkillInstalled(false); - setTryNotionPromptVisible(false); + resetOpenworkServerSettings(); return { ok: true, message: "Reset app config defaults. Restart OpenWork if any stale settings remain." }; } catch (error) { @@ -5002,10 +1320,25 @@ export default function App() { await reloadWorkspaceEngine(); }; + const isActiveSessionStatus = (status: string | null | undefined) => + status === "running" || status === "retry"; + + const reloadRequired = (...sources: ReloadTrigger["type"][]) => { + if (!reloadPending()) return false; + const triggerType = reloadTrigger()?.type; + if (!triggerType) return false; + if (!sources.length) return true; + return sources.includes(triggerType); + }; + + const markOpencodeConfigReloadRequired = () => { + markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); + }; + const activeReloadBlockingSessions = createMemo(() => { const statuses = sessionStatusById(); return sessions() - .filter((session) => statuses[session.id] === "running") + .filter((session) => isActiveSessionStatus(statuses[session.id])) .map((session) => ({ id: session.id, title: session.title?.trim() || session.slug?.trim() || session.id, @@ -5030,160 +1363,93 @@ export default function App() { }); const { - engine, - engineDoctorResult, - engineDoctorCheckedAt, - engineInstallLogs, projectDir: workspaceProjectDir, - newAuthorizedDir, - refreshEngineDoctor, stopHost, - setEngineInstallLogs, } = workspaceStore; - // Scheduler helpers - must be defined after workspaceStore - const resolveOpenworkScheduler = () => { - const client = openworkServerClient(); - const workspaceId = openworkServerWorkspaceId(); - if (openworkServerStatus() !== "connected" || !client || !workspaceId) return null; - return { client, workspaceId }; - }; - - const scheduledJobsSource = createMemo<"local" | "remote">(() => { - return resolveOpenworkScheduler() ? "remote" : "local"; - }); - - const scheduledJobsSourceReady = createMemo(() => { - if (scheduledJobsSource() !== "remote") return true; - const client = openworkServerClient(); - const workspaceId = openworkServerWorkspaceId(); - return openworkServerStatus() === "connected" && Boolean(client && workspaceId); - }); - const schedulerPluginInstalled = createMemo(() => isPluginInstalledByName("opencode-scheduler")); - const refreshScheduledJobs = async (options?: { force?: boolean }) => { - if (scheduledJobsBusy() && !options?.force) return; + const automationsStore = createAutomationsStore({ + selectedWorkspaceId: () => workspaceStore.selectedWorkspaceId(), + selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), + runtimeWorkspaceId, + openworkServer: openworkServerStore, + schedulerPluginInstalled, + }); - if (scheduledJobsSource() === "remote") { - const scheduler = resolveOpenworkScheduler(); - if (!scheduler) { - setScheduledJobs([]); - const status = - openworkServerStatus() === "disconnected" - ? "OpenWork server unavailable. Connect to sync scheduled tasks." - : openworkServerStatus() === "limited" - ? "OpenWork server needs a token to load scheduled tasks." - : "OpenWork server not ready."; - setScheduledJobsStatus(status); - return; - } - - setScheduledJobsBusy(true); - setScheduledJobsStatus(null); - - try { - const response = await scheduler.client.listScheduledJobs(scheduler.workspaceId); - const jobs = Array.isArray(response.items) ? response.items : []; - setScheduledJobs(jobs); - setScheduledJobsUpdatedAt(Date.now()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setScheduledJobs([]); - setScheduledJobsStatus(message || "Failed to load scheduled tasks."); - } finally { - setScheduledJobsBusy(false); - } - return; - } - - if (!isTauriRuntime()) { - setScheduledJobs([]); - setScheduledJobsStatus(null); - return; - } - - if (isWindowsPlatform()) { - setScheduledJobs([]); - setScheduledJobsStatus(null); - return; - } - - if (!schedulerPluginInstalled()) { - setScheduledJobs([]); - setScheduledJobsStatus(null); - return; - } - - setScheduledJobsBusy(true); - setScheduledJobsStatus(null); - - try { - const root = workspaceStore.activeWorkspaceRoot().trim(); - const jobs = await schedulerListJobs(root || undefined); - setScheduledJobs(jobs); - setScheduledJobsUpdatedAt(Date.now()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setScheduledJobs([]); - setScheduledJobsStatus(message || "Failed to load scheduled tasks."); - } finally { - setScheduledJobsBusy(false); - } - }; - - const deleteScheduledJob = async (name: string) => { - if (scheduledJobsSource() === "remote") { - const scheduler = resolveOpenworkScheduler(); - if (!scheduler) { - throw new Error("OpenWork server unavailable. Connect to sync scheduled tasks."); - } - const response = await scheduler.client.deleteScheduledJob(scheduler.workspaceId, name); - setScheduledJobs((current) => current.filter((entry) => entry.slug !== response.job.slug)); - return; - } - - if (!isTauriRuntime()) { - throw new Error("Scheduled tasks require the desktop app."); - } - if (isWindowsPlatform()) { - throw new Error("Scheduler is not supported on Windows yet."); - } - const root = workspaceStore.activeWorkspaceRoot().trim(); - const job = await schedulerDeleteJob(name, root || undefined); - setScheduledJobs((current) => current.filter((entry) => entry.slug !== job.slug)); - return; -}; + const { + scheduledJobsPollingAvailable, + refreshScheduledJobs, + } = automationsStore; createEffect(() => { - if (!isTauriRuntime()) return; - workspaceStore.activeWorkspaceId(); - workspaceProjectDir(); - void refreshMcpServers(); + if (typeof window === "undefined") return; + if (currentView() !== "settings") return; + if (settingsTab() !== "automations") return; + if (!documentVisible()) return; + + const pollingAvailable = scheduledJobsPollingAvailable(); + const startedAt = Date.now(); + let active = true; + let failureCount = 0; + let timeoutId: number | undefined; + + const clearTimer = () => { + if (timeoutId == null) return; + window.clearTimeout(timeoutId); + timeoutId = undefined; + }; + + const nextDelayMs = () => { + const baseDelay = Date.now() - startedAt < 60_000 ? 5_000 : 15_000; + if (failureCount <= 0) return baseDelay; + return Math.min(baseDelay * 2 ** failureCount, 60_000); + }; + + const scheduleNext = () => { + clearTimer(); + if (!active || !pollingAvailable) return; + timeoutId = window.setTimeout(() => { + void run("poll"); + }, nextDelayMs()); + }; + + const run = async (_reason: "initial" | "focus" | "poll") => { + if (!active) return; + const result = await refreshScheduledJobs(); + if (!active) return; + + if (result === "error") { + failureCount += 1; + } else if (result === "success" || result === "unavailable") { + failureCount = 0; + } + + scheduleNext(); + }; + + const handleFocus = () => { + clearTimer(); + void run("focus"); + }; + + void run("initial"); + window.addEventListener("focus", handleFocus); + + onCleanup(() => { + active = false; + clearTimer(); + window.removeEventListener("focus", handleFocus); + }); }); const activeAuthorizedDirs = createMemo(() => workspaceStore.authorizedDirs()); - const activeWorkspaceDisplay = createMemo(() => workspaceStore.activeWorkspaceDisplay()); + const selectedWorkspaceDisplay = createMemo(() => workspaceStore.selectedWorkspaceDisplay()); const resolvedActiveWorkspaceConfig = createMemo( () => activeWorkspaceServerConfig() ?? workspaceStore.workspaceConfig(), ); + const refreshActiveWorkspaceServerConfig = workspaceStore.refreshRuntimeWorkspaceConfig; const activePermissionMemo = createMemo(() => activePermission()); - const migrationRepairUnavailableReason = createMemo(() => { - if (workspaceStore.canRepairOpencodeMigration()) return null; - if (!isTauriRuntime()) { - return t("app.migration.desktop_required", currentLocale()); - } - - if (activeWorkspaceDisplay().workspaceType !== "local") { - return t("app.migration.local_only", currentLocale()); - } - - if (!workspaceStore.activeWorkspacePath().trim()) { - return t("app.migration.workspace_required", currentLocale()); - } - - return t("app.migration.local_only", currentLocale()); - }); const [expandedStepIds, setExpandedStepIds] = createSignal>( new Set() @@ -5199,57 +1465,6 @@ export default function App() { }); const [autoConnectAttempted, setAutoConnectAttempted] = createSignal(false); - createEffect(() => { - const workspace = activeWorkspaceDisplay(); - const openworkClient = openworkServerClient(); - const workspaceId = openworkServerWorkspaceId(); - const capabilities = resolvedOpenworkCapabilities(); - const canReadConfig = - openworkServerStatus() === "connected" && - Boolean(openworkClient && workspaceId && capabilities?.config?.read); - - if (!canReadConfig || !openworkClient || !workspaceId) { - setActiveWorkspaceServerConfig(null); - return; - } - - let cancelled = false; - - const loadWorkspaceConfig = async () => { - try { - const config = await openworkClient.getConfig(workspaceId); - if (cancelled) return; - - const normalized = normalizeWorkspaceOpenworkConfig( - config.openwork, - workspace.preset, - ); - - if (!normalized.blueprint) { - setActiveWorkspaceServerConfig({ - ...normalized, - blueprint: buildDefaultWorkspaceBlueprint( - normalized.workspace?.preset ?? workspace.preset ?? "starter", - ), - }); - return; - } - - setActiveWorkspaceServerConfig(normalized); - } catch { - if (!cancelled) { - setActiveWorkspaceServerConfig(null); - } - } - }; - - void loadWorkspaceConfig(); - - onCleanup(() => { - cancelled = true; - }); - }); - const [appVersion, setAppVersion] = createSignal(null); const [launchUpdateCheckTriggered, setLaunchUpdateCheckTriggered] = createSignal(false); @@ -5295,1046 +1510,9 @@ export default function App() { void workspaceStore.onConnectClient(); }); - const selectedSessionModel = createMemo(() => { - const id = selectedSessionId(); - if (!id) return pendingSessionModel() ?? defaultModel(); - - const override = sessionModelOverrideById()[id]; - if (override) return override; - - const known = sessionModelById()[id]; - if (known) return known; - - const fromMessages = lastUserModelFromMessages(messages()); - if (fromMessages) return fromMessages; - - return defaultModel(); - }); - - const selectedSessionAgent = createMemo(() => { - const id = selectedSessionId(); - if (!id) return null; - return sessionAgentById()[id] ?? null; - }); - - const selectedSessionModelLabel = createMemo(() => - formatModelLabel(selectedSessionModel(), providers()) - ); - - const findProviderModel = (ref: ModelRef) => { - const provider = providers().find((entry) => entry.id === ref.providerID); - return provider?.models?.[ref.modelID] ?? null; - }; - - const sanitizeModelVariantForRef = (ref: ModelRef, value: string | null) => { - const modelInfo = findProviderModel(ref); - if (!modelInfo) return normalizeModelBehaviorValue(value); - return sanitizeModelBehaviorValue(ref.providerID, modelInfo, value); - }; - - const getModelBehaviorCopy = (ref: ModelRef, value: string | null) => { - const modelInfo = findProviderModel(ref); - if (!modelInfo) { - return { - title: "Model behavior", - label: formatGenericBehaviorLabel(value), - description: "Choose the model first to see provider-specific behavior controls.", - options: [], - }; - } - return getModelBehaviorSummary(ref.providerID, modelInfo, value); - }; - - const modelPickerCurrent = createMemo(() => - modelPickerTarget() === "default" ? defaultModel() : selectedSessionModel() - ); - - const isHeroModel = (id: string) => { - const check = id.toLowerCase(); - if (check.includes("gpt-5")) return true; - if (check.includes("opus-4")) return true; - if (check.includes("claude-3-7-sonnet")) return true; - if (check.includes("claude-3-5-sonnet")) return true; - if (check.includes("gpt-4o") && !check.includes("mini") && !check.includes("audio")) return true; - if (check.includes("o3-mini")) return true; - if (check.includes("o1") && !check.includes("mini")) return true; - if (check.includes("deepseek-r1")) return true; - return false; - }; - - const modelOptions = createMemo(() => { - const allProviders = providers(); - const defaults = providerDefaults(); - const currentDefault = defaultModel(); - - if (!allProviders.length) { - const behavior = getModelBehaviorCopy(DEFAULT_MODEL, getVariantFor(DEFAULT_MODEL)); - return [ - { - providerID: DEFAULT_MODEL.providerID, - modelID: DEFAULT_MODEL.modelID, - title: DEFAULT_MODEL.modelID, - description: DEFAULT_MODEL.providerID, - footer: t("settings.model_fallback", currentLocale()), - behaviorTitle: behavior.title, - behaviorLabel: behavior.label, - behaviorDescription: behavior.description, - behaviorValue: normalizeModelBehaviorValue(getVariantFor(DEFAULT_MODEL)), - behaviorOptions: behavior.options, - isFree: true, - isConnected: false, - }, - ]; - } - - const sortedProviders = allProviders.slice().sort(compareProviders); - - const next: ModelOption[] = []; - - for (const provider of sortedProviders) { - const defaultModelID = defaults[provider.id]; - const isConnected = providerConnectedIds().includes(provider.id); - const models = Object.values(provider.models ?? {}).filter( - (m) => m.status !== "deprecated" - ); - - models.sort((a, b) => { - const aFree = a.cost?.input === 0 && a.cost?.output === 0; - const bFree = b.cost?.input === 0 && b.cost?.output === 0; - if (aFree !== bFree) return aFree ? -1 : 1; - return (a.name ?? a.id).localeCompare(b.name ?? b.id); - }); - - for (const model of models) { - const isFree = model.cost?.input === 0 && model.cost?.output === 0; - const isDefault = - provider.id === currentDefault.providerID && model.id === currentDefault.modelID; - const ref = { providerID: provider.id, modelID: model.id }; - const behavior = getModelBehaviorSummary(provider.id, model, getVariantFor(ref)); - const behaviorValue = sanitizeModelBehaviorValue(provider.id, model, getVariantFor(ref)); - const footerBits: string[] = []; - if (defaultModelID === model.id || isDefault) { - footerBits.push(t("settings.model_default", currentLocale())); - } - if (model.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale())); - - next.push({ - providerID: provider.id, - modelID: model.id, - title: model.name ?? model.id, - description: provider.name, - footer: footerBits.length - ? footerBits.slice(0, 2).join(" · ") - : undefined, - behaviorTitle: behavior.title, - behaviorLabel: behavior.label, - behaviorDescription: behavior.description, - behaviorValue, - behaviorOptions: behavior.options, - disabled: !isConnected, - isFree, - isConnected, - isRecommended: isHeroModel(model.id), - }); - } - } - - next.sort((a, b) => { - if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; - if (a.isFree !== b.isFree) return a.isFree ? -1 : 1; - const providerRankDiff = - providerPriorityRank(a.providerID) - providerPriorityRank(b.providerID); - if (providerRankDiff !== 0) return providerRankDiff; - return a.title.localeCompare(b.title); - }); - - return next; - }); - - const filteredModelOptions = createMemo(() => { - const q = modelPickerQuery().trim().toLowerCase(); - const options = modelOptions(); - if (!q) return options; - - return options.filter((opt) => { - const haystack = [ - opt.title, - opt.description ?? "", - opt.footer ?? "", - opt.behaviorTitle, - opt.behaviorLabel, - opt.behaviorDescription, - `${opt.providerID}/${opt.modelID}`, - opt.isConnected ? "connected" : "disconnected", - opt.isFree ? "free" : "paid", - ] - .join(" ") - .toLowerCase(); - return haystack.includes(q); - }); - }); - - function closeModelPicker(options?: { restorePromptFocus?: boolean }) { - const shouldFocusPrompt = - options?.restorePromptFocus ?? - modelPickerReturnFocusTarget() === "composer"; - setModelPickerOpen(false); - setModelPickerReturnFocusTarget("none"); - if (shouldFocusPrompt) { - focusSessionPromptSoon(); - } - } - - function openSessionModelPicker(options?: { - returnFocusTarget?: PromptFocusReturnTarget; - }) { - setModelPickerTarget("session"); - setModelPickerQuery(""); - setModelPickerReturnFocusTarget(options?.returnFocusTarget ?? "composer"); - setModelPickerOpen(true); - } - - function openDefaultModelPicker() { - setModelPickerTarget("default"); - setModelPickerQuery(""); - setModelPickerReturnFocusTarget("none"); - setModelPickerOpen(true); - } - - function applyModelSelection(next: ModelRef) { - const restorePromptFocus = modelPickerTarget() === "session"; - - if (modelPickerTarget() === "default") { - setDefaultModelExplicit(true); - setDefaultModel(next); - closeModelPicker({ restorePromptFocus: false }); - return; - } - - const id = selectedSessionId(); - if (!id) { - setPendingSessionModel(next); - setDefaultModelExplicit(true); - setDefaultModel(next); - closeModelPicker({ restorePromptFocus }); - return; - } - - setSessionModelOverrideById((current) => ({ ...current, [id]: next })); - setDefaultModelExplicit(true); - setDefaultModel(next); - closeModelPicker({ restorePromptFocus }); - } - function openSettingsFromModelPicker() { - setTab("settings"); - setView("dashboard"); - } - - async function connectNotion() { - if (workspaceStore.activeWorkspaceDisplay().workspaceType !== "local") { - setNotionError("Notion connections are only available for local workspaces."); - return; - } - - const projectDir = workspaceProjectDir().trim(); - if (!projectDir) { - setNotionError("Pick a workspace folder first."); - return; - } - - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const openworkCapabilities = resolvedOpenworkCapabilities(); - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.mcp?.write; - - if (!canUseOpenworkServer && !isTauriRuntime()) { - setNotionError("Notion connections require the desktop app."); - return; - } - - if (notionBusy()) return; - - setNotionBusy(true); - setNotionError(null); - setNotionStatus("connecting"); - setNotionStatusDetail(t("mcp.connecting", currentLocale())); - setNotionSkillInstalled(false); - - try { - if (canUseOpenworkServer) { - await openworkClient.addMcp(openworkWorkspaceId, { - name: "notion", - config: { - type: "remote", - url: "https://mcp.notion.com/mcp", - enabled: true, - }, - }); - } else { - const config = await readOpencodeConfig("project", projectDir); - const raw = config.content ?? ""; - const nextConfig = raw.trim() - ? (parse(raw) as Record) - : { $schema: "https://opencode.ai/config.json" }; - - const mcp = typeof nextConfig.mcp === "object" && nextConfig.mcp - ? { ...(nextConfig.mcp as Record) } - : {}; - mcp.notion = { - type: "remote", - url: "https://mcp.notion.com/mcp", - enabled: true, - }; - - nextConfig.mcp = mcp; - const formatted = JSON.stringify(nextConfig, null, 2); - - const result = await writeOpencodeConfig("project", projectDir, `${formatted}\n`); - if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); - } - } - - await refreshMcpServers(); - setNotionStatusDetail(t("mcp.connecting", currentLocale())); - try { - window.localStorage.setItem("openwork.notionStatus", "connecting"); - window.localStorage.setItem("openwork.notionStatusDetail", t("mcp.connecting", currentLocale())); - window.localStorage.setItem("openwork.notionSkillInstalled", "0"); - } catch { - // ignore - } - } catch (e) { - setNotionStatus("error"); - setNotionError(e instanceof Error ? e.message : "Failed to connect Notion."); - } finally { - setNotionBusy(false); - } - } - - async function refreshMcpServers() { - const filterConfiguredStatuses = (status: McpStatusMap, entries: McpServerEntry[]) => { - const configured = new Set(entries.map((entry) => entry.name)); - return Object.fromEntries(Object.entries(status).filter(([name]) => configured.has(name))) as McpStatusMap; - }; - - const projectDir = workspaceProjectDir().trim(); - const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote"; - const isLocalWorkspace = !isRemoteWorkspace; - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const openworkCapabilities = resolvedOpenworkCapabilities(); - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.mcp?.read; - - if (isRemoteWorkspace) { - if (!canUseOpenworkServer) { - setMcpStatus("OpenWork server unavailable. MCP config is read-only."); - setMcpServers([]); - setMcpStatuses({}); - return; - } - - try { - setMcpStatus(null); - const response = await openworkClient.listMcp(openworkWorkspaceId); - const next = response.items.map((entry) => ({ - name: entry.name, - config: entry.config as McpServerEntry["config"], - })); - setMcpServers(next); - setMcpLastUpdatedAt(Date.now()); - - const activeClient = client(); - if (activeClient && projectDir) { - try { - const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); - setMcpStatuses(filterConfiguredStatuses(status as McpStatusMap, next)); - } catch { - setMcpStatuses({}); - } - } else { - setMcpStatuses({}); - } - - if (!next.length) { - setMcpStatus("No MCP servers configured yet."); - } - } catch (e) { - setMcpServers([]); - setMcpStatuses({}); - setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers"); - } - return; - } - - if (isLocalWorkspace && canUseOpenworkServer) { - try { - setMcpStatus(null); - const response = await openworkClient.listMcp(openworkWorkspaceId); - const next = response.items.map((entry) => ({ - name: entry.name, - config: entry.config as McpServerEntry["config"], - })); - setMcpServers(next); - setMcpLastUpdatedAt(Date.now()); - - const activeClient = client(); - if (activeClient && projectDir) { - try { - const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); - setMcpStatuses(filterConfiguredStatuses(status as McpStatusMap, next)); - } catch { - setMcpStatuses({}); - } - } else { - setMcpStatuses({}); - } - - if (!next.length) { - setMcpStatus("No MCP servers configured yet."); - } - } catch (e) { - setMcpServers([]); - setMcpStatuses({}); - setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers"); - } - return; - } - - if (!isTauriRuntime()) { - setMcpStatus("MCP configuration is only available for local workspaces."); - setMcpServers([]); - setMcpStatuses({}); - return; - } - - if (!projectDir) { - setMcpStatus("Pick a workspace folder to load MCP servers."); - setMcpServers([]); - setMcpStatuses({}); - return; - } - - try { - setMcpStatus(null); - const config = await readOpencodeConfig("project", projectDir); - if (!config.exists || !config.content) { - setMcpServers([]); - setMcpStatuses({}); - setMcpStatus("No opencode.json found yet. Create one by connecting an MCP."); - return; - } - - const next = parseMcpServersFromContent(config.content); - setMcpServers(next); - setMcpLastUpdatedAt(Date.now()); - - const activeClient = client(); - if (activeClient) { - try { - const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); - setMcpStatuses(filterConfiguredStatuses(status as McpStatusMap, next)); - } catch { - setMcpStatuses({}); - } - } - - if (!next.length) { - setMcpStatus("No MCP servers configured yet."); - } - } catch (e) { - setMcpServers([]); - setMcpStatuses({}); - setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers"); - } - } - - const readMcpConfigFile = async (scope: "project" | "global") => { - const projectDir = workspaceProjectDir().trim(); - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - resolvedOpenworkCapabilities()?.config?.read; - - if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { - return openworkClient.readOpencodeConfigFile(openworkWorkspaceId, scope); - } - if (!isTauriRuntime()) { - return null; - } - return readOpencodeConfig(scope, projectDir); - }; - - async function connectMcp(entry: (typeof MCP_QUICK_CONNECT)[number]) { - const startedAt = perfNow(); - const isRemoteWorkspace = - workspaceStore.activeWorkspaceDisplay().workspaceType === "remote" || - (!isTauriRuntime() && openworkServerStatus() === "connected"); - const projectDir = workspaceProjectDir().trim(); - const entryType = entry.type ?? "remote"; - - recordPerfLog(developerMode(), "mcp.connect", "start", { - name: entry.name, - type: entryType, - workspaceType: isRemoteWorkspace ? "remote" : "local", - projectDir: projectDir || null, - }); - - const openworkClient = openworkServerClient(); - let openworkWorkspaceId = openworkServerWorkspaceId(); - const openworkCapabilities = resolvedOpenworkCapabilities(); - if (!openworkWorkspaceId && openworkClient && openworkServerStatus() === "connected") { - try { - const response = await openworkClient.listWorkspaces(); - const match = response.items?.[0]; - if (match?.id) { - openworkWorkspaceId = match.id; - setOpenworkServerWorkspaceId(match.id); - } - } catch { - // ignore - } - } - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.mcp?.write; - - if (isRemoteWorkspace && !canUseOpenworkServer) { - setMcpStatus("OpenWork server unavailable. MCP config is read-only."); - finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { - reason: "openwork-server-unavailable", - }); - return; - } - - if (!canUseOpenworkServer && !isTauriRuntime()) { - setMcpStatus(t("mcp.desktop_required", currentLocale())); - finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { - reason: "desktop-required", - }); - return; - } - - if (!isRemoteWorkspace && !projectDir) { - setMcpStatus(t("mcp.pick_workspace_first", currentLocale())); - finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { - reason: "missing-workspace", - }); - return; - } - - let activeClient = client(); - if (!activeClient) { - const openworkBaseUrl = openworkServerBaseUrl().trim(); - const auth = openworkServerAuth(); - if (openworkBaseUrl && auth.token) { - const opencodeUrl = `${openworkBaseUrl.replace(/\/+$/, "")}/opencode`; - activeClient = createClient(opencodeUrl, undefined, { token: auth.token, mode: "openwork" }); - setClient(activeClient); - } - } - if (!activeClient) { - setMcpStatus(t("mcp.connect_server_first", currentLocale())); - finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { - reason: "no-active-client", - }); - return; - } - - let resolvedProjectDir = projectDir; - if (!resolvedProjectDir) { - try { - const pathInfo = unwrap(await activeClient.path.get()); - const discoveredRaw = normalizeDirectoryQueryPath(pathInfo.directory ?? ""); - const discovered = discoveredRaw.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); - if (discovered) { - resolvedProjectDir = discovered; - workspaceStore.setProjectDir(discovered); - } - } catch { - // ignore - } - } - if (!resolvedProjectDir) { - setMcpStatus(t("mcp.pick_workspace_first", currentLocale())); - finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { - reason: "missing-workspace-after-discovery", - }); - return; - } - - const slug = entry.id ?? entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - - const action = mcpServers().some((server) => server.name === slug) ? "updated" : "added"; - - try { - setMcpStatus(null); - setMcpConnectingName(entry.name); - - let mcpEnvironment: Record | undefined; - - const mcpEntryConfig: Record = { - type: entryType, - enabled: true, - }; - - if (entryType === "remote") { - if (!entry.url) { - throw new Error("Missing MCP URL."); - } - mcpEntryConfig["url"] = entry.url; - if (entry.oauth) { - mcpEntryConfig["oauth"] = {}; - } - } - - if (entryType === "local") { - if (!entry.command?.length) { - throw new Error("Missing MCP command."); - } - mcpEntryConfig["command"] = entry.command; - - if (slug === CHROME_DEVTOOLS_MCP_ID && usesChromeDevtoolsAutoConnect(entry.command) && isTauriRuntime()) { - try { - const hostHome = (await homeDir()).replace(/[\\/]+$/, ""); - if (hostHome) { - mcpEnvironment = { HOME: hostHome }; - mcpEntryConfig["environment"] = mcpEnvironment; - } - } catch { - // ignore and let the MCP use the default worker environment - } - } - } - - if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { - await openworkClient.addMcp(openworkWorkspaceId, { - name: slug, - config: mcpEntryConfig, - }); - } else { - const configFile = await readOpencodeConfig("project", resolvedProjectDir); - - let existingConfig: Record = {}; - if (configFile.exists && configFile.content?.trim()) { - try { - existingConfig = parse(configFile.content) ?? {}; - } catch (parseErr) { - recordPerfLog(developerMode(), "mcp.connect", "config-parse-failed", { - error: parseErr instanceof Error ? parseErr.message : String(parseErr), - }); - existingConfig = {}; - } - } - - if (!existingConfig["$schema"]) { - existingConfig["$schema"] = "https://opencode.ai/config.json"; - } - - const mcpSection = (existingConfig["mcp"] as Record) ?? {}; - existingConfig["mcp"] = mcpSection; - mcpSection[slug] = mcpEntryConfig; - - const writeResult = await writeOpencodeConfig( - "project", - resolvedProjectDir, - `${JSON.stringify(existingConfig, null, 2)}\n` - ); - if (!writeResult.ok) { - throw new Error(writeResult.stderr || writeResult.stdout || "Failed to write opencode.json"); - } - } - - const mcpAddConfig = - entryType === "remote" - ? { - type: "remote" as const, - url: entry.url!, - enabled: true, - ...(entry.oauth ? { oauth: {} } : {}), - } - : { - type: "local" as const, - command: entry.command!, - enabled: true, - ...(mcpEnvironment ? { environment: mcpEnvironment } : {}), - }; - - const status = unwrap( - await activeClient.mcp.add({ - directory: resolvedProjectDir, - name: slug, - config: mcpAddConfig, - }), - ); - - setMcpStatuses(status as McpStatusMap); - markReloadRequired("mcp", { type: "mcp", name: slug, action }); - await refreshMcpServers(); - - if (entry.oauth) { - setMcpAuthEntry(entry); - setMcpAuthNeedsReload(true); - setMcpAuthModalOpen(true); - } else { - setMcpStatus(t("mcp.connected", currentLocale())); - } - - await refreshMcpServers(); - finishPerf(developerMode(), "mcp.connect", "done", startedAt, { - name: entry.name, - type: entryType, - slug, - }); - } catch (e) { - setMcpStatus(e instanceof Error ? e.message : t("mcp.connect_failed", currentLocale())); - finishPerf(developerMode(), "mcp.connect", "error", startedAt, { - name: entry.name, - type: entryType, - error: e instanceof Error ? e.message : safeStringify(e), - }); - } finally { - setMcpConnectingName(null); - } - } - - function authorizeMcp(entry: McpServerEntry) { - if (entry.config.type !== "remote" || entry.config.oauth === false) { - setMcpStatus(t("mcp.login_unavailable", currentLocale())); - return; - } - - const matchingQuickConnect = MCP_QUICK_CONNECT.find((candidate) => { - const candidateSlug = candidate.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - return candidateSlug === entry.name || candidate.name === entry.name; - }); - - setMcpAuthEntry( - matchingQuickConnect ?? { - name: entry.name, - description: "", - type: "remote", - url: entry.config.url, - oauth: true, - }, - ); - setMcpAuthNeedsReload(false); - setMcpAuthModalOpen(true); - } - - async function logoutMcpAuth(name: string) { - const isRemoteWorkspace = - workspaceStore.activeWorkspaceDisplay().workspaceType === "remote" || - (!isTauriRuntime() && openworkServerStatus() === "connected"); - const projectDir = workspaceProjectDir().trim(); - - const openworkClient = openworkServerClient(); - let openworkWorkspaceId = openworkServerWorkspaceId(); - const openworkCapabilities = resolvedOpenworkCapabilities(); - if (!openworkWorkspaceId && openworkClient && openworkServerStatus() === "connected") { - try { - const response = await openworkClient.listWorkspaces(); - const match = response.items?.[0]; - if (match?.id) { - openworkWorkspaceId = match.id; - setOpenworkServerWorkspaceId(match.id); - } - } catch { - // ignore - } - } - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.mcp?.write; - - if (isRemoteWorkspace && !canUseOpenworkServer) { - setMcpStatus("OpenWork server unavailable. MCP auth is read-only."); - return; - } - - if (!canUseOpenworkServer && !isTauriRuntime()) { - setMcpStatus(t("mcp.desktop_required", currentLocale())); - return; - } - - let activeClient = client(); - if (!activeClient) { - const openworkBaseUrl = openworkServerBaseUrl().trim(); - const auth = openworkServerAuth(); - if (openworkBaseUrl && auth.token) { - const opencodeUrl = `${openworkBaseUrl.replace(/\/+$/, "")}/opencode`; - activeClient = createClient(opencodeUrl, undefined, { token: auth.token, mode: "openwork" }); - setClient(activeClient); - } - } - if (!activeClient) { - setMcpStatus(t("mcp.connect_server_first", currentLocale())); - return; - } - - let resolvedProjectDir = projectDir; - if (!resolvedProjectDir) { - try { - const pathInfo = unwrap(await activeClient.path.get()); - const discoveredRaw = normalizeDirectoryQueryPath(pathInfo.directory ?? ""); - const discovered = discoveredRaw.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); - if (discovered) { - resolvedProjectDir = discovered; - workspaceStore.setProjectDir(discovered); - } - } catch { - // ignore - } - } - if (!resolvedProjectDir) { - setMcpStatus(t("mcp.pick_workspace_first", currentLocale())); - return; - } - - const safeName = validateMcpServerName(name); - setMcpStatus(null); - - try { - if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { - await openworkClient.logoutMcpAuth(openworkWorkspaceId, safeName); - } else { - try { - await activeClient.mcp.disconnect({ directory: resolvedProjectDir, name: safeName }); - } catch { - // ignore - } - await activeClient.mcp.auth.remove({ directory: resolvedProjectDir, name: safeName }); - } - - try { - const status = unwrap(await activeClient.mcp.status({ directory: resolvedProjectDir })); - setMcpStatuses(status as McpStatusMap); - } catch { - // ignore - } - - await refreshMcpServers(); - setMcpStatus(t("mcp.logout_success", currentLocale()).replace("{server}", safeName)); - } catch (e) { - setMcpStatus(e instanceof Error ? e.message : t("mcp.logout_failed", currentLocale())); - } - } - - async function removeMcp(name: string) { - try { - setMcpStatus(null); - - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - resolvedOpenworkCapabilities()?.mcp?.write; - - if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { - await openworkClient.removeMcp(openworkWorkspaceId, name); - } else { - const projectDir = workspaceProjectDir().trim(); - if (!projectDir) { - setMcpStatus(t("mcp.pick_workspace_first", currentLocale())); - return; - } - await removeMcpFromConfig(projectDir, name); - } - - markReloadRequired("mcp", { type: "mcp", name, action: "removed" }); - await refreshMcpServers(); - if (selectedMcp() === name) { - setSelectedMcp(null); - } - setMcpStatus(null); - } catch (e) { - setMcpStatus(e instanceof Error ? e.message : t("mcp.remove_failed", currentLocale())); - } - } - - async function createSessionAndOpen() { - const c = client(); - if (!c) { - return; - } - - const perfEnabled = developerMode(); - const startedAt = perfNow(); - const runId = (() => { - const key = "__openwork_create_session_run__"; - const w = window as typeof window & { [key]?: number }; - w[key] = (w[key] ?? 0) + 1; - return w[key]; - })(); - - const mark = (event: string, payload?: Record) => { - const elapsed = Math.round((perfNow() - startedAt) * 100) / 100; - recordPerfLog(perfEnabled, "session.create", event, { - runId, - elapsedMs: elapsed, - ...(payload ?? {}), - }); - }; - - mark("start", { - baseUrl: baseUrl(), - workspace: workspaceStore.activeWorkspaceRoot().trim() || null, - }); - - // Abort any in-flight refresh operations to free up connection resources - abortRefreshes(); - - // Small delay to allow pending requests to settle - await new Promise((resolve) => setTimeout(resolve, 50)); - - setBusy(true); - setBusyLabel("status.creating_task"); - setBusyStartedAt(Date.now()); - setError(null); - setCreatingSession(true); - - const withTimeout = async ( - promise: Promise, - ms: number, - label: string - ) => { - let timeoutId: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error(`Timed out waiting for ${label}`)), - ms - ); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - }; - - try { - // Quick health check to detect stale connection - mark("health:start"); - try { - await withTimeout(c.global.health(), 3_000, "health"); - mark("health:ok"); - } catch (healthErr) { - mark("health:error", { - error: healthErr instanceof Error ? healthErr.message : safeStringify(healthErr), - }); - throw new Error(t("app.connection_lost", currentLocale())); - } - - let rawResult: Awaited>; - try { - const directory = toSessionTransportDirectory(workspaceStore.activeWorkspaceRoot().trim()) || undefined; - logWorkspaceScopeSnapshot("session:create:scope", { - transportDirectory: directory ?? null, - transportScope: describeDirectoryScope(directory ?? null), - }); - mark("session:create:start"); - rawResult = await c.session.create({ - directory, - }); - mark("session:create:ok"); - } catch (createErr) { - mark("session:create:error", { - error: createErr instanceof Error ? createErr.message : safeStringify(createErr), - }); - throw createErr; - } - - const session = unwrap(rawResult); - const pendingModel = pendingSessionModel(); - // Immediately select and show the new session before background list refresh. - setBusyLabel("status.loading_session"); - mark("session:select:start", { sessionID: session.id }); - await selectSession(session.id); - mark("session:select:ok", { sessionID: session.id }); - - if (pendingModel) { - setSessionModelOverrideById((current) => ({ - ...current, - [session.id]: pendingModel, - })); - setPendingSessionModel(null); - } - - // Inject the new session into the reactive sessions() store so - // the createEffect bridge (sessions → sidebar) will always include it, - // even if the background loadSessionsWithReady hasn't returned yet. - const currentStoreSessions = sessions(); - if (!currentStoreSessions.some((s) => s.id === session.id)) { - setSessions([session, ...currentStoreSessions]); - } - - const newItem: SidebarSessionItem = { - id: session.id, - title: session.title, - slug: session.slug, - parentID: session.parentID, - time: session.time, - directory: session.directory, - }; - const wsId = workspaceStore.activeWorkspaceId().trim(); - if (wsId) { - const currentSessions = sidebarSessionsByWorkspaceId()[wsId] || []; - setSidebarSessionsByWorkspaceId((prev) => ({ - ...prev, - [wsId]: [newItem, ...currentSessions], - })); - setSidebarSessionStatusByWorkspaceId((prev) => ({ - ...prev, - [wsId]: "ready", - })); - } - - // setSessionViewLockUntil(Date.now() + 1200); - goToSession(session.id); - - // The new session is already in the sessions() store (injected above) - // and in the sidebar signal. SSE session.created events will handle - // any further syncing. Calling loadSessionsWithReady() here would - // race with the store injection — the server may not have indexed the - // session yet, so reconcile() would wipe it from the store, causing - // the sidebar to flash and the route guard to bounce back. - finishPerf(perfEnabled, "session.create", "done", startedAt, { - runId, - sessionID: session.id, - }); - return session.id; - } catch (e) { - finishPerf(perfEnabled, "session.create", "error", startedAt, { - runId, - error: e instanceof Error ? e.message : safeStringify(e), - }); - const message = e instanceof Error ? e.message : t("app.unknown_error", currentLocale()); - setError(addOpencodeCacheHint(message)); - return undefined; - } finally { - setCreatingSession(false); - setBusy(false); - } + setSettingsTab("general"); + setView("settings"); } @@ -6415,36 +1593,6 @@ export default function App() { setOpencodeEnableExa(storedOpencodeEnableExa === "1"); } - const storedDefaultModel = window.localStorage.getItem(MODEL_PREF_KEY); - const parsedDefaultModel = parseModelRef(storedDefaultModel); - if (parsedDefaultModel) { - setDefaultModel(parsedDefaultModel); - setLegacyDefaultModel(parsedDefaultModel); - } else { - setDefaultModel(DEFAULT_MODEL); - setLegacyDefaultModel(DEFAULT_MODEL); - try { - window.localStorage.setItem( - MODEL_PREF_KEY, - formatModelRef(DEFAULT_MODEL) - ); - } catch { - // ignore - } - } - - const storedThinking = window.localStorage.getItem(THINKING_PREF_KEY); - if (storedThinking != null) { - try { - const parsed = JSON.parse(storedThinking); - if (typeof parsed === "boolean") { - setShowThinking(parsed); - } - } catch { - // ignore - } - } - const storedHideTitlebar = window.localStorage.getItem(HIDE_TITLEBAR_PREF_KEY); if (storedHideTitlebar != null) { try { @@ -6457,32 +1605,6 @@ export default function App() { } } - const storedAutoCompactContext = window.localStorage.getItem(AUTO_COMPACT_CONTEXT_PREF_KEY); - if (storedAutoCompactContext != null) { - try { - const parsed = JSON.parse(storedAutoCompactContext); - if (typeof parsed === "boolean") { - setAutoCompactContext(parsed); - } - } catch { - // ignore - } - } - - const storedVariant = window.localStorage.getItem(VARIANT_PREF_KEY); - if (storedVariant && storedVariant.trim()) { - try { - const parsed = JSON.parse(storedVariant); - if (typeof parsed === "object" && parsed !== null) { - setModelVariantMap(parsed); - } else { - setModelVariantMap({ [`${DEFAULT_MODEL.providerID}/${DEFAULT_MODEL.modelID}`]: normalizeModelBehaviorValue(storedVariant)! }); - } - } catch { - setModelVariantMap({ [`${DEFAULT_MODEL.providerID}/${DEFAULT_MODEL.modelID}`]: normalizeModelBehaviorValue(storedVariant)! }); - } - } - const storedUpdateAutoCheck = window.localStorage.getItem( "openwork.updateAutoCheck" ); @@ -6511,29 +1633,7 @@ export default function App() { } } - const storedNotionStatus = window.localStorage.getItem("openwork.notionStatus"); - if ( - storedNotionStatus === "disconnected" || - storedNotionStatus === "connected" || - storedNotionStatus === "connecting" || - storedNotionStatus === "error" - ) { - setNotionStatus(storedNotionStatus); - } - - const storedNotionDetail = window.localStorage.getItem("openwork.notionStatusDetail"); - if (storedNotionDetail) { - setNotionStatusDetail(storedNotionDetail); - } else if (storedNotionStatus === "connecting") { - setNotionStatusDetail(t("mcp.connecting", currentLocale())); - } - await refreshMcpServers(); - - const storedNotionSkillInstalled = window.localStorage.getItem("openwork.notionSkillInstalled"); - if (storedNotionSkillInstalled === "1") { - setNotionSkillInstalled(true); - } } catch { // ignore } @@ -6559,13 +1659,13 @@ export default function App() { } if (typeof window !== "undefined") { - const handleDeepLinkEvent = (event: Event) => { - const detail = (event as CustomEvent).detail; - consumeDeepLinks(detail?.urls ?? []); - }; + const handleDeepLinkEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + deepLinks.consumeDeepLinks(detail?.urls ?? []); + }; - consumeDeepLinks(drainPendingDeepLinks(window)); - window.addEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); + deepLinks.consumeDeepLinks(drainPendingDeepLinks(window)); + window.addEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); onCleanup(() => { window.removeEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); }); @@ -6574,330 +1674,6 @@ export default function App() { void workspaceStore.bootstrapOnboarding().finally(() => setBooting(false)); }); - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = workspaceStore.activeWorkspaceId(); - if (!workspaceId) return; - - setSessionModelOverridesReady(false); - const raw = window.localStorage.getItem(sessionModelOverridesKey(workspaceId)); - setSessionModelOverrideById(parseSessionModelOverrides(raw)); - setSessionModelOverridesReady(true); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - const projectDir = workspaceProjectDir().trim(); - if (!projectDir) return; - void refreshMcpServers(); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!sessionModelOverridesReady()) return; - const workspaceId = workspaceStore.activeWorkspaceId(); - if (!workspaceId) return; - - const payload = serializeSessionModelOverrides(sessionModelOverrideById()); - try { - if (payload) { - window.localStorage.setItem(sessionModelOverridesKey(workspaceId), payload); - } else { - window.localStorage.removeItem(sessionModelOverridesKey(workspaceId)); - } - } catch { - // ignore - } - }); - - createEffect(() => { - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const canReadConfig = openworkServerCanReadConfig(); - - if (!openworkClient || !openworkWorkspaceId || !canReadConfig) { - setAuthorizedFolders([]); - setAuthorizedFolderDraft(""); - setAuthorizedFolderHiddenEntries({}); - setAuthorizedFoldersLoading(false); - setAuthorizedFoldersStatus(null); - setAuthorizedFoldersError(null); - return; - } - - let cancelled = false; - setAuthorizedFolderDraft(""); - setAuthorizedFoldersLoading(true); - setAuthorizedFoldersError(null); - setAuthorizedFoldersStatus(null); - - const loadAuthorizedFolders = async () => { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - if (cancelled) return; - const next = readAuthorizedFoldersFromConfig(ensureRecord(config.opencode)); - setAuthorizedFolders(next.folders); - setAuthorizedFolderHiddenEntries(next.hiddenEntries); - setAuthorizedFoldersStatus( - buildAuthorizedFoldersStatus(Object.keys(next.hiddenEntries).length), - ); - } catch (error) { - if (cancelled) return; - const message = error instanceof Error ? error.message : safeStringify(error); - setAuthorizedFolders([]); - setAuthorizedFolderHiddenEntries({}); - setAuthorizedFoldersError(message); - } finally { - if (!cancelled) { - setAuthorizedFoldersLoading(false); - } - } - }; - - void loadAuthorizedFolders(); - - onCleanup(() => { - cancelled = true; - }); - }); - - const persistAuthorizedFolders = async (nextFolders: string[]) => { - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - if (!openworkClient || !openworkWorkspaceId || !openworkServerCanWriteConfig()) { - setAuthorizedFoldersError( - "A writable OpenWork server workspace is required to update authorized folders.", - ); - return false; - } - - setAuthorizedFoldersSaving(true); - setAuthorizedFoldersError(null); - setAuthorizedFoldersStatus("Saving authorized folders..."); - - try { - const currentConfig = await openworkClient.getConfig(openworkWorkspaceId); - const currentAuthorizedFolders = readAuthorizedFoldersFromConfig( - ensureRecord(currentConfig.opencode), - ); - const nextExternalDirectory = mergeAuthorizedFoldersIntoExternalDirectory( - nextFolders, - currentAuthorizedFolders.hiddenEntries, - ); - - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { - permission: { - external_directory: nextExternalDirectory, - }, - }, - }); - setAuthorizedFolders(nextFolders); - setAuthorizedFolderHiddenEntries(currentAuthorizedFolders.hiddenEntries); - setAuthorizedFoldersStatus( - buildAuthorizedFoldersStatus( - Object.keys(currentAuthorizedFolders.hiddenEntries).length, - "Authorized folders updated.", - ), - ); - markReloadRequired("config", { - type: "config", - name: "opencode.json", - action: "updated", - }); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - setAuthorizedFoldersError(message); - setAuthorizedFoldersStatus(null); - return false; - } finally { - setAuthorizedFoldersSaving(false); - } - }; - - const addAuthorizedFolder = async () => { - const normalized = normalizeAuthorizedFolderPath(authorizedFolderDraft()); - if (!normalized) return; - if (authorizedFolders().includes(normalized)) { - setAuthorizedFolderDraft(""); - setAuthorizedFoldersStatus("Folder is already authorized."); - setAuthorizedFoldersError(null); - return; - } - - const ok = await persistAuthorizedFolders([...authorizedFolders(), normalized]); - if (ok) { - setAuthorizedFolderDraft(""); - } - }; - - const removeAuthorizedFolder = async (folder: string) => { - const nextFolders = authorizedFolders().filter((entry) => entry !== folder); - await persistAuthorizedFolders(nextFolders); - }; - - const pickAuthorizedFolder = async () => { - if (!isTauriRuntime()) return; - try { - const selection = await pickDirectory({ title: t("onboarding.authorize_folder", currentLocale()) }); - const folder = typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - const normalized = normalizeAuthorizedFolderPath(folder); - if (!normalized) return; - setAuthorizedFolderDraft(normalized); - if (authorizedFolders().includes(normalized)) { - setAuthorizedFoldersStatus("Folder is already authorized."); - setAuthorizedFoldersError(null); - return; - } - const ok = await persistAuthorizedFolders([...authorizedFolders(), normalized]); - if (ok) { - setAuthorizedFolderDraft(""); - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - setAuthorizedFoldersError(message); - } - }; - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = workspaceStore.activeWorkspaceId(); - if (!workspaceId) return; - - setWorkspaceDefaultModelReady(false); - const workspaceType = workspaceStore.activeWorkspaceDisplay().workspaceType; - const workspaceRoot = workspaceStore.activeWorkspacePath().trim(); - const activeClient = client(); - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const openworkCapabilities = resolvedOpenworkCapabilities(); - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.read; - - let cancelled = false; - - const applyDefault = async () => { - let configDefault: ModelRef | null = null; - let configFileContent: string | null = null; - - if (workspaceType === "local" && workspaceRoot) { - if (canUseOpenworkServer) { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const model = typeof config.opencode?.model === "string" ? config.opencode.model : null; - configDefault = parseModelRef(model); - } catch { - // ignore - } - } else if (isTauriRuntime()) { - try { - const configFile = await readOpencodeConfig("project", workspaceRoot); - configFileContent = configFile.content; - configDefault = parseDefaultModelFromConfig(configFile.content); - } catch { - // ignore - } - } - } else if (activeClient) { - try { - const config = unwrap( - await activeClient.config.get({ directory: workspaceRoot || undefined }) - ); - if (typeof config.model === "string") { - configDefault = parseModelRef(config.model); - } - } catch { - // ignore - } - } - - setDefaultModelExplicit(Boolean(configDefault)); - const nextDefault = configDefault ?? legacyDefaultModel(); - const currentDefault = untrack(defaultModel); - if (nextDefault && !modelEquals(currentDefault, nextDefault)) { - setDefaultModel(nextDefault); - } - - if (workspaceType === "local" && workspaceRoot) { - setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); - } - - if (!cancelled) { - setWorkspaceDefaultModelReady(true); - } - }; - - void applyDefault(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - if (!workspaceDefaultModelReady()) return; - if (!isTauriRuntime()) return; - if (!defaultModelExplicit()) return; - - const workspace = workspaceStore.activeWorkspaceDisplay(); - if (workspace.workspaceType !== "local") return; - - const root = workspaceStore.activeWorkspacePath().trim(); - if (!root) return; - const nextModel = defaultModel(); - const openworkClient = openworkServerClient(); - const openworkWorkspaceId = openworkServerWorkspaceId(); - const openworkCapabilities = resolvedOpenworkCapabilities(); - const canUseOpenworkServer = - openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.write; - let cancelled = false; - - const writeConfig = async () => { - try { - if (canUseOpenworkServer) { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const currentModel = typeof config.opencode?.model === "string" ? parseModelRef(config.opencode.model) : null; - if (currentModel && modelEquals(currentModel, nextModel)) return; - - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { model: formatModelRef(nextModel) }, - }); - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); - return; - } - - const configFile = await readOpencodeConfig("project", root); - const existingModel = parseDefaultModelFromConfig(configFile.content); - if (existingModel && modelEquals(existingModel, nextModel)) return; - - const content = formatConfigWithDefaultModel(configFile.content, nextModel); - const result = await writeOpencodeConfig("project", root, content); - if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); - } - setLastKnownConfigSnapshot(getConfigSnapshot(content)); - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); - } catch (error) { - if (cancelled) return; - const message = error instanceof Error ? error.message : safeStringify(error); - setError(addOpencodeCacheHint(message)); - } - }; - - void writeConfig(); - - onCleanup(() => { - cancelled = true; - }); - }); - createEffect(() => { if (!isTauriRuntime()) return; if (onboardingStep() !== "local") return; @@ -6979,18 +1755,6 @@ export default function App() { } }); - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - MODEL_PREF_KEY, - formatModelRef(defaultModel()) - ); - } catch { - // ignore - } - }); - createEffect(() => { if (typeof window === "undefined") return; try { @@ -7015,18 +1779,6 @@ export default function App() { } }); - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - THINKING_PREF_KEY, - JSON.stringify(showThinking()) - ); - } catch { - // ignore - } - }); - // Persist and apply hideTitlebar setting createEffect(() => { if (typeof window === "undefined") return; @@ -7044,29 +1796,6 @@ export default function App() { } }); - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(AUTO_COMPACT_CONTEXT_PREF_KEY, JSON.stringify(autoCompactContext())); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - const map = modelVariantMap(); - if (Object.keys(map).length > 0) { - window.localStorage.setItem(VARIANT_PREF_KEY, JSON.stringify(map)); - } else { - window.localStorage.removeItem(VARIANT_PREF_KEY); - } - } catch { - // ignore - } - }); - createEffect(() => { const state = updateStatus(); if (typeof window === "undefined") return; @@ -7158,150 +1887,26 @@ export default function App() { return seconds > 0 ? `${label} · ${seconds}s` : label; }); - const workspaceSwitchWorkspace = createMemo(() => { - const switchingId = workspaceStore.connectingWorkspaceId(); - if (switchingId) { - return workspaceStore.workspaces().find((ws) => ws.id === switchingId) ?? activeWorkspaceDisplay(); - } - return activeWorkspaceDisplay(); + const modelControlsStore = createModelControlsStore({ + selectedSessionModelLabel, + openSessionModelPicker, + sessionModelVariantLabel, + sessionModelVariant: modelVariant, + sessionModelBehaviorOptions, + setSessionModelVariant, + defaultModelLabel, + defaultModelRef, + openDefaultModelPicker, + autoCompactContext, + toggleAutoCompactContext, + autoCompactContextBusy: autoCompactContextSaving, + defaultModelVariantLabel, + editDefaultModelVariant: openDefaultModelPicker, }); - // Avoid flashing the full-screen switch overlay for fast workspace switches. - // Only show it if a switch is still in progress after a short delay. - const [workspaceSwitchDelayElapsed, setWorkspaceSwitchDelayElapsed] = createSignal(false); - createEffect(() => { - if (typeof window === "undefined") return; - const switchingId = workspaceStore.connectingWorkspaceId(); - if (!switchingId) { - setWorkspaceSwitchDelayElapsed(false); - return; - } - - setWorkspaceSwitchDelayElapsed(false); - const timer = window.setTimeout(() => setWorkspaceSwitchDelayElapsed(true), 250); - onCleanup(() => window.clearTimeout(timer)); - }); - - const workspaceSwitchOpen = createMemo(() => { - if (booting()) return true; - if (workspaceStore.connectingWorkspaceId()) return workspaceSwitchDelayElapsed(); - if (!busy() || !busyLabel()) return false; - const label = busyLabel(); - return ( - label === "status.starting_engine" || - label === "status.restarting_engine" - ); - }); - - const workspaceSwitchStatusKey = createMemo(() => { - const label = busyLabel(); - if (label === "status.connecting") return "workspace.switching_status_connecting"; - if (label === "status.starting_engine" || label === "status.restarting_engine") { - return "workspace.switching_status_preparing"; - } - if (label === "status.loading_session") return "workspace.switching_status_loading"; - if (workspaceStore.connectingWorkspaceId()) return "workspace.switching_status_loading"; - if (booting()) return "workspace.switching_status_preparing"; - return "workspace.switching_status_preparing"; - }); - - const localHostLabel = createMemo(() => { - const info = engine(); - if (info?.hostname && info?.port) { - return `${info.hostname}:${info.port}`; - } - - try { - return new URL(baseUrl()).host; - } catch { - return "localhost:4096"; - } - }); - - const onboardingProps = () => ({ - startupPreference: startupPreference(), - onboardingStep: onboardingStep(), - rememberStartupChoice: rememberStartupChoice(), - busy: busy(), - clientDirectory: clientDirectory(), - openworkHostUrl: openworkServerSettings().urlOverride ?? "", - openworkToken: openworkServerSettings().token ?? "", - newAuthorizedDir: newAuthorizedDir(), - authorizedDirs: workspaceStore.authorizedDirs(), - activeWorkspacePath: workspaceStore.activeWorkspacePath(), - workspaces: workspaceStore.workspaces(), - localHostLabel: localHostLabel(), - engineRunning: Boolean(engine()?.running), - developerMode: developerMode(), - engineBaseUrl: engine()?.baseUrl ?? null, - engineDoctorFound: engineDoctorResult()?.found ?? null, - engineDoctorSupportsServe: engineDoctorResult()?.supportsServe ?? null, - engineDoctorVersion: engineDoctorResult()?.version ?? null, - engineDoctorResolvedPath: engineDoctorResult()?.resolvedPath ?? null, - engineDoctorNotes: engineDoctorResult()?.notes ?? [], - engineDoctorServeHelpStdout: engineDoctorResult()?.serveHelpStdout ?? null, - engineDoctorServeHelpStderr: engineDoctorResult()?.serveHelpStderr ?? null, - engineDoctorCheckedAt: engineDoctorCheckedAt(), - engineInstallLogs: engineInstallLogs(), - error: error(), - canRepairMigration: workspaceStore.canRepairOpencodeMigration(), - migrationRepairUnavailableReason: migrationRepairUnavailableReason(), - migrationRepairBusy: workspaceStore.migrationRepairBusy(), - migrationRepairResult: workspaceStore.migrationRepairResult(), - isWindows: isWindowsPlatform(), - onClientDirectoryChange: setClientDirectory, - onOpenworkHostUrlChange: (value: string) => - updateOpenworkServerSettings({ - ...openworkServerSettings(), - urlOverride: value, - }), - onOpenworkTokenChange: (value: string) => - updateOpenworkServerSettings({ - ...openworkServerSettings(), - token: value, - }), - onSelectStartup: workspaceStore.onSelectStartup, - onRememberStartupToggle: workspaceStore.onRememberStartupToggle, - onStartHost: workspaceStore.onStartHost, - onRepairMigration: workspaceStore.onRepairOpencodeMigration, - onCreateWorkspace: workspaceStore.createWorkspaceFlow, - onPickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, - onImportWorkspaceConfig: workspaceStore.importWorkspaceConfig, - importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(), - onAttachHost: workspaceStore.onAttachHost, - onConnectClient: workspaceStore.onConnectClient, - onBackToWelcome: workspaceStore.onBackToWelcome, - onSetAuthorizedDir: workspaceStore.setNewAuthorizedDir, - onAddAuthorizedDir: workspaceStore.addAuthorizedDir, - onAddAuthorizedDirFromPicker: () => - workspaceStore.addAuthorizedDirFromPicker({ persistToWorkspace: true }), - onRemoveAuthorizedDir: workspaceStore.removeAuthorizedDirAtIndex, - onRefreshEngineDoctor: async () => { - workspaceStore.setEngineInstallLogs(null); - await workspaceStore.refreshEngineDoctor(); - }, - onInstallEngine: workspaceStore.onInstallEngine, - onShowSearchNotes: () => { - const notes = - workspaceStore.engineDoctorResult()?.notes?.join("\n") ?? ""; - workspaceStore.setEngineInstallLogs(notes || null); - }, - onOpenSettings: () => { - setTab("settings"); - setView("dashboard"); - }, - onOpenAdvancedSettings: () => { - setTab("config"); - setView("dashboard"); - }, - themeMode: themeMode(), - setThemeMode, - }); - - const dashboardProps = () => { - const workspaceType = activeWorkspaceDisplay().workspaceType; + const settingsShellProps = () => { + const workspaceType = selectedWorkspaceDisplay().workspaceType; const isRemoteWorkspace = workspaceType === "remote"; - const providerAuthWorkerType: "local" | "remote" = isRemoteWorkspace ? "remote" : "local"; const openworkStatus = openworkServerStatus(); const canUseDesktopTools = isTauriRuntime() && !isRemoteWorkspace; const canInstallSkillCreator = isRemoteWorkspace @@ -7331,8 +1936,6 @@ export default function App() { : null; return { - tab: tab(), - setTab, settingsTab: settingsTab(), setSettingsTab, providers: providers(), @@ -7342,7 +1945,7 @@ export default function App() { providerAuthError: providerAuthError(), providerAuthMethods: providerAuthMethods(), providerAuthPreferredProviderId: providerAuthPreferredProviderId(), - providerAuthWorkerType, + providerAuthWorkerType: providerAuthWorkerType(), openProviderAuthModal, disconnectProvider, closeProviderAuthModal, @@ -7372,10 +1975,10 @@ export default function App() { shareRemoteAccessBusy: shareRemoteAccessBusy(), shareRemoteAccessError: shareRemoteAccessError(), saveShareRemoteAccess, - openworkServerCapabilities: devtoolsCapabilities(), + openworkServerCapabilities: openworkServerCapabilities(), openworkServerDiagnostics: openworkServerDiagnostics(), - openworkServerWorkspaceId: openworkServerWorkspaceId(), - activeWorkspaceType: workspaceStore.activeWorkspaceDisplay().workspaceType, + runtimeWorkspaceId: runtimeWorkspaceId(), + activeWorkspaceType: workspaceStore.selectedWorkspaceDisplay().workspaceType, openworkAuditEntries: openworkAuditEntries(), openworkAuditStatus: openworkAuditStatus(), openworkAuditError: openworkAuditError(), @@ -7396,19 +1999,19 @@ export default function App() { setWorkspaceAutoReloadEnabled, workspaceAutoReloadResumeEnabled: workspaceAutoReloadResumeEnabled(), setWorkspaceAutoReloadResumeEnabled, - activeWorkspaceDisplay: activeWorkspaceDisplay(), + selectedWorkspaceDisplay: selectedWorkspaceDisplay(), workspaces: workspaceStore.workspaces(), - activeWorkspaceId: workspaceStore.activeWorkspaceId(), + selectedWorkspaceId: workspaceStore.selectedWorkspaceId(), connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(), - activateWorkspace: workspaceStore.activateWorkspace, + switchWorkspace: workspaceStore.switchWorkspace, testWorkspaceConnection: workspaceStore.testWorkspaceConnection, recoverWorkspace: workspaceStore.recoverWorkspace, openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true), - getStartedWorkspace: workspaceStore.quickStartWorkspaceFlow, pickFolderWorkspace: workspaceStore.createWorkspaceFromPickedFolder, openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true), connectRemoteWorkspace: workspaceStore.createRemoteWorkspaceFlow, + openTeamBundle: bundlesStore.openTeamBundle, importWorkspaceConfig: workspaceStore.importWorkspaceConfig, importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(), exportWorkspaceConfig: workspaceStore.exportWorkspaceConfig, @@ -7419,75 +2022,26 @@ export default function App() { pickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, workspaceSessionGroups: sidebarWorkspaceGroups(), selectedSessionId: activeSessionId(), - openRenameWorkspace, - editWorkspaceConnection: openWorkspaceConnectionSettings, + openRenameWorkspace: workspaceStore.openRenameWorkspace, + editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings, forgetWorkspace: workspaceStore.forgetWorkspace, stopSandbox: workspaceStore.stopSandbox, - scheduledJobs: scheduledJobs(), - scheduledJobsSource: scheduledJobsSource(), - scheduledJobsSourceReady: scheduledJobsSourceReady(), schedulerPluginInstalled: schedulerPluginInstalled(), - scheduledJobsStatus: scheduledJobsStatus(), - scheduledJobsBusy: scheduledJobsBusy(), - scheduledJobsUpdatedAt: scheduledJobsUpdatedAt(), - refreshScheduledJobs: (options?: { force?: boolean }) => - refreshScheduledJobs(options).catch(() => undefined), - deleteScheduledJob, - activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), - isRemoteWorkspace: workspaceStore.activeWorkspaceDisplay().workspaceType === "remote", - refreshSkills: (options?: { force?: boolean }) => refreshSkills(options).catch(() => undefined), - refreshHubSkills: (options?: { force?: boolean }) => refreshHubSkills(options).catch(() => undefined), - refreshPlugins: (scopeOverride?: PluginScope) => - refreshPlugins(scopeOverride).catch(() => undefined), - skills: skills(), - skillsStatus: skillsStatus(), - hubSkills: hubSkills(), - hubSkillsStatus: hubSkillsStatus(), - hubRepo: hubRepo(), - hubRepos: hubRepos(), + selectedWorkspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), + isRemoteWorkspace: workspaceStore.selectedWorkspaceDisplay().workspaceType === "remote", skillsAccessHint, canInstallSkillCreator, canUseDesktopTools, - importLocalSkill, - installSkillCreator, - installHubSkill, - setHubRepo, - addHubRepo, - removeHubRepo, - revealSkillsFolder, - uninstallSkill, - readSkill, - saveSkill, pluginsAccessHint, canEditPlugins, canUseGlobalPluginScope, - pluginScope: pluginScope(), - setPluginScope, - pluginConfigPath: pluginConfigPath() ?? pluginConfig()?.path ?? null, - pluginList: pluginList(), - pluginInput: pluginInput(), - setPluginInput, - pluginStatus: pluginStatus(), - activePluginGuide: activePluginGuide(), - setActivePluginGuide, - isPluginInstalled: isPluginInstalledByName, suggestedPlugins: SUGGESTED_PLUGINS, addPlugin, - removePlugin, createSessionAndOpen, setPrompt, selectSession: selectSession, - defaultModelLabel: formatModelLabel(defaultModel(), providers()), - defaultModelRef: formatModelRef(defaultModel()), - openDefaultModelPicker, - showThinking: showThinking(), - toggleShowThinking: () => setShowThinking((v) => !v), - autoCompactContext: autoCompactContext(), - toggleAutoCompactContext: () => setAutoCompactContext((v) => !v), hideTitlebar: hideTitlebar(), toggleHideTitlebar: () => setHideTitlebar((v) => !v), - modelVariantLabel: getModelBehaviorCopy(defaultModel(), getVariantFor(defaultModel())).label, - editModelVariant: openDefaultModelPicker, updateAutoCheck: updateAutoCheck(), toggleUpdateAutoCheck: () => setUpdateAutoCheck((v) => !v), updateAutoDownload: updateAutoDownload(), @@ -7535,114 +2089,40 @@ export default function App() { sandboxCreateProgressLast: workspaceStore.lastSandboxCreateProgress(), clearWorkspaceDebugEvents: workspaceStore.clearWorkspaceDebugEvents, safeStringify, - repairOpencodeMigration: workspaceStore.repairOpencodeMigration, - migrationRepairBusy: workspaceStore.migrationRepairBusy(), - migrationRepairResult: workspaceStore.migrationRepairResult(), - migrationRepairAvailable: workspaceStore.canRepairOpencodeMigration(), - migrationRepairUnavailableReason: migrationRepairUnavailableReason(), repairOpencodeCache, cacheRepairBusy: cacheRepairBusy(), cacheRepairResult: cacheRepairResult(), cleanupOpenworkDockerContainers, dockerCleanupBusy: dockerCleanupBusy(), dockerCleanupResult: dockerCleanupResult(), - authorizedFolders: authorizedFolders(), - authorizedFolderDraft: authorizedFolderDraft(), - setAuthorizedFolderDraft, - authorizedFoldersLoading: authorizedFoldersLoading(), - authorizedFoldersSaving: authorizedFoldersSaving(), - authorizedFoldersError: authorizedFoldersError(), - authorizedFoldersStatus: authorizedFoldersStatus(), - authorizedFoldersAvailable: openworkServerCanReadConfig(), - authorizedFoldersEditable: openworkServerCanWriteConfig(), - authorizedFoldersHint: !openworkServerReady() - ? "OpenWork server is disconnected." - : !openworkServerWorkspaceReady() - ? "No active server workspace is selected." - : !openworkServerCanReadConfig() - ? "OpenWork server config access is unavailable for this workspace." - : !openworkServerCanWriteConfig() - ? "OpenWork server is connected read-only for workspace config." - : null, - addAuthorizedFolder, - pickAuthorizedFolder, - removeAuthorizedFolder, + markOpencodeConfigReloadRequired, resetAppConfigDefaults, - notionStatus: notionStatus(), - notionStatusDetail: notionStatusDetail(), - notionError: notionError(), - notionBusy: notionBusy(), - connectNotion, - openDebugDeepLink, - mcpServers: mcpServers(), - mcpStatus: mcpStatus(), - mcpLastUpdatedAt: mcpLastUpdatedAt(), - mcpStatuses: mcpStatuses(), - mcpConnectingName: mcpConnectingName(), - selectedMcp: selectedMcp(), - setSelectedMcp, - readConfigFile: readMcpConfigFile, - quickConnect: MCP_QUICK_CONNECT, - connectMcp, - authorizeMcp, - logoutMcpAuth, - removeMcp, - refreshMcpServers, - showMcpReloadBanner: - reloadRequired() && (reloadTrigger()?.type === "mcp" || reloadTrigger()?.type === "config"), - mcpReloadBlocked: activeReloadBlockingSessions().length > 0, - reloadBlocked: activeReloadBlockingSessions().length > 0, - reloadMcpEngine: () => reloadWorkspaceEngineAndResume(), + openDebugDeepLink: deepLinks.openDebugDeepLink, language: currentLocale(), setLanguage: setLocale, }; }; - const searchWorkspaceFiles = async (query: string) => { - const trimmed = query.trim(); - if (!trimmed) return []; - const activeClient = client(); - if (!activeClient) return []; - try { - const directory = workspaceProjectDir().trim(); - const result = unwrap( - await activeClient.find.files({ - query: trimmed, - dirs: "true", - limit: 50, - directory: directory || undefined, - }), - ); - return result; - } catch { - return []; - } - }; - const sessionProps = () => ({ - providerAuthWorkerType: (activeWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local") as - | "remote" - | "local", + providerAuthWorkerType: providerAuthWorkerType(), selectedSessionId: activeSessionId(), setView, - tab: tab(), - setTab, + settingsTab: settingsTab(), setSettingsTab, toggleSettings: () => toggleSettingsView("general"), - activeWorkspaceDisplay: activeWorkspaceDisplay(), - activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), + selectedWorkspaceDisplay: selectedWorkspaceDisplay(), + selectedWorkspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), activeWorkspaceConfig: resolvedActiveWorkspaceConfig(), workspaces: workspaceStore.workspaces(), - activeWorkspaceId: workspaceStore.activeWorkspaceId(), + selectedWorkspaceId: workspaceStore.selectedWorkspaceId(), connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(), - activateWorkspace: workspaceStore.activateWorkspace, + switchWorkspace: workspaceStore.switchWorkspace, testWorkspaceConnection: workspaceStore.testWorkspaceConnection, recoverWorkspace: workspaceStore.recoverWorkspace, - editWorkspaceConnection: openWorkspaceConnectionSettings, + editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings, forgetWorkspace: workspaceStore.forgetWorkspace, openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true), - getStartedWorkspace: workspaceStore.quickStartWorkspaceFlow, pickFolderWorkspace: workspaceStore.createWorkspaceFromPickedFolder, openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true), importWorkspaceConfig: workspaceStore.importWorkspaceConfig, @@ -7658,7 +2138,7 @@ export default function App() { shareRemoteAccessBusy: shareRemoteAccessBusy(), shareRemoteAccessError: shareRemoteAccessError(), saveShareRemoteAccess, - openworkServerWorkspaceId: openworkServerWorkspaceId(), + runtimeWorkspaceId: runtimeWorkspaceId(), engineInfo: workspaceStore.engine(), engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null, orchestratorStatus: orchestratorStatusState(), @@ -7671,43 +2151,13 @@ export default function App() { updateEnv: updateEnv(), anyActiveRuns: anyActiveRuns(), installUpdateAndRestart, - selectedSessionModelLabel: selectedSessionModelLabel(), - selectedProviderID: selectedSessionModel().providerID, - openSessionModelPicker: openSessionModelPicker, - modelVariantLabel: getModelBehaviorCopy(selectedSessionModel(), getVariantFor(selectedSessionModel())).label, - modelVariant: getVariantFor(selectedSessionModel()), - modelBehaviorOptions: getModelBehaviorCopy(selectedSessionModel(), getVariantFor(selectedSessionModel())).options, - setModelVariant: (value: string | null) => updateModelVariant(selectedSessionModel(), value), activePlugins: sidebarPluginList(), activePluginStatus: sidebarPluginStatus(), - mcpServers: mcpServers(), - mcpStatuses: mcpStatuses(), - mcpStatus: mcpStatus(), skills: skills(), skillsStatus: skillsStatus(), - showSkillReloadBanner: reloadRequired() && reloadTrigger()?.type === "skill", - reloadBannerTitle: reloadCopy().title, - reloadBannerBody: reloadCopy().body, - reloadBannerBlocked: activeReloadBlockingSessions().length > 0, - reloadBannerActiveCount: activeReloadBlockingSessions().length, - canReloadWorkspace: canReloadWorkspace(), - reloadWorkspaceEngine: reloadWorkspaceEngineAndResume, - forceStopActiveConversations: forceStopActiveSessionsAndReload, - dismissReloadBanner: clearReloadRequired, - reloadBusy: reloadBusy(), - reloadError: reloadError(), - createSessionAndOpen: createSessionAndOpen, - sendPromptAsync: sendPrompt, - abortSession: abortSession, - sessionRevertMessageId: selectedSession()?.revert?.messageID ?? null, - undoLastUserMessage: undoLastUserMessage, - redoLastUserMessage: redoLastUserMessage, - compactSession: compactCurrentSession, - lastPromptSent: lastPromptSent(), - retryLastPrompt: retryLastPrompt, newTaskDisabled: newTaskDisabled(), workspaceSessionGroups: sidebarWorkspaceGroups(), - openRenameWorkspace, + openRenameWorkspace: workspaceStore.openRenameWorkspace, selectSession: selectSession, messages: visibleMessages(), getSessionById: sessionById, @@ -7717,11 +2167,7 @@ export default function App() { todos: activeTodos(), busyLabel: busyLabel(), developerMode: developerMode(), - showThinking: showThinking(), - autoCompactContext: autoCompactContext(), - toggleAutoCompactContext: () => setAutoCompactContext((v) => !v), - groupMessageParts, - summarizeStep, + sessionCompactionState: selectedSessionCompactionState(), expandedStepIds: expandedStepIds(), setExpandedStepIds: setExpandedStepIds, expandedSidebarSections: expandedSidebarSections(), @@ -7739,7 +2185,6 @@ export default function App() { questionReplyBusy: questionReplyBusy(), respondQuestion: respondQuestion, safeStringify: safeStringify, - showTryNotionPrompt: tryNotionPromptVisible() && notionIsActive(), startProviderAuth: startProviderAuth, completeProviderAuthOAuth: completeProviderAuthOAuth, refreshProviders: refreshProviders, @@ -7753,48 +2198,35 @@ export default function App() { providerAuthPreferredProviderId: providerAuthPreferredProviderId(), providers: providers(), providerConnectedIds: providerConnectedIds(), - listAgents: listAgents, - listCommands: listCommands, - selectedSessionAgent: selectedSessionAgent(), - setSessionAgent: setSessionAgent, - saveSession: saveSessionExport, sessionStatusById: activeSessionStatusById(), hasEarlierMessages: selectedSessionHasEarlierMessages(), loadingEarlierMessages: selectedSessionLoadingEarlierMessages(), loadEarlierMessages, - searchFiles: searchWorkspaceFiles, - deleteSession: deleteSessionById, - onTryNotionPrompt: () => { - setPrompt("setup my crm"); - setTryNotionPromptVisible(false); - setNotionSkillInstalled(true); - try { - window.localStorage.setItem("openwork.notionSkillInstalled", "1"); - } catch { - // ignore - } - }, sessionStatus: selectedSessionStatus(), - renameSession: renameSessionTitle, error: error(), }); - const dashboardTabs = new Set([ - "scheduled", + const settingsTabs = new Set([ + "general", + "den", + "model", + "automations", "skills", - "plugins", - "mcp", - "identities", - "config", - "settings", + "extensions", + "messaging", + "advanced", + "appearance", + "updates", + "recovery", + "debug", ]); - const resolveDashboardTab = (value?: string | null) => { + const resolveSettingsTab = (value?: string | null) => { const normalized = value?.trim().toLowerCase() ?? ""; - if (dashboardTabs.has(normalized as DashboardTab)) { - return normalized as DashboardTab; + if (settingsTabs.has(normalized as SettingsTab)) { + return normalized as SettingsTab; } - return "scheduled"; + return "general"; }; const initialRoute = () => { @@ -7813,13 +2245,19 @@ export default function App() { if (path.startsWith("/dashboard")) { const [, , tabSegment] = path.split("/"); - const resolvedTab = resolveDashboardTab(tabSegment); + goToSettings(mapLegacySurfaceToSettingsTab(tabSegment ?? "settings"), { replace: true }); + return; + } - if (resolvedTab !== tab()) { - setTabState(resolvedTab); + if (path.startsWith("/settings")) { + const [, , tabSegment] = path.split("/"); + const resolvedTab = resolveSettingsTab(tabSegment); + + if (resolvedTab !== settingsTab()) { + setSettingsTabState(resolvedTab); } if (!tabSegment || tabSegment !== resolvedTab) { - goToDashboard(resolvedTab, { replace: true }); + goToSettings(resolvedTab, { replace: true }); } return; } @@ -7830,21 +2268,26 @@ export default function App() { if (!id) { if (selectedSessionId()) { - setSelectedSessionId(null); - setMessages([]); - setTodos([]); + workspaceStore.clearSelectedSessionSurface(); } return; } // If the URL points at a session that no longer exists (e.g. after deletion), // route back to /session so the app can fall back safely. + const pendingInitialSelection = pendingInitialSessionSelection(); + const selectedWorkspaceRoot = normalizeDirectoryPath(workspaceStore.selectedWorkspaceRoot().trim()); + const matchingSession = sessions().find((session) => session.id === id) ?? null; + const hasMatchingSessionInScope = matchingSession + ? !selectedWorkspaceRoot || normalizeDirectoryPath(matchingSession.directory) === selectedWorkspaceRoot + : false; if ( sessionsLoaded() && + !pendingInitialSelection && shouldRedirectMissingSessionAfterScopedLoad({ loadedScopeRoot: loadedSessionScopeRoot(), - workspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), - hasMatchingSession: sessions().some((session) => session.id === id), + workspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), + hasMatchingSession: hasMatchingSessionInScope, }) ) { if (selectedSessionId() === id) { @@ -7855,33 +2298,19 @@ export default function App() { } if (selectedSessionId() !== id) { + setSelectedSessionId(id); void selectSession(id); } return; } - if (path.startsWith("/proto-v1-ux")) { + if (path.startsWith("/proto-v1-ux") || path.startsWith("/proto")) { if (isTauriRuntime()) { - navigate("/dashboard/scheduled", { replace: true }); - } - return; - } - - if (path.startsWith("/proto")) { - if (isTauriRuntime()) { - navigate("/dashboard/scheduled", { replace: true }); + navigate("/settings/automations", { replace: true }); return; } - const [, , protoSegment] = rawPath.split("/"); - if (!protoSegment) { - navigate("/proto/workspaces", { replace: true }); - } - return; - } - - if (path.startsWith("/onboarding")) { - navigate("/session", { replace: true }); + navigate("/settings/automations", { replace: true }); return; } @@ -7894,34 +2323,24 @@ export default function App() { }); return ( - <> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + { - updateModelVariant(model, sanitizeModelVariantForRef(model, value)); - }} + onBehaviorChange={setModelPickerBehavior} onOpenSettings={openSettingsFromModelPicker} onClose={closeModelPicker} /> @@ -7956,45 +2373,64 @@ export default function App() { onTextChange={setResetModalText} /> - 0} activeSessions={activeReloadBlockingSessions()} - isRemoteWorkspace={activeWorkspaceDisplay().workspaceType === "remote"} + isRemoteWorkspace={selectedWorkspaceDisplay().workspaceType === "remote"} onForceStopSession={(sessionID) => abortSession(sessionID)} - onClose={() => { - setMcpAuthModalOpen(false); - setMcpAuthEntry(null); - setMcpAuthNeedsReload(false); - }} - onComplete={async () => { - setMcpAuthModalOpen(false); - setMcpAuthEntry(null); - setMcpAuthNeedsReload(false); - await refreshMcpServers(); - }} onReloadEngine={() => reloadWorkspaceEngineAndResume()} /> - { - void openSharedBundleCreateWorkerFlow(); + void bundlesStore.openCreateWorkspaceFromChoice(); }} onSelectWorker={(workspaceId) => { - void importSharedBundleIntoExistingWorkspace(workspaceId); + void bundlesStore.importBundleIntoExistingWorkspace(workspaceId); + }} + /> + + { + const warning = bundlesStore.untrustedBundleWarning(); + const actualOrigin = warning?.actualOrigin?.trim() || "an unknown origin"; + const configuredOrigin = warning?.configuredOrigin?.trim() || "the configured OpenWork share service"; + return `This link points to ${actualOrigin}, but OpenWork only auto-imports bundles from ${configuredOrigin}. Untrusted bundles can contain malicious instructions or settings. Only continue if you trust the sender and expect this import.`; + })()} + confirmLabel="Import anyway" + cancelLabel="Cancel" + variant="warning" + onConfirm={() => { + void bundlesStore.confirmUntrustedBundleWarning(); + }} + onCancel={bundlesStore.dismissUntrustedBundleWarning} + /> + + { + bundlesStore.clearBundleStartRequest(); + }} + onPickFolder={workspaceStore.pickWorkspaceFolder} + onConfirm={(folder) => { + void bundlesStore.startWorkspaceFromBundle(folder); }} /> @@ -8003,63 +2439,25 @@ export default function App() { onClose={() => { workspaceStore.setCreateWorkspaceOpen(false); workspaceStore.clearSandboxCreateProgress?.(); - setSharedBundleCreateWorkerRequest(null); + bundlesStore.clearCreateWorkspaceRequest(); }} onPickFolder={workspaceStore.pickWorkspaceFolder} - defaultPreset={createWorkspaceDefaultPreset()} - onConfirm={async (preset, folder) => { - const request = sharedBundleCreateWorkerRequest(); - const ok = await workspaceStore.createWorkspaceFlow(preset, folder); - if (!ok || !request) return; - const imported = await importSharedBundleIntoActiveWorker(request.request, { - localRoot: workspaceStore.activeWorkspaceRoot().trim(), - }, request.bundle); - setSharedBundleCreateWorkerRequest(null); - if (imported) { - if (request.bundle.type === "skill") { - showSharedSkillSuccessToast({ - title: "Skill added", - description: `Added '${request.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForToasts(workspaceStore.activeWorkspaceDisplay())}.`, - }); - } - setSharedSkillDestinationRequest(null); - } - }} + onImportConfig={isTauriRuntime() ? workspaceStore.importWorkspaceConfig : undefined} + importingConfig={workspaceStore.importingWorkspaceConfig()} + defaultPreset={bundlesStore.createWorkspaceDefaultPreset()} + onConfirmRemote={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} + onConfirmTemplate={(template, preset, folder) => + bundlesStore.startWorkspaceFromTeamTemplate({ + name: template.name, + templateData: template.templateData, + folder, + preset, + }) + } + onConfirm={bundlesStore.handleCreateWorkspaceConfirm} onConfirmWorker={ isTauriRuntime() - ? async (preset, folder) => { - const request = sharedBundleCreateWorkerRequest(); - const ok = await workspaceStore.createSandboxFlow( - preset, - folder, - request - ? { - onReady: async () => { - const active = workspaceStore.activeWorkspaceDisplay(); - await importSharedBundleIntoActiveWorker(request.request, { - workspaceId: - active.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || - null, - directoryHint: active.directory?.trim() || active.path?.trim() || null, - }, request.bundle); - if (request.bundle.type === "skill") { - showSharedSkillSuccessToast({ - title: "Skill added", - description: `Added '${request.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForToasts(active)}.`, - }); - } - }, - } - : undefined, - ); - if (!ok) return; - setSharedBundleCreateWorkerRequest(null); - if (request) { - setSharedSkillDestinationRequest(null); - } - } + ? bundlesStore.handleCreateSandboxConfirm : undefined } workerDisabled={(() => { @@ -8116,6 +2514,14 @@ export default function App() { void workspaceStore.refreshSandboxDoctor?.(); }} workerSubmitting={workspaceStore.sandboxPreflightBusy?.() ?? false} + localDisabled={!isTauriRuntime()} + localDisabledReason={ + !isTauriRuntime() + ? "Create local workspaces in the desktop app. Remote and shared workspaces still work here." + : null + } + remoteSubmitting={busy() && busyLabel() === "status.connecting"} + remoteError={busyLabel() === "status.connecting" ? error() : null} submitting={(() => { const phase = workspaceStore.sandboxCreatePhase?.() ?? "idle"; if (phase === "provisioning" || phase === "finalizing") return true; @@ -8124,14 +2530,14 @@ export default function App() { submittingProgress={workspaceStore.sandboxCreateProgress?.() ?? null} /> - { - const request = sharedSkillDestinationRequest(); + const request = bundlesStore.skillDestinationRequest(); if (!request) return null; return { name: request.bundle.name, @@ -8139,140 +2545,85 @@ export default function App() { trigger: request.bundle.trigger ?? null, }; })()} - workspaces={sharedSkillDestinationWorkspaces()} - activeWorkspaceId={workspaceStore.activeWorkspaceId()} - busyWorkspaceId={sharedSkillDestinationBusyId()} + workspaces={bundlesStore.skillDestinationWorkspaces()} + selectedWorkspaceId={workspaceStore.selectedWorkspaceId()} + busyWorkspaceId={bundlesStore.skillDestinationBusyId()} onClose={() => { - if (sharedSkillDestinationBusyId()) return; - setSharedSkillDestinationRequest(null); + bundlesStore.clearSkillDestinationRequest(); }} - onSubmitWorkspace={importSharedSkillIntoWorkspace} + onSubmitWorkspace={bundlesStore.importSkillIntoWorkspace} onCreateWorker={ isTauriRuntime() - ? () => { - const request = sharedSkillDestinationRequest(); - if (!request) return; - setError(null); - setSharedBundleCreateWorkerRequest({ - request: request.request, - bundle: request.bundle, - defaultPreset: "starter", - }); - workspaceStore.setCreateWorkspaceOpen(true); - } + ? bundlesStore.openCreateWorkspaceFromSkillDestination : undefined } onConnectRemote={() => { - setError(null); - workspaceStore.setCreateRemoteWorkspaceOpen(true); + bundlesStore.openRemoteConnectFromSkillDestination(); }} /> - { - workspaceStore.setCreateRemoteWorkspaceOpen(false); - setDeepLinkRemoteWorkspaceDefaults(null); + { + workspaceStore.setCreateRemoteWorkspaceOpen(false); + deepLinks.clearDeepLinkRemoteWorkspaceDefaults(); + }} + onConfirm={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} + initialValues={deepLinks.deepLinkRemoteWorkspaceDefaults() ?? undefined} + submitting={ + busy() && + (busyLabel() === "status.creating_workspace" || busyLabel() === "status.connecting") + } + /> + + 0 ? "Reload & Stop Tasks" : "Reload now"} + dismissLabel="Later" + reloadBusy={reloadBusy()} + canReload={canReloadWorkspace()} + hasActiveRuns={activeReloadBlockingSessions().length > 0} + onReload={() => { + void (activeReloadBlockingSessions().length > 0 + ? forceStopActiveSessionsAndReload() + : reloadWorkspaceEngineAndResume()); }} - onConfirm={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} - initialValues={deepLinkRemoteWorkspaceDefaults() ?? undefined} - submitting={ - busy() && - (busyLabel() === "status.creating_workspace" || busyLabel() === "status.connecting") - } + onDismissReload={clearReloadRequired} /> - -
-
-
-
- OpenWork Cloud -
-

Adding your worker

-

- Connecting your OpenWork worker now. This usually takes a moment. -

-
-
-
-
-
-
-
Preparing your session
-
- We are adding the remote worker in the background so you can land directly in the chat view. -
-
-
-
-
- - -
-
- -
- -
- 0 && !renameWorkspaceBusy()} - onClose={closeRenameWorkspace} - onSave={saveRenameWorkspace} - onTitleChange={setRenameWorkspaceName} + open={workspaceStore.renameWorkspaceOpen()} + title={workspaceStore.renameWorkspaceName()} + busy={workspaceStore.renameWorkspaceBusy()} + canSave={workspaceStore.renameWorkspaceName().trim().length > 0 && !workspaceStore.renameWorkspaceBusy()} + onClose={workspaceStore.closeRenameWorkspace} + onSave={workspaceStore.saveRenameWorkspace} + onTitleChange={workspaceStore.setRenameWorkspaceName} /> { - setEditRemoteWorkspaceOpen(false); - setEditRemoteWorkspaceId(null); - setEditRemoteWorkspaceError(null); - }} + open={workspaceStore.editRemoteWorkspaceOpen()} + onClose={workspaceStore.closeWorkspaceConnectionSettings} onConfirm={(input) => { - const workspaceId = editRemoteWorkspaceId(); - if (!workspaceId) return; - setEditRemoteWorkspaceError(null); - void (async () => { - try { - const ok = await workspaceStore.updateRemoteWorkspaceFlow(workspaceId, input); - if (ok) { - setEditRemoteWorkspaceOpen(false); - setEditRemoteWorkspaceId(null); - setEditRemoteWorkspaceError(null); - } else { - setEditRemoteWorkspaceError(error() || "Connection failed. Check the URL and token."); - setError(null); - } - } catch (e) { - const message = e instanceof Error ? e.message : "Connection failed"; - setEditRemoteWorkspaceError(message); - setError(null); - } - })(); + void workspaceStore.saveWorkspaceConnectionSettings(input); }} - initialValues={editRemoteWorkspaceDefaults() ?? undefined} + initialValues={workspaceStore.editRemoteWorkspaceDefaults() ?? undefined} submitting={busy() && busyLabel() === "status.connecting"} - error={editRemoteWorkspaceError()} + error={workspaceStore.editRemoteWorkspaceError()} title={t("dashboard.edit_remote_workspace_title", currentLocale())} subtitle={t("dashboard.edit_remote_workspace_subtitle", currentLocale())} confirmLabel={t("dashboard.edit_remote_workspace_confirm", currentLocale())} /> - + + + + + + + ); } diff --git a/apps/app/src/app/automations/provider.tsx b/apps/app/src/app/automations/provider.tsx new file mode 100644 index 00000000..e59cf719 --- /dev/null +++ b/apps/app/src/app/automations/provider.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +import type { AutomationsStore } from "../context/automations"; + +const AutomationsContext = createContext(); + +export function AutomationsProvider(props: ParentProps<{ store: AutomationsStore }>) { + return ( + + {props.children} + + ); +} + +export function useAutomations() { + const context = useContext(AutomationsContext); + if (!context) { + throw new Error("useAutomations must be used within an AutomationsProvider"); + } + return context; +} diff --git a/apps/app/src/app/bundles/apply.ts b/apps/app/src/app/bundles/apply.ts new file mode 100644 index 00000000..1f776e41 --- /dev/null +++ b/apps/app/src/app/bundles/apply.ts @@ -0,0 +1,117 @@ +import type { WorkspaceDisplay } from "../types"; +import { parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server"; +import type { WorkspaceInfo } from "../lib/tauri"; +import type { BundleImportTarget, BundleV1 } from "./types"; + +export function buildImportPayloadFromBundle(bundle: BundleV1): { + payload: Record; + importedSkillsCount: number; +} { + if (bundle.type === "skill") { + return { + payload: { + mode: { skills: "merge" }, + skills: [ + { + name: bundle.name, + description: bundle.description, + trigger: bundle.trigger, + content: bundle.content, + }, + ], + }, + importedSkillsCount: 1, + }; + } + + if (bundle.type === "skills-set") { + return { + payload: { + mode: { skills: "merge" }, + skills: bundle.skills.map((skill) => ({ + name: skill.name, + description: skill.description, + trigger: skill.trigger, + content: skill.content, + })), + }, + importedSkillsCount: bundle.skills.length, + }; + } + + const workspace = bundle.workspace; + const payload: Record = { + mode: { + opencode: "merge", + openwork: "merge", + skills: "merge", + commands: "merge", + files: "merge", + }, + }; + + if (workspace.opencode && typeof workspace.opencode === "object") { + payload.opencode = workspace.opencode; + } + if (workspace.openwork && typeof workspace.openwork === "object") { + payload.openwork = workspace.openwork; + } + if (Array.isArray(workspace.skills) && workspace.skills.length > 0) { + payload.skills = workspace.skills; + } + if (Array.isArray(workspace.commands) && workspace.commands.length > 0) { + payload.commands = workspace.commands; + } + if (Array.isArray(workspace.files) && workspace.files.length > 0) { + payload.files = workspace.files; + } + + return { + payload, + importedSkillsCount: Array.isArray(workspace.skills) ? workspace.skills.length : 0, + }; +} + +export function isBundleImportWorkspace(workspace: WorkspaceDisplay | WorkspaceInfo | null): boolean { + if (!workspace?.id?.trim()) return false; + if (workspace.workspaceType === "local") { + return Boolean(workspace.path?.trim()); + } + return Boolean(workspace.remoteType === "openwork" || workspace.openworkHostUrl?.trim() || workspace.openworkWorkspaceId?.trim()); +} + +export function resolveBundleImportTargetForWorkspace( + workspace: WorkspaceDisplay | WorkspaceInfo | null, +): BundleImportTarget | undefined { + if (!workspace) return undefined; + if (workspace.workspaceType === "local") { + const localRoot = workspace.path?.trim() ?? ""; + return localRoot ? { localRoot } : undefined; + } + + const workspaceId = + workspace.openworkWorkspaceId?.trim() || + parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || + parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || + null; + const directoryHint = workspace.directory?.trim() || workspace.path?.trim() || null; + if (workspaceId || directoryHint) { + return { + workspaceId, + directoryHint, + }; + } + return undefined; +} + +export function describeWorkspaceForBundleToasts(workspace: WorkspaceDisplay | WorkspaceInfo | null): string { + return ( + workspace?.displayName?.trim() || + workspace?.openworkWorkspaceName?.trim() || + workspace?.name?.trim() || + workspace?.directory?.trim() || + workspace?.path?.trim() || + workspace?.baseUrl?.trim() || + "the selected worker" + ); +} diff --git a/apps/app/src/app/components/shared-bundle-import-modal.tsx b/apps/app/src/app/bundles/import-modal.tsx similarity index 96% rename from apps/app/src/app/components/shared-bundle-import-modal.tsx rename to apps/app/src/app/bundles/import-modal.tsx index 59f945be..77bbce09 100644 --- a/apps/app/src/app/components/shared-bundle-import-modal.tsx +++ b/apps/app/src/app/bundles/import-modal.tsx @@ -2,21 +2,14 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "so import { Boxes, ChevronDown, ChevronRight, Plus, Sparkles, X } from "lucide-solid"; -type ExistingWorkerOption = { - id: string; - label: string; - detail: string; - badge: string; - current: boolean; - disabledReason?: string | null; -}; +import type { BundleWorkerOption } from "./types"; -export default function SharedBundleImportModal(props: { +export default function BundleImportModal(props: { open: boolean; title: string; description: string; items: string[]; - workers: ExistingWorkerOption[]; + workers: BundleWorkerOption[]; busy?: boolean; error?: string | null; onClose: () => void; @@ -105,7 +98,7 @@ export default function SharedBundleImportModal(props: {
-
Create New Worker
+
Create new worker
Open the existing new worker flow, then import this bundle into it.
@@ -122,7 +115,7 @@ export default function SharedBundleImportModal(props: { >
Add to existing worker
-
Pick an existing worker and import the shared skills there.
+
Pick an existing worker and import this bundle there.
}> diff --git a/apps/app/src/app/bundles/index.ts b/apps/app/src/app/bundles/index.ts new file mode 100644 index 00000000..51b7b054 --- /dev/null +++ b/apps/app/src/app/bundles/index.ts @@ -0,0 +1,6 @@ +export * from "./apply"; +export * from "./publish"; +export * from "./schema"; +export * from "./sources"; +export * from "./store"; +export * from "./types"; diff --git a/apps/app/src/app/bundles/publish.ts b/apps/app/src/app/bundles/publish.ts new file mode 100644 index 00000000..50b7a9cd --- /dev/null +++ b/apps/app/src/app/bundles/publish.ts @@ -0,0 +1,129 @@ +import { createDenClient, readDenSettings, writeDenSettings } from "../lib/den"; +import type { + OpenworkServerClient, + OpenworkWorkspaceExport, + OpenworkWorkspaceExportSensitiveMode, +} from "../lib/openwork-server"; +import type { SkillsSetBundleV1, WorkspaceProfileBundleV1 } from "./types"; + +export function buildWorkspaceProfileBundle( + workspaceName: string, + exported: OpenworkWorkspaceExport, +): WorkspaceProfileBundleV1 { + return { + schemaVersion: 1, + type: "workspace-profile", + name: `${workspaceName} template`, + description: "Full OpenWork workspace template with config, commands, skills, and portable .opencode files.", + workspace: exported, + }; +} + +export function buildSkillsSetBundle( + workspaceName: string, + exported: OpenworkWorkspaceExport, +): SkillsSetBundleV1 { + const skills = Array.isArray(exported.skills) ? exported.skills : []; + if (!skills.length) { + throw new Error("No skills found in this workspace."); + } + + return { + schemaVersion: 1, + type: "skills-set", + name: `${workspaceName} skills`, + description: "Complete skills set from an OpenWork workspace.", + skills: skills.map((skill) => ({ + name: skill.name, + description: skill.description, + trigger: skill.trigger, + content: skill.content, + })), + }; +} + +export async function publishWorkspaceProfileBundleFromWorkspace(input: { + client: OpenworkServerClient; + workspaceId: string; + workspaceName: string; + sensitiveMode?: Exclude | null; +}) { + const exported = await input.client.exportWorkspace(input.workspaceId, { + sensitiveMode: input.sensitiveMode ?? undefined, + }); + const payload = buildWorkspaceProfileBundle(input.workspaceName, exported); + return input.client.publishBundle(payload, "workspace-profile", { + name: payload.name, + }); +} + +export async function publishSkillsSetBundleFromWorkspace(input: { + client: OpenworkServerClient; + workspaceId: string; + workspaceName: string; +}) { + const exported = await input.client.exportWorkspace(input.workspaceId, { + sensitiveMode: "exclude", + }); + const payload = buildSkillsSetBundle(input.workspaceName, exported); + return input.client.publishBundle(payload, "skills-set", { + name: payload.name, + }); +} + +export async function saveWorkspaceProfileBundleToTeam(input: { + client: OpenworkServerClient; + workspaceId: string; + workspaceName: string; + requestedName: string; + sensitiveMode?: Exclude | null; +}) { + const exported = await input.client.exportWorkspace(input.workspaceId, { + sensitiveMode: input.sensitiveMode ?? undefined, + }); + const fallbackName = `${input.workspaceName} template`; + const name = input.requestedName.trim() || fallbackName; + const payload = { + ...buildWorkspaceProfileBundle(input.workspaceName, exported), + name, + } satisfies WorkspaceProfileBundleV1; + + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + if (!token) { + throw new Error("Sign in to OpenWork Cloud in Settings to share with your team."); + } + + const cloudClient = createDenClient({ baseUrl: settings.baseUrl, token }); + let orgId = settings.activeOrgId?.trim() ?? ""; + let orgSlug = settings.activeOrgSlug?.trim() ?? ""; + let orgName = settings.activeOrgName?.trim() ?? ""; + + if (!orgSlug || !orgName) { + const response = await cloudClient.listOrgs(); + const match = orgId + ? response.orgs.find((org) => org.id === orgId) + : response.orgs.find((org) => org.slug === orgSlug) ?? response.orgs[0]; + if (!match) { + throw new Error("Choose an organization in Settings -> Cloud before sharing with your team."); + } + orgId = match.id; + orgSlug = match.slug; + orgName = match.name; + writeDenSettings({ + ...settings, + baseUrl: settings.baseUrl, + authToken: token, + activeOrgId: orgId, + activeOrgSlug: orgSlug, + activeOrgName: orgName, + }); + } + + const created = await cloudClient.createTemplate(orgSlug, { + name, + templateData: payload, + }); + + return { created, orgName }; +} diff --git a/apps/app/src/app/bundles/schema.ts b/apps/app/src/app/bundles/schema.ts new file mode 100644 index 00000000..35416b9d --- /dev/null +++ b/apps/app/src/app/bundles/schema.ts @@ -0,0 +1,177 @@ +import type { WorkspacePreset } from "../types"; +import type { + BundleImportSummary, + BundleV1, + SkillBundleItem, + WorkspaceProfileBundleV1, +} from "./types"; +import type { OpenworkWorkspaceExport } from "../lib/openwork-server"; + +type PortableFileItem = { + path: string; + content: string; +}; + +function readRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function readSkillItem(value: unknown): SkillBundleItem | null { + const record = readRecord(value); + if (!record) return null; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!name || !content) return null; + return { + name, + description: typeof record.description === "string" ? record.description : undefined, + trigger: typeof record.trigger === "string" ? record.trigger : undefined, + content, + }; +} + +function readPortableFileItem(value: unknown): PortableFileItem | null { + const record = readRecord(value); + if (!record) return null; + const path = typeof record.path === "string" ? record.path.trim() : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!path) return null; + return { path, content }; +} + +function readWorkspacePreset(value: unknown): WorkspacePreset { + const normalized = String(value ?? "").trim().toLowerCase(); + if (normalized === "automation" || normalized === "minimal") { + return normalized; + } + return "starter"; +} + +export function defaultPresetFromWorkspaceProfileBundle(bundle: WorkspaceProfileBundleV1): WorkspacePreset { + const openwork = bundle.workspace?.openwork; + if (!openwork || typeof openwork !== "object") return "starter"; + const workspace = (openwork as Record).workspace; + if (!workspace || typeof workspace !== "object") return "starter"; + return readWorkspacePreset((workspace as Record).preset); +} + +function describeWorkspaceProfileItems(bundle: WorkspaceProfileBundleV1): string[] { + const workspace = bundle.workspace; + const skills = Array.isArray(workspace.skills) + ? workspace.skills + .map((skill) => (skill && typeof skill === "object" && typeof (skill as { name?: unknown }).name === "string" + ? (skill as { name: string }).name.trim() + : "")) + .filter(Boolean) + : []; + const commands = Array.isArray(workspace.commands) ? workspace.commands.length : 0; + const files = Array.isArray(workspace.files) ? workspace.files.length : 0; + const hasOpenCodeConfig = Boolean(workspace.opencode && typeof workspace.opencode === "object"); + const hasOpenWorkConfig = Boolean(workspace.openwork && typeof workspace.openwork === "object"); + + return [ + ...skills, + ...(commands > 0 ? [`${commands} command${commands === 1 ? "" : "s"}`] : []), + ...(hasOpenCodeConfig ? ["OpenCode config"] : []), + ...(hasOpenWorkConfig ? ["OpenWork config"] : []), + ...(files > 0 ? [`${files} portable file${files === 1 ? "" : "s"}`] : []), + ]; +} + +export function describeBundleImport(bundle: BundleV1): BundleImportSummary { + if (bundle.type === "skill") { + return { + title: "Import 1 skill", + description: bundle.description?.trim() || `Add \`${bundle.name}\` to an existing worker or create a new one for it.`, + items: [bundle.name], + }; + } + + if (bundle.type === "skills-set") { + const count = bundle.skills.length; + return { + title: `Import ${count} skill${count === 1 ? "" : "s"}`, + description: + bundle.description?.trim() || + `${bundle.name || "Shared skills"} is ready to import into an existing worker or a new worker.`, + items: bundle.skills.map((skill) => skill.name), + }; + } + + return { + title: bundle.name?.trim() || "Open workspace bundle", + description: + bundle.description?.trim() || + `${bundle.name || "This workspace bundle"} is ready to start in a new worker or import into an existing one.`, + items: describeWorkspaceProfileItems(bundle), + }; +} + +export function parseBundlePayload(value: unknown): BundleV1 { + const record = readRecord(value); + if (!record) { + throw new Error("Invalid bundle payload."); + } + + const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : null; + const type = typeof record.type === "string" ? record.type.trim() : ""; + const name = typeof record.name === "string" ? record.name.trim() : ""; + + if (schemaVersion !== 1) { + throw new Error("Unsupported bundle schema version."); + } + + if (type === "skill") { + const content = typeof record.content === "string" ? record.content : ""; + if (!name || !content) { + throw new Error("Invalid skill bundle payload."); + } + return { + schemaVersion: 1, + type: "skill", + name, + description: typeof record.description === "string" ? record.description : undefined, + trigger: typeof record.trigger === "string" ? record.trigger : undefined, + content, + }; + } + + if (type === "skills-set") { + const skills = Array.isArray(record.skills) + ? record.skills.map(readSkillItem).filter((item): item is SkillBundleItem => Boolean(item)) + : []; + if (!skills.length) { + throw new Error("Skills set bundle has no importable skills."); + } + return { + schemaVersion: 1, + type: "skills-set", + name: name || "Shared skills", + description: typeof record.description === "string" ? record.description : undefined, + skills, + }; + } + + if (type === "workspace-profile") { + const workspace = readRecord(record.workspace); + if (!workspace) { + throw new Error("Workspace profile bundle is missing workspace payload."); + } + const files = Array.isArray(workspace.files) + ? workspace.files.map(readPortableFileItem).filter((item): item is PortableFileItem => Boolean(item)) + : []; + return { + schemaVersion: 1, + type: "workspace-profile", + name: name || "Shared workspace profile", + description: typeof record.description === "string" ? record.description : undefined, + workspace: { + ...(workspace as OpenworkWorkspaceExport), + ...(files.length ? { files } : {}), + }, + }; + } + + throw new Error(`Unsupported bundle type: ${type || "unknown"}`); +} diff --git a/apps/app/src/app/components/shared-skill-destination-modal.tsx b/apps/app/src/app/bundles/skill-destination-modal.tsx similarity index 72% rename from apps/app/src/app/components/shared-skill-destination-modal.tsx rename to apps/app/src/app/bundles/skill-destination-modal.tsx index 7ec16a13..c63e1caf 100644 --- a/apps/app/src/app/components/shared-skill-destination-modal.tsx +++ b/apps/app/src/app/bundles/skill-destination-modal.tsx @@ -4,19 +4,19 @@ import { CheckCircle2, Folder, FolderPlus, Globe, Loader2, Sparkles, X } from "l import type { WorkspaceInfo } from "../lib/tauri"; import { t, currentLocale } from "../../i18n"; -import Button from "./button"; +import Button from "../components/button"; -type SharedSkillSummary = { +type SkillSummary = { name: string; description?: string | null; trigger?: string | null; }; -export default function SharedSkillDestinationModal(props: { +export default function SkillDestinationModal(props: { open: boolean; - skill: SharedSkillSummary | null; + skill: SkillSummary | null; workspaces: WorkspaceInfo[]; - activeWorkspaceId?: string | null; + selectedWorkspaceId?: string | null; busyWorkspaceId?: string | null; onClose: () => void; onSubmitWorkspace: (workspaceId: string) => void | Promise; @@ -68,7 +68,7 @@ export default function SharedSkillDestinationModal(props: { createEffect(() => { if (!props.open) return; - const activeMatch = props.workspaces.find((workspace) => workspace.id === props.activeWorkspaceId) ?? props.workspaces[0] ?? null; + const activeMatch = props.workspaces.find((workspace) => workspace.id === props.selectedWorkspaceId) ?? props.workspaces[0] ?? null; setSelectedWorkspaceId(activeMatch?.id ?? null); }); @@ -169,7 +169,7 @@ export default function SharedSkillDestinationModal(props: {
{(workspace) => { - const isActive = () => workspace.id === props.activeWorkspaceId; + const isActive = () => workspace.id === props.selectedWorkspaceId; const isSelected = () => workspace.id === selectedWorkspaceId(); const isBusy = () => workspace.id === props.busyWorkspaceId; const WorkspaceIcon = () => (workspace.workspaceType === "remote" ? : ); @@ -229,73 +229,77 @@ export default function SharedSkillDestinationModal(props: {
-
-
{translate("share_skill_destination.new_destination")}
-
- -
- - + + - -
- -
+ +
+ - + -
-
- {translate("share_skill_destination.footer_idle")}}> +
+
+ {(workspace) => ( - - {translate("share_skill_destination.footer_selected")} {displayName(workspace())} - +
+ {displayName(workspace())} + · + {subtitle(workspace())} +
)}
-
-
- - +
+ + +
diff --git a/apps/app/src/app/bundles/sources.ts b/apps/app/src/app/bundles/sources.ts new file mode 100644 index 00000000..34eb79ed --- /dev/null +++ b/apps/app/src/app/bundles/sources.ts @@ -0,0 +1,157 @@ +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; + +import type { OpenworkServerClient } from "../lib/openwork-server"; +import { isTauriRuntime, safeStringify } from "../utils"; +import { parseBundlePayload } from "./schema"; +import type { BundleImportIntent, BundleRequest, BundleV1 } from "./types"; +import { extractBundleId, isConfiguredBundlePublisherUrl } from "./url-policy"; + +function isSupportedDeepLinkProtocol(protocol: string): boolean { + const normalized = protocol.toLowerCase(); + return normalized === "openwork:" || normalized === "openwork-dev:" || normalized === "https:" || normalized === "http:"; +} + +export function normalizeBundleImportIntent(value: string | null | undefined): BundleImportIntent { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") { + return "new_worker"; + } + return "import_current"; +} + +export function parseBundleDeepLink(rawUrl: string): BundleRequest | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + const looksLikeImportRoute = routeHost === "import-bundle" || routePath === "import-bundle" || routeTail === "import-bundle"; + + const rawBundleUrl = url.searchParams.get("ow_bundle") ?? url.searchParams.get("bundleUrl") ?? ""; + if (!looksLikeImportRoute && !rawBundleUrl.trim()) { + return null; + } + + try { + if ((protocol === "https:" || protocol === "http:") && !rawBundleUrl.trim()) { + if (isConfiguredBundlePublisherUrl(url.toString())) { + return { + bundleUrl: url.toString(), + intent: normalizeBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent")), + source: url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? undefined, + label: url.searchParams.get("ow_label")?.trim() ?? url.searchParams.get("label")?.trim() ?? undefined, + }; + } + } + + const parsedBundleUrl = new URL(rawBundleUrl.trim()); + if (parsedBundleUrl.protocol !== "https:" && parsedBundleUrl.protocol !== "http:") { + return null; + } + return { + bundleUrl: parsedBundleUrl.toString(), + intent: normalizeBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent")), + source: url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? undefined, + label: url.searchParams.get("ow_label")?.trim() ?? url.searchParams.get("label")?.trim() ?? undefined, + }; + } catch { + return null; + } +} + +export function stripBundleQuery(rawUrl: string): string | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + let changed = false; + for (const key of ["ow_bundle", "bundleUrl", "ow_intent", "intent", "ow_source", "source", "ow_org", "ow_label"]) { + if (url.searchParams.has(key)) { + url.searchParams.delete(key); + changed = true; + } + } + + if (!changed) { + return null; + } + + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; +} + +export async function fetchBundle( + bundleUrl: string, + serverClient?: OpenworkServerClient | null, + options?: { forceClientFetch?: boolean }, +): Promise { + let targetUrl: URL; + try { + targetUrl = new URL(bundleUrl); + } catch { + throw new Error("Invalid bundle URL."); + } + + if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") { + throw new Error("Bundle URL must use http(s)."); + } + + const bundleId = extractBundleId(targetUrl); + if (bundleId) { + targetUrl.pathname = `/b/${bundleId}/data`; + targetUrl.searchParams.delete("format"); + } + + if (!targetUrl.searchParams.has("format")) { + targetUrl.searchParams.set("format", "json"); + } + + if (serverClient && !options?.forceClientFetch) { + return parseBundlePayload(await serverClient.fetchBundle(targetUrl.toString())); + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15_000); + + try { + let response: Response; + try { + response = isTauriRuntime() + ? await tauriFetch(targetUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }) + : await fetch(targetUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + throw new Error(`Failed to load bundle from ${targetUrl.toString()}: ${message}`); + } + if (!response.ok) { + const details = (await response.text()).trim(); + const suffix = details ? `: ${details}` : ""; + throw new Error(`Failed to fetch bundle from ${targetUrl.toString()} (${response.status})${suffix}`); + } + return parseBundlePayload(await response.json()); + } finally { + window.clearTimeout(timeout); + } +} diff --git a/apps/app/src/app/bundles/start-modal.tsx b/apps/app/src/app/bundles/start-modal.tsx new file mode 100644 index 00000000..fdf9aced --- /dev/null +++ b/apps/app/src/app/bundles/start-modal.tsx @@ -0,0 +1,149 @@ +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; + +import { FolderPlus, Loader2, Rocket, X } from "lucide-solid"; + +import Button from "../components/button"; + +export default function BundleStartModal(props: { + open: boolean; + templateName: string; + description?: string | null; + items?: string[]; + busy?: boolean; + onClose: () => void; + onPickFolder: () => Promise; + onConfirm: (folder: string | null) => void | Promise; +}) { + let pickFolderRef: HTMLButtonElement | undefined; + const [selectedFolder, setSelectedFolder] = createSignal(null); + const [pickingFolder, setPickingFolder] = createSignal(false); + + createEffect(() => { + if (!props.open) return; + setSelectedFolder(null); + requestAnimationFrame(() => pickFolderRef?.focus()); + }); + + createEffect(() => { + if (!props.open) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + if (props.busy) return; + props.onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + + const visibleItems = createMemo(() => (props.items ?? []).filter(Boolean).slice(0, 4)); + const hiddenItemCount = createMemo(() => Math.max(0, (props.items ?? []).filter(Boolean).length - visibleItems().length)); + const canSubmit = createMemo(() => Boolean(selectedFolder()?.trim()) && !props.busy && !pickingFolder()); + + const handlePickFolder = async () => { + if (pickingFolder() || props.busy) return; + setPickingFolder(true); + try { + const next = await props.onPickFolder(); + if (next) setSelectedFolder(next); + } finally { + setPickingFolder(false); + } + }; + + return ( + +
+
+
+
+
+
+ +
+
+

Start with {props.templateName}

+

+ {props.description?.trim() || "Pick a folder and OpenWork will create a workspace from this template."} +

+
+
+ +
+ + 0}> +
+ + {(item) => ( + + {item} + + )} + + 0}> + + +{hiddenItemCount()} more + + +
+
+
+ +
+
+
Workspace folder
+

+ Choose where this template should live. OpenWork will create the workspace and bring in the template automatically. +

+
+ No folder selected yet.}> + {selectedFolder()} + +
+
+ +
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/bundles/store.ts b/apps/app/src/app/bundles/store.ts new file mode 100644 index 00000000..4799f5d8 --- /dev/null +++ b/apps/app/src/app/bundles/store.ts @@ -0,0 +1,886 @@ +import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from "solid-js"; + +import type { + ReloadReason, + ReloadTrigger, + SettingsTab, + View, + WorkspacePreset, +} from "../types"; +import { normalizeOpenworkServerUrl, parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server"; +import { isTauriRuntime, safeStringify, addOpencodeCacheHint } from "../utils"; +import type { WorkspaceStore } from "../context/workspace"; +import type { StartupPreference } from "../types"; +import type { OpenworkServerStore } from "../connections/openwork-server-store"; +import { + buildImportPayloadFromBundle, + describeWorkspaceForBundleToasts, + isBundleImportWorkspace, + resolveBundleImportTargetForWorkspace, +} from "./apply"; +import { defaultPresetFromWorkspaceProfileBundle, describeBundleImport, parseBundlePayload } from "./schema"; +import { fetchBundle, parseBundleDeepLink } from "./sources"; +import { describeBundleUrlTrust } from "./url-policy"; +import type { + BundleCreateWorkspaceRequest, + BundleImportChoice, + BundleImportSummary, + BundleImportTarget, + BundleRequest, + BundleStartRequest, + BundleWorkerOption, + BundleV1, + SkillDestinationRequest, + WorkspaceProfileBundleV1, +} from "./types"; +import type { AppStatusToastInput } from "../shell/status-toasts"; + +type BundleProcessResult = + | { mode: "choice"; bundle: BundleV1 } + | { mode: "start_modal"; bundle: BundleV1 } + | { mode: "blocked_import_current"; bundle: BundleV1 } + | { mode: "blocked_new_worker"; bundle: BundleV1 } + | { mode: "untrusted_warning" } + | { mode: "imported"; bundle: BundleV1 }; + +type UntrustedBundleWarning = { + request: BundleRequest; + actualOrigin: string | null; + configuredOrigin: string | null; +}; + +export type BundlesStore = ReturnType; + +export function createBundlesStore(options: { + booting: Accessor; + startupPreference: Accessor; + openworkServer: OpenworkServerStore; + runtimeWorkspaceId: Accessor; + workspaceStore: WorkspaceStore; + setError: (value: string | null) => void; + error: Accessor; + setView: (next: View, sessionId?: string) => void; + setSettingsTab: (nextTab: SettingsTab) => void; + refreshActiveWorkspaceServerConfig: (workspaceId: string) => Promise; + refreshSkills: (input?: { force?: boolean }) => Promise; + refreshHubSkills: (input?: { force?: boolean }) => Promise; + markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void; + showStatusToast: (toast: AppStatusToastInput) => void; +}) { + const [pendingBundleRequest, setPendingBundleRequest] = createSignal(null); + const [bundleStartRequest, setBundleStartRequest] = createSignal(null); + const [bundleStartBusy, setBundleStartBusy] = createSignal(false); + const [createWorkspaceRequest, setCreateWorkspaceRequest] = createSignal(null); + const [skillDestinationRequest, setSkillDestinationRequest] = createSignal(null); + const [skillDestinationBusyId, setSkillDestinationBusyId] = createSignal(null); + const [bundleImportChoice, setBundleImportChoice] = createSignal(null); + const [bundleImportBusy, setBundleImportBusy] = createSignal(false); + const [bundleImportError, setBundleImportError] = createSignal(null); + const [bundleNoticeShown, setBundleNoticeShown] = createSignal(false); + const [untrustedBundleWarning, setUntrustedBundleWarning] = createSignal(null); + + const showSkillSuccessToast = (toast: { title: string; description: string }) => { + options.showStatusToast({ + ...toast, + tone: "success", + durationMs: 4200, + }); + }; + + const resetInteractiveBundleState = () => { + setSkillDestinationRequest(null); + setSkillDestinationBusyId(null); + setBundleImportChoice(null); + setBundleStartRequest(null); + setCreateWorkspaceRequest(null); + setBundleImportError(null); + setBundleNoticeShown(false); + setUntrustedBundleWarning(null); + }; + + const maybeWarnAboutUntrustedBundle = (request: BundleRequest, options?: { allowUntrustedClientFetch?: boolean }) => { + const rawUrl = request.bundleUrl?.trim() ?? ""; + if (!rawUrl || options?.allowUntrustedClientFetch) return false; + const trust = describeBundleUrlTrust(rawUrl); + if (trust.trusted) return false; + setUntrustedBundleWarning({ + request, + actualOrigin: trust.actualOrigin, + configuredOrigin: trust.configuredOrigin, + }); + return true; + }; + + const resolveBundleWorkerTarget = () => { + const pref = options.startupPreference(); + const hostInfo = options.openworkServer.openworkServerHostInfo(); + const settings = options.openworkServer.openworkServerSettings(); + + const localHostUrl = normalizeOpenworkServerUrl(hostInfo?.baseUrl ?? "") ?? ""; + const localToken = hostInfo?.clientToken?.trim() ?? ""; + const serverHostUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; + const serverToken = settings.token?.trim() ?? ""; + + if (pref === "server") { + return { + hostUrl: serverHostUrl || localHostUrl, + token: serverToken || localToken, + }; + } + + if (pref === "local") { + return { + hostUrl: localHostUrl || serverHostUrl, + token: localToken || serverToken, + }; + } + + if (localHostUrl) { + return { + hostUrl: localHostUrl, + token: localToken || serverToken, + }; + } + + return { + hostUrl: serverHostUrl, + token: serverToken || localToken, + }; + }; + + const resolveActiveBundleImportTarget = (): BundleImportTarget => { + const active = options.workspaceStore.selectedWorkspaceDisplay(); + if (active.workspaceType === "local") { + return { localRoot: options.workspaceStore.selectedWorkspaceRoot().trim() }; + } + + return { + workspaceId: + active.openworkWorkspaceId?.trim() || + parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || + parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || + null, + directoryHint: active.directory?.trim() || active.path?.trim() || null, + }; + }; + + const waitForBundleImportTarget = async (timeoutMs = 20_000, target?: BundleImportTarget) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const client = options.openworkServer.openworkServerClient(); + if (client && options.openworkServer.openworkServerStatus() === "connected") { + if (target?.workspaceId?.trim() || target?.localRoot?.trim() || target?.directoryHint?.trim()) { + try { + const matchId = await options.workspaceStore.ensureRuntimeWorkspaceId({ + workspaceId: target.workspaceId, + localRoot: target.localRoot, + directoryHint: target.directoryHint, + strictMatch: true, + }); + if (matchId) { + return { client, workspaceId: matchId }; + } + } catch { + // ignore and keep polling + } + } else { + const workspaceId = options.runtimeWorkspaceId(); + if (workspaceId) { + return { client, workspaceId }; + } + } + } + await new Promise((resolve) => { + window.setTimeout(resolve, 200); + }); + } + throw new Error("OpenWork worker is not ready yet."); + }; + + const importBundlePayload = async (bundle: BundleV1, target?: BundleImportTarget) => { + const { client, workspaceId } = await waitForBundleImportTarget(20_000, target); + const { payload, importedSkillsCount } = buildImportPayloadFromBundle(bundle); + await client.importWorkspace(workspaceId, payload); + await options.refreshActiveWorkspaceServerConfig(workspaceId); + await options.refreshSkills({ force: true }); + await options.refreshHubSkills({ force: true }); + if (importedSkillsCount > 0) { + options.markReloadRequired("skills", { + type: "skill", + name: bundle.name?.trim() || undefined, + action: "added", + }); + } + }; + + const importBundleIntoActiveWorker = async ( + request: BundleRequest, + target?: BundleImportTarget, + bundleOverride?: BundleV1, + importOptions?: { allowUntrustedClientFetch?: boolean }, + ) => { + try { + const bundle = + bundleOverride ?? + (await fetchBundle(request.bundleUrl?.trim() ?? "", options.openworkServer.openworkServerClient(), { + forceClientFetch: importOptions?.allowUntrustedClientFetch, + })); + await importBundlePayload(bundle, target); + options.setError(null); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } + }; + + const createWorkerForBundle = async (request: BundleRequest, bundle: BundleV1) => { + const target = resolveBundleWorkerTarget(); + const hostUrl = target.hostUrl.trim(); + const token = target.token.trim(); + if (!hostUrl || !token) { + throw new Error("Bundle link detected. Configure an OpenWork worker host and token, then open the link again."); + } + + const label = (request.label?.trim() || bundle.name?.trim() || "Shared setup").slice(0, 80); + const ok = await options.workspaceStore.createRemoteWorkspaceFlow({ + openworkHostUrl: hostUrl, + openworkToken: token, + directory: null, + displayName: label, + manageBusy: false, + closeModal: false, + }); + + if (!ok) { + throw new Error("Failed to create a worker from this bundle."); + } + }; + + const startWorkspaceFromBundle = async (folder: string | null) => { + const request = bundleStartRequest(); + if (!request || bundleStartBusy()) return false; + + setBundleStartBusy(true); + try { + const ok = await options.workspaceStore.createWorkspaceFlow(request.defaultPreset, folder); + if (!ok) return false; + + const imported = await importBundleIntoActiveWorker( + request.request, + { + localRoot: options.workspaceStore.selectedWorkspaceRoot().trim(), + }, + request.bundle, + ); + if (!imported) return false; + + setBundleStartRequest(null); + options.setError(null); + return true; + } finally { + setBundleStartBusy(false); + } + }; + + const createWorkspaceFromBundle = async ( + bundle: WorkspaceProfileBundleV1, + folder: string | null, + defaultPreset = defaultPresetFromWorkspaceProfileBundle(bundle), + ) => { + const request: BundleRequest = { + intent: "new_worker", + source: "team-template", + label: bundle.name, + }; + + const ok = await options.workspaceStore.createWorkspaceFlow(defaultPreset, folder); + if (!ok) return false; + + return importBundleIntoActiveWorker( + request, + { + localRoot: options.workspaceStore.selectedWorkspaceRoot().trim(), + }, + bundle, + ); + }; + + const importSkillIntoWorkspace = async (workspaceId: string) => { + if (skillDestinationBusyId()) return; + const destination = skillDestinationRequest(); + if (!destination) return; + + const workspace = options.workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; + if (!isBundleImportWorkspace(workspace)) { + options.setError("This worker cannot accept imported skills yet."); + return; + } + + options.setView("settings"); + options.setSettingsTab("skills"); + options.setError(null); + setSkillDestinationBusyId(workspaceId); + + try { + const ok = await options.workspaceStore.activateWorkspace(workspaceId); + if (!ok) return; + + const imported = await importBundleIntoActiveWorker( + destination.request, + resolveBundleImportTargetForWorkspace(workspace), + destination.bundle, + ); + if (!imported) return; + + showSkillSuccessToast({ + title: "Skill added", + description: `Added '${destination.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForBundleToasts(workspace)}.`, + }); + setSkillDestinationRequest(null); + setCreateWorkspaceRequest(null); + setBundleNoticeShown(false); + } finally { + setSkillDestinationBusyId(null); + } + }; + + const processBundleRequest = async ( + request: BundleRequest, + processOptions?: { allowUntrustedClientFetch?: boolean }, + ): Promise => { + if (maybeWarnAboutUntrustedBundle(request, processOptions)) { + return { mode: "untrusted_warning" }; + } + + const bundle = await fetchBundle(request.bundleUrl?.trim() ?? "", options.openworkServer.openworkServerClient(), { + forceClientFetch: processOptions?.allowUntrustedClientFetch, + }); + + if (bundle.type === "skill") { + options.setView("settings"); + options.setSettingsTab("skills"); + options.setError(null); + setSkillDestinationRequest({ request, bundle }); + return { mode: "choice", bundle }; + } + + if (bundle.type === "skills-set") { + options.setView("settings"); + options.setSettingsTab("skills"); + options.setError(null); + setBundleImportChoice({ request, bundle }); + return { mode: "choice", bundle }; + } + + if (request.intent === "new_worker" && isTauriRuntime()) { + options.setView("settings"); + options.setSettingsTab("skills"); + options.setError(null); + setCreateWorkspaceRequest(null); + setBundleImportChoice(null); + setBundleStartRequest({ + request, + bundle, + defaultPreset: defaultPresetFromWorkspaceProfileBundle(bundle), + }); + return { mode: "start_modal", bundle }; + } + + if (request.intent === "import_current") { + const client = options.openworkServer.openworkServerClient(); + const connected = options.openworkServer.openworkServerStatus() === "connected"; + const target = resolveActiveBundleImportTarget(); + const hasTargetHint = Boolean(target.workspaceId?.trim() || target.localRoot?.trim() || target.directoryHint?.trim()); + if (!client || !connected || !hasTargetHint) { + if (!bundleNoticeShown()) { + setBundleNoticeShown(true); + options.setError("Bundle link detected. Connect to a writable OpenWork worker to import this bundle."); + } + return { mode: "blocked_import_current", bundle }; + } + } else { + const target = resolveBundleWorkerTarget(); + if (!target.hostUrl.trim() || !target.token.trim()) { + if (!bundleNoticeShown()) { + setBundleNoticeShown(true); + options.setError("Bundle link detected. Configure an OpenWork host and token to create a new worker."); + } + return { mode: "blocked_new_worker", bundle }; + } + } + + if (request.intent === "new_worker") { + await createWorkerForBundle(request, bundle); + } + + await importBundlePayload(bundle, resolveActiveBundleImportTarget()); + options.setError(null); + return { mode: "imported", bundle }; + }; + + createEffect(() => { + const request = pendingBundleRequest(); + if (!request || options.booting()) { + return; + } + + if (untrack(bundleImportBusy)) { + return; + } + + let cancelled = false; + setBundleImportBusy(true); + + void (async () => { + try { + await processBundleRequest(request); + if (cancelled) return; + } catch (error) { + if (!cancelled) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + } + } finally { + if (!cancelled) { + const nextPendingRequest = pendingBundleRequest(); + const shouldClearPendingRequest = nextPendingRequest === request; + setBundleImportBusy(false); + if (shouldClearPendingRequest) { + setPendingBundleRequest(null); + setBundleNoticeShown(false); + } else if (nextPendingRequest) { + setPendingBundleRequest({ ...nextPendingRequest }); + } + } + } + })(); + + onCleanup(() => { + cancelled = true; + }); + }); + + const queueBundleLink = (rawUrl: string): boolean => { + const parsed = parseBundleDeepLink(rawUrl); + if (!parsed) { + return false; + } + setPendingBundleRequest(parsed); + resetInteractiveBundleState(); + return true; + }; + + const openDebugBundleRequest = async (request: BundleRequest): Promise<{ ok: boolean; message: string }> => { + setPendingBundleRequest(null); + setBundleNoticeShown(false); + resetInteractiveBundleState(); + options.setError(null); + + try { + setBundleImportBusy(true); + const result = await processBundleRequest(request); + switch (result.mode) { + case "choice": + return { ok: true, message: "Opened the bundle import chooser." }; + case "start_modal": + return { ok: true, message: "Opened the template start flow." }; + case "blocked_import_current": + case "blocked_new_worker": + return { ok: false, message: options.error() || "The bundle needs more worker setup before it can open." }; + case "untrusted_warning": + return { ok: false, message: "Showed a security warning for an untrusted bundle link." }; + case "imported": + return { ok: true, message: "Imported the bundle into the current worker." }; + } + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + const friendly = addOpencodeCacheHint(message); + options.setError(friendly); + return { ok: false, message: friendly }; + } finally { + setBundleImportBusy(false); + } + }; + + const closeBundleImportChoice = () => { + if (bundleImportBusy()) return; + setBundleImportChoice(null); + setBundleImportError(null); + }; + + const dismissUntrustedBundleWarning = () => { + if (bundleImportBusy()) return; + setUntrustedBundleWarning(null); + }; + + const confirmUntrustedBundleWarning = async () => { + const warning = untrustedBundleWarning(); + if (!warning || bundleImportBusy()) return; + setUntrustedBundleWarning(null); + setBundleImportError(null); + options.setError(null); + + try { + setBundleImportBusy(true); + await processBundleRequest(warning.request, { allowUntrustedClientFetch: true }); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + const friendly = addOpencodeCacheHint(message); + options.setError(friendly); + } finally { + setBundleImportBusy(false); + } + }; + + const openTeamBundle = async (input: { + templateId?: string; + name: string; + templateData: unknown; + organizationName?: string | null; + }) => { + const bundle = parseBundlePayload(input.templateData); + options.setError(null); + options.setView("settings"); + options.setSettingsTab("general"); + setSkillDestinationBusyId(null); + setBundleImportError(null); + setBundleStartRequest(null); + setCreateWorkspaceRequest(null); + + if (bundle.type === "skill") { + setBundleImportChoice(null); + setSkillDestinationRequest({ + request: { + intent: "import_current", + source: "team-template", + label: input.name, + }, + bundle, + }); + return; + } + + setSkillDestinationRequest(null); + setBundleImportChoice({ + request: { + intent: "import_current", + source: "team-template", + label: input.name, + }, + bundle, + }); + }; + + const startWorkspaceFromTeamTemplate = async (input: { + name: string; + templateData: unknown; + folder: string | null; + preset?: WorkspacePreset; + }) => { + const bundle = parseBundlePayload(input.templateData); + if (bundle.type !== "workspace-profile") { + throw new Error("Only workspace templates can start a new workspace."); + } + + options.setError(null); + setSkillDestinationRequest(null); + setBundleImportChoice(null); + setBundleImportError(null); + setCreateWorkspaceRequest(null); + setBundleStartRequest(null); + + const imported = await createWorkspaceFromBundle( + bundle, + input.folder, + input.preset ?? defaultPresetFromWorkspaceProfileBundle(bundle), + ); + if (!imported) { + throw new Error(`Failed to create ${input.name} from template.`); + } + }; + + const bundleImportSummary = createMemo(() => { + const choice = bundleImportChoice(); + return choice ? describeBundleImport(choice.bundle) : null; + }); + + const bundleStartItems = createMemo(() => { + const request = bundleStartRequest(); + return request ? describeBundleImport(request.bundle).items : []; + }); + + const createWorkspaceDefaultPreset = createMemo(() => createWorkspaceRequest()?.defaultPreset ?? "starter"); + + const skillDestinationWorkspaces = createMemo(() => { + const activeId = options.workspaceStore.selectedWorkspaceId(); + return options.workspaceStore + .workspaces() + .filter((workspace) => isBundleImportWorkspace(workspace)) + .slice() + .sort((a, b) => { + if (a.id === activeId && b.id !== activeId) return -1; + if (b.id === activeId && a.id !== activeId) return 1; + const aLabel = + a.displayName?.trim() || + a.openworkWorkspaceName?.trim() || + a.name?.trim() || + a.directory?.trim() || + a.path?.trim() || + a.baseUrl?.trim() || + ""; + const bLabel = + b.displayName?.trim() || + b.openworkWorkspaceName?.trim() || + b.name?.trim() || + b.directory?.trim() || + b.path?.trim() || + b.baseUrl?.trim() || + ""; + return aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" }); + }); + }); + + const bundleWorkerOptions = createMemo(() => { + const selectedWorkspaceId = options.workspaceStore.selectedWorkspaceId().trim(); + const items = options.workspaceStore.workspaces().map((workspace) => { + let disabledReason: string | null = null; + if (!resolveBundleImportTargetForWorkspace(workspace)) { + disabledReason = + workspace.workspaceType === "remote" && workspace.remoteType !== "openwork" + ? "Only OpenWork-connected workers support direct bundle imports." + : "This worker is missing the info OpenWork needs to import the bundle."; + } + + const label = + workspace.displayName?.trim() || + workspace.openworkWorkspaceName?.trim() || + workspace.name?.trim() || + workspace.path?.trim() || + "Worker"; + const badge = + workspace.workspaceType === "remote" + ? workspace.sandboxBackend === "docker" || + Boolean(workspace.sandboxRunId?.trim()) || + Boolean(workspace.sandboxContainerName?.trim()) + ? "Sandbox" + : "Remote" + : "Local"; + const detail = + workspace.workspaceType === "local" + ? workspace.path?.trim() || "Local worker" + : workspace.directory?.trim() || workspace.baseUrl?.trim() || workspace.openworkHostUrl?.trim() || "Remote worker"; + + return { + id: workspace.id, + label, + detail, + badge, + current: workspace.id === selectedWorkspaceId, + disabledReason, + } satisfies BundleWorkerOption; + }); + + return items.sort((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }); + + const openCreateWorkspaceFromChoice = async () => { + const choice = bundleImportChoice(); + if (!choice || bundleImportBusy()) return; + + setBundleImportError(null); + options.setError(null); + + if (isTauriRuntime()) { + options.setView("settings"); + options.setSettingsTab("skills"); + setCreateWorkspaceRequest({ + request: choice.request, + bundle: choice.bundle, + defaultPreset: choice.bundle.type === "workspace-profile" ? defaultPresetFromWorkspaceProfileBundle(choice.bundle) : "starter", + }); + setBundleImportChoice(null); + options.workspaceStore.setCreateWorkspaceOpen(true); + return; + } + + setBundleImportBusy(true); + try { + await createWorkerForBundle(choice.request, choice.bundle); + await importBundlePayload(choice.bundle, resolveActiveBundleImportTarget()); + setBundleImportChoice(null); + options.setError(null); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + const friendly = addOpencodeCacheHint(message); + setBundleImportError(friendly); + options.setError(friendly); + } finally { + setBundleImportBusy(false); + } + }; + + const importBundleIntoExistingWorkspace = async (workspaceId: string) => { + const choice = bundleImportChoice(); + if (!choice || bundleImportBusy()) return; + + const workspace = options.workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; + if (!workspace) { + setBundleImportError("The selected worker is no longer available."); + return; + } + + const target = resolveBundleImportTargetForWorkspace(workspace); + if (!target) { + setBundleImportError("This worker cannot accept bundle imports yet."); + return; + } + + setBundleImportBusy(true); + setBundleImportError(null); + options.setError(null); + + try { + options.setView("settings"); + options.setSettingsTab(choice.bundle.type === "workspace-profile" ? "general" : "skills"); + const ok = await options.workspaceStore.activateWorkspace(workspace.id); + if (!ok) { + throw new Error(options.error() || `Failed to switch to ${workspace.displayName?.trim() || workspace.name || "the selected worker"}.`); + } + await importBundlePayload(choice.bundle, target); + setBundleImportChoice(null); + options.setError(null); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + const friendly = addOpencodeCacheHint(message); + setBundleImportError(friendly); + options.setError(friendly); + } finally { + setBundleImportBusy(false); + } + }; + + const openCreateWorkspaceFromSkillDestination = () => { + const request = skillDestinationRequest(); + if (!request) return; + options.setError(null); + setCreateWorkspaceRequest({ + request: request.request, + bundle: request.bundle, + defaultPreset: "minimal", + }); + options.workspaceStore.setCreateWorkspaceOpen(true); + }; + + const openRemoteConnectFromSkillDestination = () => { + options.setError(null); + options.workspaceStore.setCreateRemoteWorkspaceOpen(true); + }; + + const handleCreateWorkspaceConfirm = async (preset: WorkspacePreset, folder: string | null) => { + const request = createWorkspaceRequest(); + const ok = await options.workspaceStore.createWorkspaceFlow(preset, folder); + if (!ok || !request) return; + + const imported = await importBundleIntoActiveWorker( + request.request, + { + localRoot: options.workspaceStore.selectedWorkspaceRoot().trim(), + }, + request.bundle, + ); + setCreateWorkspaceRequest(null); + if (imported) { + if (request.bundle.type === "skill") { + showSkillSuccessToast({ + title: "Skill added", + description: `Added '${request.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForBundleToasts(options.workspaceStore.selectedWorkspaceDisplay())}.`, + }); + } + setSkillDestinationRequest(null); + } + }; + + const handleCreateSandboxConfirm = async (preset: WorkspacePreset, folder: string | null) => { + const request = createWorkspaceRequest(); + const ok = await options.workspaceStore.createSandboxFlow( + preset, + folder, + request + ? { + onReady: async () => { + const active = options.workspaceStore.selectedWorkspaceDisplay(); + await importBundleIntoActiveWorker( + request.request, + { + workspaceId: + active.openworkWorkspaceId?.trim() || + parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || + parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || + null, + directoryHint: active.directory?.trim() || active.path?.trim() || null, + }, + request.bundle, + ); + if (request.bundle.type === "skill") { + showSkillSuccessToast({ + title: "Skill added", + description: `Added '${request.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForBundleToasts(active)}.`, + }); + } + }, + } + : undefined, + ); + if (!ok) return; + setCreateWorkspaceRequest(null); + if (request) { + setSkillDestinationRequest(null); + } + }; + + return { + queueBundleLink, + openDebugBundleRequest, + openTeamBundle, + startWorkspaceFromTeamTemplate, + closeBundleImportChoice, + openCreateWorkspaceFromChoice, + importBundleIntoExistingWorkspace, + clearBundleStartRequest: () => { + if (bundleStartBusy()) return; + setBundleStartRequest(null); + }, + startWorkspaceFromBundle, + clearCreateWorkspaceRequest: () => setCreateWorkspaceRequest(null), + clearSkillDestinationRequest: () => { + if (skillDestinationBusyId()) return; + setSkillDestinationRequest(null); + }, + importSkillIntoWorkspace, + openCreateWorkspaceFromSkillDestination, + openRemoteConnectFromSkillDestination, + handleCreateWorkspaceConfirm, + handleCreateSandboxConfirm, + dismissUntrustedBundleWarning, + confirmUntrustedBundleWarning, + bundleImportChoice, + bundleImportSummary, + bundleWorkerOptions, + bundleImportBusy, + bundleImportError, + bundleStartRequest, + bundleStartItems, + bundleStartBusy, + createWorkspaceRequest, + createWorkspaceDefaultPreset, + untrustedBundleWarning, + skillDestinationRequest, + skillDestinationWorkspaces, + skillDestinationBusyId, + }; +} diff --git a/apps/app/src/app/bundles/types.ts b/apps/app/src/app/bundles/types.ts new file mode 100644 index 00000000..67f39cbb --- /dev/null +++ b/apps/app/src/app/bundles/types.ts @@ -0,0 +1,88 @@ +import type { OpenworkWorkspaceExport } from "../lib/openwork-server"; +import type { WorkspacePreset } from "../types"; + +export type SkillBundleItem = { + name: string; + description?: string; + content: string; + trigger?: string; +}; + +export type SkillBundleV1 = { + schemaVersion: 1; + type: "skill"; + name: string; + description?: string; + trigger?: string; + content: string; +}; + +export type SkillsSetBundleV1 = { + schemaVersion: 1; + type: "skills-set"; + name: string; + description?: string; + skills: SkillBundleItem[]; +}; + +export type WorkspaceProfileBundleV1 = { + schemaVersion: 1; + type: "workspace-profile"; + name: string; + description?: string; + workspace: OpenworkWorkspaceExport; +}; + +export type BundleV1 = SkillBundleV1 | SkillsSetBundleV1 | WorkspaceProfileBundleV1; + +export type BundleImportIntent = "new_worker" | "import_current"; + +export type BundleRequest = { + bundleUrl?: string | null; + intent: BundleImportIntent; + source?: string; + label?: string; +}; + +export type BundleImportTarget = { + workspaceId?: string | null; + localRoot?: string | null; + directoryHint?: string | null; +}; + +export type BundleCreateWorkspaceRequest = { + request: BundleRequest; + bundle: BundleV1; + defaultPreset: WorkspacePreset; +}; + +export type BundleStartRequest = { + request: BundleRequest; + bundle: WorkspaceProfileBundleV1; + defaultPreset: WorkspacePreset; +}; + +export type SkillDestinationRequest = { + request: BundleRequest; + bundle: SkillBundleV1; +}; + +export type BundleImportChoice = { + request: BundleRequest; + bundle: BundleV1; +}; + +export type BundleWorkerOption = { + id: string; + label: string; + detail: string; + badge: string; + current: boolean; + disabledReason?: string | null; +}; + +export type BundleImportSummary = { + title: string; + description: string; + items: string[]; +}; diff --git a/apps/app/src/app/bundles/url-policy.ts b/apps/app/src/app/bundles/url-policy.ts new file mode 100644 index 00000000..c40bd2bc --- /dev/null +++ b/apps/app/src/app/bundles/url-policy.ts @@ -0,0 +1,49 @@ +import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL } from "../lib/publisher"; + +export type BundleUrlTrust = { + trusted: boolean; + bundleId: string | null; + actualOrigin: string | null; + configuredOrigin: string | null; +}; + +export function extractBundleId(url: URL): string | null { + const segments = url.pathname.split("/").filter(Boolean); + if (segments[0] === "b" && segments[1] && (segments.length === 2 || (segments.length === 3 && segments[2] === "data"))) { + return segments[1]; + } + return null; +} + +export function resolveConfiguredBundlePublisherOrigin(baseUrl = DEFAULT_OPENWORK_PUBLISHER_BASE_URL): string | null { + try { + return new URL(baseUrl).origin; + } catch { + return null; + } +} + +export function describeBundleUrlTrust(bundleUrl: string, baseUrl = DEFAULT_OPENWORK_PUBLISHER_BASE_URL): BundleUrlTrust { + const configuredOrigin = resolveConfiguredBundlePublisherOrigin(baseUrl); + try { + const url = new URL(bundleUrl); + const bundleId = extractBundleId(url); + return { + trusted: Boolean(configuredOrigin && url.origin === configuredOrigin && bundleId), + bundleId, + actualOrigin: url.origin, + configuredOrigin, + }; + } catch { + return { + trusted: false, + bundleId: null, + actualOrigin: null, + configuredOrigin, + }; + } +} + +export function isConfiguredBundlePublisherUrl(bundleUrl: string, baseUrl = DEFAULT_OPENWORK_PUBLISHER_BASE_URL): boolean { + return describeBundleUrlTrust(bundleUrl, baseUrl).trusted; +} diff --git a/apps/app/src/app/components/add-mcp-modal.tsx b/apps/app/src/app/components/add-mcp-modal.tsx index dd3431e8..9301c0e8 100644 --- a/apps/app/src/app/components/add-mcp-modal.tsx +++ b/apps/app/src/app/components/add-mcp-modal.tsx @@ -23,6 +23,7 @@ export default function AddMcpModal(props: AddMcpModalProps) { const [command, setCommand] = createSignal(""); const [oauthRequired, setOauthRequired] = createSignal(false); const [error, setError] = createSignal(null); + const [submitting, setSubmitting] = createSignal(false); const reset = () => { setName(""); @@ -34,11 +35,13 @@ export default function AddMcpModal(props: AddMcpModalProps) { }; const handleClose = () => { + if (submitting()) return; reset(); props.onClose(); }; - const handleSubmit = () => { + const handleSubmit = async () => { + if (submitting()) return; setError(null); const trimmedName = name().trim(); @@ -47,34 +50,46 @@ export default function AddMcpModal(props: AddMcpModalProps) { return; } + setSubmitting(true); + if (serverType() === "remote") { const trimmedUrl = url().trim(); if (!trimmedUrl) { setError(tr("mcp.url_or_command_required")); + setSubmitting(false); return; } - props.onAdd({ - name: trimmedName, - description: "", - type: "remote", - url: trimmedUrl, - oauth: oauthRequired(), - }); + try { + await Promise.resolve(props.onAdd({ + name: trimmedName, + description: "", + type: "remote", + url: trimmedUrl, + oauth: oauthRequired(), + })); + } finally { + setSubmitting(false); + } } else { const trimmedCommand = command().trim(); if (!trimmedCommand) { setError(tr("mcp.url_or_command_required")); + setSubmitting(false); return; } - props.onAdd({ - name: trimmedName, - description: "", - type: "local", - command: trimmedCommand.split(/\s+/), - oauth: false, - }); + try { + await Promise.resolve(props.onAdd({ + name: trimmedName, + description: "", + type: "local", + command: trimmedCommand.split(/\s+/), + oauth: false, + })); + } finally { + setSubmitting(false); + } } handleClose(); @@ -88,7 +103,10 @@ export default function AddMcpModal(props: AddMcpModalProps) { onClick={handleClose} /> -
+
event.stopPropagation()} + > {/* Header */}
@@ -159,15 +177,21 @@ export default function AddMcpModal(props: AddMcpModalProps) { value={url()} onInput={(e) => setUrl(e.currentTarget.value)} /> - +
+
{tr("mcp.sign_in_section_label")}
+ +
@@ -190,11 +214,11 @@ export default function AddMcpModal(props: AddMcpModalProps) { {/* Footer */}
- - - -
- -
-
-
-
- -
-
-
Remote server details
-
Use the URL your OpenWork server shared with you. Add a token only if the server needs one.
-
-
- -
- - - - -
- - - -
-
-
-
- -
- -
- {props.error} -
-
-
- - - - -
-
-
- ); - - return ( - -
- {content} -
-
- ); -} diff --git a/apps/app/src/app/components/create-workspace-modal.tsx b/apps/app/src/app/components/create-workspace-modal.tsx deleted file mode 100644 index 081ba181..00000000 --- a/apps/app/src/app/components/create-workspace-modal.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import { FolderPlus, Loader2, X, XCircle } from "lucide-solid"; -import { t, currentLocale } from "../../i18n"; -import type { WorkspacePreset } from "../types"; - -import Button from "./button"; - -export default function CreateWorkspaceModal(props: { - open: boolean; - onClose: () => void; - onConfirm: (preset: WorkspacePreset, folder: string | null) => void; - onConfirmWorker?: (preset: WorkspacePreset, folder: string | null) => void; - onPickFolder: () => Promise; - submitting?: boolean; - inline?: boolean; - showClose?: boolean; - defaultPreset?: WorkspacePreset; - title?: string; - subtitle?: string; - confirmLabel?: string; - workerLabel?: string; - workerDisabled?: boolean; - workerDisabledReason?: string | null; - workerCtaLabel?: string; - workerCtaDescription?: string; - onWorkerCta?: () => void; - workerRetryLabel?: string; - onWorkerRetry?: () => void; - workerDebugLines?: string[]; - workerSubmitting?: boolean; - submittingProgress?: { - runId: string; - startedAt: number; - stage: string; - error: string | null; - steps: Array<{ key: string; label: string; status: "pending" | "active" | "done" | "error"; detail?: string | null }>; - logs: string[]; - } | null; -}) { - let pickFolderRef: HTMLButtonElement | undefined; - const translate = (key: string) => t(key, currentLocale()); - - const [preset, setPreset] = createSignal(props.defaultPreset ?? "starter"); - const [selectedFolder, setSelectedFolder] = createSignal(null); - const [pickingFolder, setPickingFolder] = createSignal(false); - const [showProgressDetails, setShowProgressDetails] = createSignal(false); - const [now, setNow] = createSignal(Date.now()); - - createEffect(() => { - if (props.open) { - setPreset(props.defaultPreset ?? "starter"); - requestAnimationFrame(() => pickFolderRef?.focus()); - } - }); - - const handlePickFolder = async () => { - if (pickingFolder()) return; - setPickingFolder(true); - try { - await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); - const next = await props.onPickFolder(); - if (next) setSelectedFolder(next); - } finally { - setPickingFolder(false); - } - }; - - const showClose = () => props.showClose ?? true; - const title = () => props.title ?? translate("dashboard.create_workspace_title"); - const subtitle = () => props.subtitle ?? translate("dashboard.create_workspace_subtitle"); - const confirmLabel = () => props.confirmLabel ?? translate("dashboard.create_workspace_confirm"); - const workerLabel = () => props.workerLabel ?? translate("dashboard.create_sandbox_confirm"); - const isInline = () => props.inline ?? false; - const submitting = () => props.submitting ?? false; - const workerSubmitting = () => props.workerSubmitting ?? false; - const progress = createMemo(() => props.submittingProgress ?? null); - const provisioning = createMemo(() => submitting() && Boolean(progress())); - const workerDisabled = () => Boolean(props.workerDisabled); - const workerDisabledReason = () => (props.workerDisabledReason ?? "").trim(); - const showWorkerCallout = () => Boolean(props.onConfirmWorker && workerDisabled() && workerDisabledReason()); - const workerDebugLines = createMemo(() => (props.workerDebugLines ?? []).map((line) => line.trim()).filter(Boolean)); - const hasSelectedFolder = createMemo(() => Boolean(selectedFolder()?.trim())); - - createEffect(() => { - if (!submitting()) { - setShowProgressDetails(false); - return; - } - - const id = window.setInterval(() => setNow(Date.now()), 500); - onCleanup(() => window.clearInterval(id)); - }); - - const elapsedSeconds = createMemo(() => { - const current = progress(); - if (!current?.startedAt) return 0; - return Math.max(0, Math.floor((now() - current.startedAt) / 1000)); - }); - - const content = ( -
-
-
-

{title()}

-

{subtitle()}

-
- - - -
- -
-
-
-
Workspace folder
-
-
- - {selectedFolder()} - -
- -
-
- -
- - {(p) => ( -
-
-
-
- }> - - - Sandbox setup -
-
{p().stage}
-
{elapsedSeconds()}s
-
- -
- - - {(err) => ( -
- {err()} -
- )} -
- -
- - {(step) => { - const icon = () => { - if (step.status === "done") return ; - if (step.status === "active") return ; - if (step.status === "error") return ; - return
; - }; - - const textClass = () => { - if (step.status === "done") return "text-gray-11 font-medium"; - if (step.status === "active") return "text-gray-12 font-semibold"; - if (step.status === "error") return "text-red-11 font-medium"; - return "text-gray-9"; - }; - - return ( -
-
{icon()}
-
-
{step.label}
- -
- {step.detail} -
-
-
-
- ); - }} - -
- - 0}> -
-
-
Live Logs
-
-
- - {(line) =>
{line}
} -
-
-
-
-
- )} - - - -
-
{translate("dashboard.sandbox_get_ready_title")}
- -
{workerDisabledReason() || props.workerCtaDescription?.trim()}
-
-
- - - - - - -
- 0}> -
- Docker debug details -
- - {(line) =>
{line}
} -
-
-
-
-
-
- -
- - - - - - - -
-
-
- ); - - return ( - -
- {content} -
-
- ); -} diff --git a/apps/app/src/app/components/den-settings-panel.tsx b/apps/app/src/app/components/den-settings-panel.tsx index 174fe342..bf33410b 100644 --- a/apps/app/src/app/components/den-settings-panel.tsx +++ b/apps/app/src/app/components/den-settings-panel.tsx @@ -1,19 +1,25 @@ import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; -import { ArrowUpRight, Cloud, LogOut, RefreshCcw, Server, Users } from "lucide-solid"; +import { ArrowUpRight, Boxes, Cloud, LogOut, RefreshCcw, Server, Users } from "lucide-solid"; import Button from "./button"; import TextInput from "./text-input"; import { + buildDenAuthUrl, clearDenSession, DEFAULT_DEN_BASE_URL, DenApiError, + type DenTemplate, createDenClient, normalizeDenBaseUrl, readDenSettings, resolveDenBaseUrls, writeDenSettings, } from "../lib/den"; -import { isDesktopDeployment } from "../lib/openwork-deployment"; +import { + clearDenTemplateCache, + loadDenTemplateCache, + readDenTemplateCacheSnapshot, +} from "../lib/den-template-cache"; import { usePlatform } from "../context/platform"; type DenSettingsPanelProps = { @@ -24,6 +30,12 @@ type DenSettingsPanelProps = { directory?: string | null; displayName?: string | null; }) => Promise; + openTeamBundle: (input: { + templateId: string; + name: string; + templateData: unknown; + organizationName?: string | null; + }) => void | Promise; }; function statusBadgeClass(kind: "ready" | "warning" | "neutral" | "error") { @@ -74,10 +86,13 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const [authToken, setAuthToken] = createSignal(initial.authToken?.trim() || ""); const [activeOrgId, setActiveOrgId] = createSignal(initial.activeOrgId?.trim() || ""); const [authBusy, setAuthBusy] = createSignal(false); + const [manualAuthOpen, setManualAuthOpen] = createSignal(false); + const [manualAuthInput, setManualAuthInput] = createSignal(""); const [sessionBusy, setSessionBusy] = createSignal(false); const [orgsBusy, setOrgsBusy] = createSignal(false); const [workersBusy, setWorkersBusy] = createSignal(false); const [openingWorkerId, setOpeningWorkerId] = createSignal(null); + const [openingTemplateId, setOpeningTemplateId] = createSignal(null); const [user, setUser] = createSignal<{ id: string; email: string; @@ -101,16 +116,30 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const [authError, setAuthError] = createSignal(null); const [orgsError, setOrgsError] = createSignal(null); const [workersError, setWorkersError] = createSignal(null); + const [templateActionError, setTemplateActionError] = createSignal(null); const activeOrg = createMemo(() => orgs().find((org) => org.id === activeOrgId()) ?? null); const client = createMemo(() => createDenClient({ baseUrl: baseUrl(), token: authToken() }), ); const isSignedIn = createMemo(() => Boolean(user() && authToken().trim())); + const activeOrgName = createMemo(() => activeOrg()?.name || "No org selected"); + const templateCacheSnapshot = createMemo(() => + readDenTemplateCacheSnapshot({ + baseUrl: baseUrl(), + token: authToken(), + orgSlug: activeOrg()?.slug ?? null, + }), + ); + const templatesBusy = createMemo(() => templateCacheSnapshot().busy); + const templates = createMemo(() => templateCacheSnapshot().templates); + const templatesError = createMemo( + () => templateActionError() ?? templateCacheSnapshot().error, + ); const summaryTone = createMemo(() => { - if (authError() || workersError() || orgsError()) return "error" as const; - if (sessionBusy() || orgsBusy() || workersBusy()) return "warning" as const; + if (authError() || workersError() || orgsError() || templatesError()) return "error" as const; + if (sessionBusy() || orgsBusy() || workersBusy() || templatesBusy()) return "warning" as const; if (isSignedIn()) return "ready" as const; return "neutral" as const; }); @@ -127,6 +156,8 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL, authToken: authToken() || null, activeOrgId: activeOrgId() || null, + activeOrgSlug: activeOrg()?.slug ?? null, + activeOrgName: activeOrg()?.name ?? null, }); }); @@ -143,13 +174,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { }; const openBrowserAuth = (mode: "sign-in" | "sign-up") => { - const target = new URL(resolveDenBaseUrls(baseUrl()).baseUrl); - target.searchParams.set("mode", mode); - if (isDesktopDeployment()) { - target.searchParams.set("desktopAuth", "1"); - target.searchParams.set("desktopScheme", "openwork"); - } - platform.openLink(target.toString()); + platform.openLink(buildDenAuthUrl(baseUrl(), mode)); setStatusMessage( mode === "sign-up" ? "Finish account creation in your browser to connect OpenWork." @@ -158,6 +183,94 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { setAuthError(null); }; + const parseManualAuthInput = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + const url = new URL(trimmed); + const protocol = url.protocol.toLowerCase(); + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + if ( + (protocol === "openwork:" || protocol === "openwork-dev:") && + (routeHost === "den-auth" || routePath === "den-auth" || routeTail === "den-auth") + ) { + const grant = url.searchParams.get("grant")?.trim() ?? ""; + const nextBaseUrl = + normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? undefined; + return grant ? { grant, baseUrl: nextBaseUrl } : null; + } + } catch { + // treat non-URL input as a raw handoff grant + } + + return trimmed.length >= 12 ? { grant: trimmed } : null; + }; + + const submitManualAuth = async () => { + const parsed = parseManualAuthInput(manualAuthInput()); + if (!parsed || authBusy()) { + if (!parsed) { + setAuthError("Paste a valid OpenWork sign-in link or one-time sign-in code."); + } + return; + } + + const nextBaseUrl = parsed.baseUrl ?? baseUrl(); + + setAuthBusy(true); + setAuthError(null); + setStatusMessage("Finishing OpenWork Cloud sign-in..."); + + try { + const result = await createDenClient({ baseUrl: nextBaseUrl }).exchangeDesktopHandoff(parsed.grant); + if (!result.token) { + throw new Error("Desktop sign-in completed, but OpenWork Cloud did not return a session token."); + } + + if (props.developerMode) { + setBaseUrl(nextBaseUrl); + setBaseUrlDraft(nextBaseUrl); + } + + writeDenSettings({ + baseUrl: props.developerMode ? nextBaseUrl : DEFAULT_DEN_BASE_URL, + authToken: result.token, + activeOrgId: null, + activeOrgSlug: null, + activeOrgName: null, + }); + + setManualAuthInput(""); + setManualAuthOpen(false); + window.dispatchEvent( + new CustomEvent("openwork-den-session-updated", { + detail: { + status: "success", + email: result.user?.email ?? null, + }, + }), + ); + } catch (error) { + window.dispatchEvent( + new CustomEvent("openwork-den-session-updated", { + detail: { + status: "error", + message: + error instanceof Error + ? error.message + : "Failed to complete OpenWork Cloud sign-in.", + }, + }), + ); + } finally { + setAuthBusy(false); + } + }; + const clearSessionState = () => { setUser(null); setOrgs([]); @@ -165,16 +278,19 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { setActiveOrgId(""); setOrgsError(null); setWorkersError(null); + setTemplateActionError(null); }; const clearSignedInState = (message?: string | null) => { clearDenSession({ includeBaseUrls: !props.developerMode }); + clearDenTemplateCache(); if (!props.developerMode) { setBaseUrl(DEFAULT_DEN_BASE_URL); setBaseUrlDraft(DEFAULT_DEN_BASE_URL); } setAuthToken(""); setOpeningWorkerId(null); + setOpeningTemplateId(null); clearSessionState(); setBaseUrlError(null); setAuthError(null); @@ -184,7 +300,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const applyBaseUrl = () => { const normalized = normalizeDenBaseUrl(baseUrlDraft()); if (!normalized) { - setBaseUrlError("Enter a valid http:// or https:// Den control plane URL."); + setBaseUrlError("Enter a valid http:// or https:// Cloud control plane URL."); return; } @@ -197,7 +313,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { setBaseUrl(resolved.baseUrl); setBaseUrlDraft(resolved.baseUrl); - clearSignedInState("Updated the Den control plane URL. Sign in again to continue."); + clearSignedInState("Updated the Cloud control plane URL. Sign in again to continue."); }; const refreshOrgs = async (quiet = false) => { @@ -216,7 +332,15 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const current = activeOrgId().trim(); const fallback = response.defaultOrgId ?? response.orgs[0]?.id ?? ""; const next = response.orgs.some((org) => org.id === current) ? current : fallback; + const nextOrg = response.orgs.find((org) => org.id === next) ?? null; setActiveOrgId(next); + writeDenSettings({ + baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL, + authToken: authToken() || null, + activeOrgId: next || null, + activeOrgSlug: nextOrg?.slug ?? null, + activeOrgName: nextOrg?.name ?? null, + }); if (!quiet && response.orgs.length > 0) { setStatusMessage( `Loaded ${response.orgs.length} org${response.orgs.length === 1 ? "" : "s"}.`, @@ -256,6 +380,37 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { } }; + const refreshTemplates = async (quiet = false) => { + const orgSlug = activeOrg()?.slug?.trim() ?? ""; + if (!authToken().trim() || !orgSlug) { + return; + } + + setTemplateActionError(null); + + try { + const nextTemplates = await loadDenTemplateCache( + { + baseUrl: baseUrl(), + token: authToken(), + orgSlug, + }, + { force: true }, + ); + if (!quiet) { + setStatusMessage( + nextTemplates.length > 0 + ? `Loaded ${nextTemplates.length} template${nextTemplates.length === 1 ? "" : "s"} for ${activeOrg()?.name ?? "this org"}.` + : `No team templates found for ${activeOrg()?.name ?? "this org"}.`, + ); + } + } catch (error) { + if (!quiet) { + setTemplateActionError(error instanceof Error ? error.message : "Failed to load team templates."); + } + } + }; + createEffect(() => { const token = authToken().trim(); const currentBaseUrl = baseUrl(); @@ -286,7 +441,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { clearSessionState(); } setAuthError( - error instanceof Error ? error.message : "No active Den session found.", + error instanceof Error ? error.message : "No active Cloud session found.", ); }) .finally(() => { @@ -308,6 +463,11 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { void refreshWorkers(true); }); + createEffect(() => { + if (!user() || !activeOrg()?.slug?.trim()) return; + void refreshTemplates(true); + }); + createEffect(() => { const handler = (event: Event) => { const customEvent = event as CustomEvent<{ @@ -324,13 +484,13 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { setAuthError(null); setStatusMessage( customEvent.detail.email?.trim() - ? `Connected OpenWork Den as ${customEvent.detail.email.trim()}.` - : "Connected OpenWork Den.", + ? `Connected OpenWork Cloud as ${customEvent.detail.email.trim()}.` + : "Connected OpenWork Cloud.", ); } else if (customEvent.detail?.status === "error") { setAuthError( customEvent.detail.message?.trim() || - "Failed to finish OpenWork Den sign-in.", + "Failed to finish OpenWork Cloud sign-in.", ); } }; @@ -361,7 +521,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { } clearSignedInState( - "Signed out and cleared your OpenWork Den session on this device.", + "Signed out and cleared your OpenWork Cloud session on this device.", ); }; @@ -406,14 +566,58 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { } }; + const handleOpenTemplate = async (template: DenTemplate) => { + if (openingTemplateId()) return; + + setOpeningTemplateId(template.id); + setTemplateActionError(null); + + try { + await props.openTeamBundle({ + templateId: template.id, + name: template.name, + templateData: template.templateData, + organizationName: activeOrg()?.name ?? null, + }); + setStatusMessage(`Opened ${template.name} from ${activeOrg()?.name ?? "team templates"}.`); + } catch (error) { + setTemplateActionError(error instanceof Error ? error.message : `Failed to open ${template.name}.`); + } finally { + setOpeningTemplateId(null); + } + }; + + const formatTemplateTimestamp = (value: string | null) => { + if (!value) return "Recently updated"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Recently updated"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); + }; + + const templateCreatorLabel = (template: DenTemplate) => { + const creator = template.creator; + if (!creator) return "Unknown creator"; + return creator.name?.trim() || creator.email?.trim() || "Unknown creator"; + }; + const settingsPanelClass = - "rounded-[28px] border border-dls-border bg-dls-surface p-5 md:p-6"; + "ow-soft-card rounded-[28px] p-5 md:p-6"; const settingsPanelSoftClass = - "rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4"; + "ow-soft-card-quiet rounded-2xl p-4"; const headerBadgeClass = - "inline-flex min-h-8 items-center gap-2 rounded-xl border border-gray-6/60 bg-gray-1/40 px-3 text-[13px] font-medium text-dls-text"; + "inline-flex min-h-8 items-center gap-2 rounded-xl bg-[#f3f4f6] px-3 text-[13px] font-medium text-dls-text"; const headerStatusBadgeClass = - "inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-gray-6/60 bg-gray-1/40 px-3 text-[13px] leading-none font-medium text-dls-secondary"; + "inline-flex min-h-10 min-w-[132px] items-center justify-center gap-2 rounded-2xl bg-[#f3f4f6] px-4 text-center text-sm font-medium text-dls-text"; + const sectionPillClass = + "inline-flex items-center gap-1.5 rounded-full bg-[#f3f4f6] px-2.5 py-1 text-[11px] font-medium text-gray-11"; + const softNoticeClass = + "rounded-xl bg-[#f8fafc] px-3 py-2 text-xs text-gray-11"; + const quietControlClass = + "bg-white/90 text-dls-text border border-black/8 shadow-[0_1px_2px_rgba(17,24,39,0.06)]"; return (
@@ -422,14 +626,14 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
- OpenWork Den + OpenWork Cloud
- Sign in, pick an org, and open Den workers from Settings. + Sign in, pick an org, and open Cloud workers or team templates.
- Sign in to OpenWork Den to keep your tasks alive even when your + Sign in to OpenWork Cloud to keep your tasks alive even when your computer sleeps.
@@ -445,11 +649,11 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
setBaseUrlDraft(event.currentTarget.value)} placeholder={DEFAULT_DEN_BASE_URL} - hint="Developer mode only. Use this to target a local or self-hosted Den control plane. Changing it signs you out so the app can re-hydrate against the new control plane." + hint="Developer mode only. Use this to target a local or self-hosted Cloud control plane. Changing it signs you out so the app can re-hydrate against the new control plane." disabled={authBusy() || sessionBusy()} />
@@ -489,9 +693,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { )} - + {(value) => ( -
+
{value()}
)} @@ -500,15 +704,15 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
-
-
- Sign in to OpenWork Den +
+
+ Sign in to OpenWork Cloud +
+
+ Sign in to OpenWork Cloud to keep your tasks alive even when your + computer sleeps. +
-
- Sign in to OpenWork Den to keep your tasks alive even when your - computer sleeps. -
-
+
+ +
+ setManualAuthInput(event.currentTarget.value)} + placeholder="openwork://den-auth?... or pasted code" + disabled={authBusy() || sessionBusy()} + hint="If your browser doesn't bounce back into OpenWork automatically, paste the sign-in link or one-time code from OpenWork Cloud here." + /> +
+ +
+ Accepts an openwork://den-auth link or the raw one-time grant. +
+
+
+
+ {(value) => (
@@ -544,14 +785,14 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
-
Den Account
+
Cloud account
Manage your connected account and organization.
-
+
{user()?.name || user()?.email} @@ -562,7 +803,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
-
+
Active org
- Workers are scoped to the selected org. + Cloud workers and team templates are scoped to the selected org.
+
+
+ + + {(value) => ( +
+ {value()} +
+ )} +
+ + +
+ + No team templates yet. Use Share -> Template -> Share with team. + +
+
+ +
+ + {(template) => { + const isMine = () => template.creator?.userId === user()?.id; + const opening = () => openingTemplateId() === template.id; + return ( +
+
+
+ + {template.name} + + + Team template + + + + Mine + + +
+
+ by {templateCreatorLabel(template)} · {formatTemplateTimestamp(template.createdAt)} +
+
+ +
+ ); + }} +
+
+
diff --git a/apps/app/src/app/components/model-picker-modal.tsx b/apps/app/src/app/components/model-picker-modal.tsx index 4f208564..0ce5df73 100644 --- a/apps/app/src/app/components/model-picker-modal.tsx +++ b/apps/app/src/app/components/model-picker-modal.tsx @@ -328,7 +328,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {

{props.target === "default" - ? "Choose the default model for new chats. If a model supports reasoning profiles, configure them on its card." + ? "Choose the default model for new chats, then fine-tune reasoning profiles on its card before pressing Done." : "Choose the model for this chat. If a model supports reasoning profiles, configure them on its card."}

diff --git a/apps/app/src/app/components/onboarding-workspace-selector.tsx b/apps/app/src/app/components/onboarding-workspace-selector.tsx deleted file mode 100644 index 41ce4d60..00000000 --- a/apps/app/src/app/components/onboarding-workspace-selector.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { For, Show, createEffect, createSignal } from "solid-js"; - -import { CheckCircle2, FolderPlus, Loader2 } from "lucide-solid"; - -import Button from "./button"; - -export default function OnboardingWorkspaceSelector(props: { - defaultPath: string; - onConfirm: (preset: "starter" | "automation" | "minimal", folder: string | null) => void; - onPickFolder: () => Promise; -}) { - const [preset, setPreset] = createSignal<"starter" | "automation" | "minimal">("starter"); - const [selectedFolder, setSelectedFolder] = createSignal(props.defaultPath); - const [pickingFolder, setPickingFolder] = createSignal(false); - - const options = () => [ - { - id: "starter" as const, - name: "Starter worker", - desc: "Preconfigured to show you how to use plugins, commands, and skills.", - }, - { - id: "minimal" as const, - name: "Empty worker", - desc: "Start with a blank folder and add what you need.", - }, - ]; - - const canContinue = () => Boolean(selectedFolder().trim()); - - createEffect(() => { - if (!selectedFolder().trim()) { - setSelectedFolder(props.defaultPath); - } - }); - - const handlePickFolder = async () => { - if (pickingFolder()) return; - setPickingFolder(true); - try { - await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); - const next = await props.onPickFolder(); - if (next) { - setSelectedFolder(next); - } - } finally { - setPickingFolder(false); - } - }; - - return ( -
-
-
-
-
1
- Select Folder -
-
-
-
- - setSelectedFolder(e.currentTarget.value)} - placeholder={props.defaultPath} - /> - -
-
-
-
- -
-
-
2
- Choose Preset -
-
- - {(opt) => ( -
{ - if (!canContinue()) return; - setPreset(opt.id); - }} - class={`p-4 rounded-xl border cursor-pointer transition-all ${ - preset() === opt.id - ? "bg-gray-4 border-gray-6 hover:border-gray-7" - : "bg-gray-1/40 border-gray-6 hover:border-gray-7" - } ${!canContinue() ? "pointer-events-none" : ""}`.trim()} - > -
-
-
- {opt.name} -
-
{opt.desc}
-
- - - -
-
- )} -
-
-
-
- -
- ); -} diff --git a/apps/app/src/app/components/reload-workspace-toast.tsx b/apps/app/src/app/components/reload-workspace-toast.tsx index bf7da747..99bcf958 100644 --- a/apps/app/src/app/components/reload-workspace-toast.tsx +++ b/apps/app/src/app/components/reload-workspace-toast.tsx @@ -76,7 +76,7 @@ export default function ReloadWorkspaceToast(props: ReloadWorkspaceToastProps) { return ( -
+
void; +}; + +export default function ComposerNotice(props: { notice: ComposerNotice | null }) { + const tone = () => props.notice?.tone ?? "info"; + + return ( + + {(notice) => ( +
+
+
+ + ) : tone() === "error" ? ( + + ) : ( + + ) + } + > + + +
+ +
+
+ {notice().title} +
+ +

+ {notice().description} +

+
+ + + +
+
+
+ )} +
+ ); +} diff --git a/apps/app/src/app/components/session/composer.tsx b/apps/app/src/app/components/session/composer.tsx index eda2c670..0fce9bf9 100644 --- a/apps/app/src/app/components/session/composer.tsx +++ b/apps/app/src/app/components/session/composer.tsx @@ -3,6 +3,7 @@ import type { Agent } from "@opencode-ai/sdk/v2/client"; import fuzzysort from "fuzzysort"; import ProviderIcon from "../provider-icon"; import { ArrowUp, AtSign, Check, ChevronDown, File as FileIcon, Paperclip, Square, Terminal, X, Zap } from "lucide-solid"; +import ComposerNotice, { type ComposerNotice as ComposerNoticeData } from "./composer-notice"; import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode, SlashCommandOption } from "../../types"; import { perfNow, recordPerfLog } from "../../lib/perf-log"; @@ -30,7 +31,6 @@ type ComposerProps = { onSend: (draft: ComposerDraft) => void; onStop: () => void; onDraftChange: (draft: ComposerDraft) => void; - selectedProviderID?: string | null; selectedModelLabel: string; onModelClick: () => void; modelVariantLabel: string; @@ -46,10 +46,8 @@ type ComposerProps = { onToggleAgentPicker: () => void; onSelectAgent: (agent: string | null) => void; setAgentPickerRef: (el: HTMLDivElement) => void; - showNotionBanner: boolean; - onNotionBannerClick: () => void; - toast: string | null; - onToast: (message: string) => void; + notice: ComposerNoticeData | null; + onNotice: (notice: ComposerNoticeData) => void; listAgents: () => Promise; recentFiles: string[]; searchFiles: (query: string) => Promise; @@ -475,11 +473,7 @@ export default function Composer(props: ComposerProps) { const [attachments, setAttachments] = createSignal([]); const [draftText, setDraftText] = createSignal(normalizeText(props.prompt)); const [mode, setMode] = createSignal("prompt"); - const [historySnapshot, setHistorySnapshot] = createSignal(null); - const [historyIndex, setHistoryIndex] = createSignal({ prompt: -1, shell: -1 }); - const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] }); const [variantMenuOpen, setVariantMenuOpen] = createSignal(false); - const [showInboxUploadAction, setShowInboxUploadAction] = createSignal(false); const compactModelLabel = createMemo(() => props.selectedModelLabel.length > 20 ? `${props.selectedModelLabel.slice(0, 20)}...` : props.selectedModelLabel, ); @@ -670,8 +664,6 @@ export default function Composer(props: ComposerProps) { if (!value && current) { setEditorText(""); setAttachments([]); - setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 })); - setHistorySnapshot(null); queueMicrotask(() => focusEditorEnd()); } return; @@ -697,8 +689,6 @@ export default function Composer(props: ComposerProps) { setEditorText(value); if (!value) { setAttachments([]); - setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 })); - setHistorySnapshot(null); } // We don't emitDraftChange here usually, to avoid loops, but if we changed text we might need to? @@ -998,48 +988,6 @@ export default function Composer(props: ComposerProps) { emitDraftChange(); }; - const canNavigateHistory = () => { - if (!editorRef) return false; - const offsets = getSelectionOffsets(editorRef); - if (!offsets || offsets.start !== offsets.end) return false; - const total = readEditorText(editorRef).length; - return offsets.start === 0 || offsets.start === total; - }; - - const applyHistoryDraft = (draft: ComposerDraft | null) => { - if (!draft) return; - setMode(draft.mode); - renderParts(draft.parts, false); - setDraftText(draft.text); - setAttachments(draft.attachments ?? []); - props.onDraftChange(draft); - }; - - const navigateHistory = (direction: "up" | "down") => { - const key = mode(); - const list = history()[key]; - if (!list.length) return; - const index = historyIndex()[key]; - const nextIndex = direction === "up" ? index + 1 : index - 1; - if (nextIndex < -1 || nextIndex >= list.length) return; - - if (index === -1 && direction === "up") { - const parts = editorRef ? buildPartsFromEditor(editorRef, pasteTextById) : []; - const text = normalizeText(partsToText(parts)); - const resolvedText = normalizeText(partsToResolvedText(parts)); - setHistorySnapshot({ mode: key, parts, attachments: attachments(), text, resolvedText }); - } - - setHistoryIndex((current: { prompt: number; shell: number }) => ({ ...current, [key]: nextIndex })); - if (nextIndex === -1) { - applyHistoryDraft(historySnapshot()); - setHistorySnapshot(null); - return; - } - const target = list[list.length - 1 - nextIndex]; - applyHistoryDraft(target); - }; - const sendDraft = () => { // Ensure any pending debounce updates are committed before sending flushDraftChange(); @@ -1062,7 +1010,6 @@ export default function Composer(props: ComposerProps) { } } - recordHistory(draft); props.onSend(draft); setSlashOpen(false); setSlashQuery(""); @@ -1086,20 +1033,12 @@ export default function Composer(props: ComposerProps) { }); }; - const recordHistory = (draft: ComposerDraft) => { - const trimmed = draft.text.trim(); - if (!trimmed && !draft.attachments.length) return; - setHistory((current: { prompt: ComposerDraft[]; shell: ComposerDraft[] }) => ({ - ...current, - [draft.mode]: [...current[draft.mode], { ...draft, attachments: [] }], - })); - setHistoryIndex((current: { prompt: number; shell: number }) => ({ ...current, [draft.mode]: -1 })); - setHistorySnapshot(null); - }; - const addAttachments = async (files: File[]) => { if (attachmentsDisabled()) { - props.onToast(props.attachmentsDisabledReason ?? "Attachments are unavailable."); + props.onNotice({ + title: props.attachmentsDisabledReason ?? "Attachments are unavailable.", + tone: "warning", + }); return; } const supportedFiles = files.filter((file) => isSupportedAttachmentType(file.type)); @@ -1116,7 +1055,10 @@ export default function Composer(props: ComposerProps) { const next: ComposerAttachment[] = []; for (const file of supportedFiles) { if (file.size > MAX_ATTACHMENT_BYTES) { - props.onToast(`${file.name} exceeds the 8MB limit.`); + props.onNotice({ + title: `${file.name} exceeds the 8MB limit.`, + tone: "warning", + }); continue; } try { @@ -1124,7 +1066,10 @@ export default function Composer(props: ComposerProps) { const processed = isImageMime(file.type) ? await compressImageFile(file) : file; const estimatedJsonBytes = estimateInlineAttachmentBytes(processed); if (estimatedJsonBytes > MAX_ATTACHMENT_BYTES) { - props.onToast(`${file.name} is too large after encoding. Try a smaller image.`); + props.onNotice({ + title: `${file.name} is too large after encoding. Try a smaller image.`, + tone: "warning", + }); continue; } next.push({ @@ -1137,7 +1082,10 @@ export default function Composer(props: ComposerProps) { previewUrl: isImageMime(processed.type) ? createObjectUrl(processed) : undefined, }); } catch (error) { - props.onToast(error instanceof Error ? error.message : "Failed to read attachment"); + props.onNotice({ + title: error instanceof Error ? error.message : "Failed to read attachment", + tone: "error", + }); } } if (next.length) { @@ -1248,29 +1196,32 @@ export default function Composer(props: ComposerProps) { updateMentionQuery(); updateSlashQuery(); emitDraftChange(); - props.onToast( - links.length === 1 - ? `Uploaded ${links[0].name} to the shared folder and inserted a link.` - : `Uploaded ${links.length} files to the shared folder and inserted links.`, - ); + props.onNotice({ + title: + links.length === 1 + ? `Uploaded ${links[0].name} to the shared folder and inserted a link.` + : `Uploaded ${links.length} files to the shared folder and inserted links.`, + tone: "success", + }); return; } } - props.onToast( - "Couldn't upload to the shared folder. Inserted local links instead.", - ); + props.onNotice({ + title: "Couldn't upload to the shared folder. Inserted local links instead.", + tone: "warning", + }); } const text = formatLinks(fallbackLinks()); if (!text) { - props.onToast("Unsupported attachment type."); + props.onNotice({ title: "Unsupported attachment type.", tone: "warning" }); return; } insertPlainTextAtSelection(text); updateMentionQuery(); updateSlashQuery(); emitDraftChange(); - props.onToast("Inserted links for unsupported files."); + props.onNotice({ title: "Inserted links for unsupported files.", tone: "info" }); }; const handlePaste = (event: ClipboardEvent) => { @@ -1303,10 +1254,13 @@ export default function Composer(props: ComposerProps) { const hasAbsolutePosix = /(^|\s)\/(Users|home|var|etc|opt|tmp|private|Volumes|Applications)\//.test(trimmedForCheck); const hasAbsoluteWindows = /(^|\s)[a-zA-Z]:\\/.test(trimmedForCheck); if (hasFileUrl || hasAbsolutePosix || hasAbsoluteWindows) { - props.onToast( + props.onNotice({ + title: "This is a remote worker. Sandboxes are remote too. To share files with it, upload them to the Shared folder in the sidebar.", - ); - setShowInboxUploadAction(Boolean(props.onUploadInboxFiles)); + tone: "warning", + actionLabel: props.onUploadInboxFiles ? "Upload to shared folder" : undefined, + onAction: props.onUploadInboxFiles ? () => inboxFileInputRef?.click() : undefined, + }); } } @@ -1329,12 +1283,6 @@ export default function Composer(props: ComposerProps) { emitDraftChange(); }; - createEffect(() => { - if (!props.toast) { - setShowInboxUploadAction(false); - } - }); - const handleDrop = (event: DragEvent) => { if (!event.dataTransfer) return; event.preventDefault(); @@ -1483,14 +1431,6 @@ export default function Composer(props: ComposerProps) { return; } - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - if (canNavigateHistory()) { - event.preventDefault(); - navigateHistory(event.key === "ArrowUp" ? "up" : "down"); - return; - } - } - if (event.key === "Enter") { event.preventDefault(); if (props.busy) return; @@ -1693,17 +1633,6 @@ export default function Composer(props: ComposerProps) {
- - - -
@@ -1742,22 +1671,7 @@ export default function Composer(props: ComposerProps) {
- -
-
- {props.toast} - - - -
-
-
+
diff --git a/apps/app/src/app/components/session/context-panel.tsx b/apps/app/src/app/components/session/context-panel.tsx index e89ffe40..40633ca7 100644 --- a/apps/app/src/app/components/session/context-panel.tsx +++ b/apps/app/src/app/components/session/context-panel.tsx @@ -1,16 +1,14 @@ import { For, Show } from "solid-js"; import { ChevronDown, Circle, File, Folder, Package } from "lucide-solid"; +import { useConnections } from "../../connections/provider"; import { SUGGESTED_PLUGINS } from "../../constants"; -import type { McpServerEntry, McpStatus, McpStatusMap, SkillCard } from "../../types"; +import type { McpStatus, SkillCard } from "../../types"; import { stripPluginVersion } from "../../utils/plugins"; export type ContextPanelProps = { activePlugins: string[]; activePluginStatus: string | null; - mcpServers: McpServerEntry[]; - mcpStatuses: McpStatusMap; - mcpStatus: string | null; skills: SkillCard[]; skillsStatus: string | null; authorizedDirs: string[]; @@ -141,6 +139,7 @@ const mcpStatusDot = (status?: McpStatus, disabled?: boolean) => { }; export default function ContextPanel(props: ContextPanelProps) { + const connections = useConnections(); const displayFiles = () => props.workingFiles.map((entry) => toWorkspaceRelative(entry, props.workspaceRoot)); @@ -264,16 +263,16 @@ export default function ContextPanel(props: ContextPanelProps) {
- {props.mcpStatus ?? "No MCP servers loaded."} + {connections.mcpStatus() ?? "No MCP servers loaded."}
} > - + {(entry) => { - const status = () => props.mcpStatuses[entry.name]; + const status = () => connections.mcpStatuses()[entry.name]; const disabled = () => entry.config.enabled === false; const detail = entry.config.type === "remote" diff --git a/apps/app/src/app/components/session/message-list.tsx b/apps/app/src/app/components/session/message-list.tsx index 9226ebb4..4c360530 100644 --- a/apps/app/src/app/components/session/message-list.tsx +++ b/apps/app/src/app/components/session/message-list.tsx @@ -833,7 +833,7 @@ export default function MessageList(props: MessageListProps) {
diff --git a/apps/app/src/app/components/session/scroll-controller.ts b/apps/app/src/app/components/session/scroll-controller.ts new file mode 100644 index 00000000..b35ccaff --- /dev/null +++ b/apps/app/src/app/components/session/scroll-controller.ts @@ -0,0 +1,213 @@ +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + type Accessor, + type JSX, +} from "solid-js"; + +const FOLLOW_LATEST_BOTTOM_GAP_PX = 96; + +type SessionScrollMode = "follow-latest" | "manual-browse"; + +type SessionScrollControllerOptions = { + selectedSessionId: Accessor; + renderedMessages: Accessor; + containerRef: Accessor; + contentRef: Accessor; +}; + +export function createSessionScrollController( + options: SessionScrollControllerOptions, +) { + const [mode, setMode] = createSignal("follow-latest"); + const [topClippedMessageId, setTopClippedMessageId] = createSignal(null); + const isAtBottom = createMemo(() => mode() === "follow-latest"); + + let lastKnownScrollTop = 0; + let programmaticScroll = false; + let programmaticScrollResetRafA: number | undefined; + let programmaticScrollResetRafB: number | undefined; + let observedContentHeight = 0; + + const clearProgrammaticScrollReset = () => { + if (programmaticScrollResetRafA !== undefined) { + window.cancelAnimationFrame(programmaticScrollResetRafA); + programmaticScrollResetRafA = undefined; + } + if (programmaticScrollResetRafB !== undefined) { + window.cancelAnimationFrame(programmaticScrollResetRafB); + programmaticScrollResetRafB = undefined; + } + }; + + const releaseProgrammaticScrollSoon = () => { + clearProgrammaticScrollReset(); + programmaticScrollResetRafA = window.requestAnimationFrame(() => { + programmaticScrollResetRafA = undefined; + programmaticScrollResetRafB = window.requestAnimationFrame(() => { + programmaticScrollResetRafB = undefined; + programmaticScroll = false; + }); + }); + }; + + const scrollToBottom = (behavior: ScrollBehavior = "auto") => { + const container = options.containerRef(); + if (!container) return; + + setMode("follow-latest"); + setTopClippedMessageId(null); + programmaticScroll = true; + + if (behavior === "smooth") { + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); + releaseProgrammaticScrollSoon(); + return; + } + + container.scrollTop = container.scrollHeight; + window.requestAnimationFrame(() => { + const next = options.containerRef(); + if (!next) { + programmaticScroll = false; + return; + } + next.scrollTop = next.scrollHeight; + releaseProgrammaticScrollSoon(); + }); + }; + + const refreshTopClippedMessage = () => { + const container = options.containerRef(); + if (!container) { + setTopClippedMessageId(null); + return; + } + + const containerRect = container.getBoundingClientRect(); + const messageEls = container.querySelectorAll("[data-message-id]"); + const latestMessageEl = messageEls[messageEls.length - 1] as HTMLElement | undefined; + const latestMessageId = latestMessageEl?.getAttribute("data-message-id")?.trim() ?? ""; + let nextId: string | null = null; + + for (const node of messageEls) { + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); + if (rect.bottom <= containerRect.top + 1) continue; + if (rect.top >= containerRect.bottom - 1) break; + + if (rect.top < containerRect.top - 1) { + const id = el.getAttribute("data-message-id")?.trim() ?? ""; + if (id) { + const isLatestMessage = id === latestMessageId; + const fillsViewportTail = rect.bottom >= containerRect.bottom - 1; + if (isLatestMessage || fillsViewportTail) { + nextId = id; + } + } + } + break; + } + + setTopClippedMessageId(nextId); + }; + + const handleScroll: JSX.EventHandlerUnion = (event) => { + const container = event.currentTarget as HTMLDivElement; + if (programmaticScroll) { + lastKnownScrollTop = container.scrollTop; + refreshTopClippedMessage(); + return; + } + + const bottomGap = + container.scrollHeight - (container.scrollTop + container.clientHeight); + if (bottomGap <= FOLLOW_LATEST_BOTTOM_GAP_PX) { + setMode("follow-latest"); + } else if (container.scrollTop < lastKnownScrollTop - 1) { + setMode("manual-browse"); + } + lastKnownScrollTop = container.scrollTop; + refreshTopClippedMessage(); + }; + + const jumpToLatest = (behavior: ScrollBehavior = "smooth") => { + scrollToBottom(behavior); + }; + + const jumpToStartOfMessage = (behavior: ScrollBehavior = "smooth") => { + const messageId = topClippedMessageId(); + const container = options.containerRef(); + if (!messageId || !container) return; + + const escapedId = messageId.replace(/"/g, '\\"'); + const target = container.querySelector( + `[data-message-id="${escapedId}"]`, + ) as HTMLElement | null; + if (!target) return; + + setMode("manual-browse"); + target.scrollIntoView({ behavior, block: "start" }); + }; + + createEffect(() => { + const content = options.contentRef(); + if (!content) return; + + observedContentHeight = content.offsetHeight; + const observer = new ResizeObserver(() => { + const nextContent = options.contentRef(); + if (!nextContent) return; + + const nextHeight = nextContent.offsetHeight; + const grew = nextHeight > observedContentHeight + 1; + observedContentHeight = nextHeight; + + if (grew && isAtBottom()) { + scrollToBottom("auto"); + return; + } + + refreshTopClippedMessage(); + }); + + observer.observe(content); + onCleanup(() => observer.disconnect()); + }); + + createEffect( + on( + options.selectedSessionId, + (sessionId, previousSessionId) => { + if (sessionId === previousSessionId) return; + if (!sessionId) return; + + setMode("follow-latest"); + setTopClippedMessageId(null); + observedContentHeight = 0; + queueMicrotask(() => scrollToBottom("auto")); + }, + ), + ); + + createEffect(() => { + options.renderedMessages(); + queueMicrotask(refreshTopClippedMessage); + }); + + onCleanup(() => { + clearProgrammaticScrollReset(); + }); + + return { + isAtBottom, + topClippedMessageId, + handleScroll, + scrollToBottom, + jumpToLatest, + jumpToStartOfMessage, + }; +} diff --git a/apps/app/src/app/components/session/sidebar.tsx b/apps/app/src/app/components/session/sidebar.tsx index b95f17ba..639cc22b 100644 --- a/apps/app/src/app/components/session/sidebar.tsx +++ b/apps/app/src/app/components/session/sidebar.tsx @@ -30,14 +30,11 @@ export type SidebarProps = { expandedSections: SidebarSectionState; onToggleSection: (section: keyof SidebarSectionState) => void; workspaceGroups: WorkspaceSessionGroup[]; - activeWorkspaceId: string; + selectedWorkspaceId: string; connectingWorkspaceId?: string | null; workspaceConnectionStateById: Record; onSelectWorkspace: (workspaceId: string) => void; onCreateWorkspace: () => void; - onCreateRemoteWorkspace: () => void; - onImportWorkspace: () => void; - importingWorkspaceConfig?: boolean; onEditWorkspace: (workspaceId: string) => void; onTestWorkspaceConnection: (workspaceId: string) => void; onForgetWorkspace: (workspaceId: string) => void; @@ -81,9 +78,6 @@ export default function SessionSidebar(props: SidebarProps) { const [showAllSessionsByWorkspaceId, setShowAllSessionsByWorkspaceId] = createSignal< Record >({}); - const [addWorkspaceMenuOpen, setAddWorkspaceMenuOpen] = createSignal(false); - let addWorkspaceMenuRef: HTMLDivElement | undefined; - const workspaceLabel = (workspace: WorkspaceInfo) => workspace.displayName?.trim() || workspace.openworkWorkspaceName?.trim() || @@ -263,17 +257,6 @@ export default function SessionSidebar(props: SidebarProps) { }); }); - createEffect(() => { - if (!addWorkspaceMenuOpen()) return; - const closeMenu = (event: MouseEvent) => { - const target = event.target as Node | null; - if (addWorkspaceMenuRef && target && addWorkspaceMenuRef.contains(target)) return; - setAddWorkspaceMenuOpen(false); - }; - window.addEventListener("click", closeMenu); - onCleanup(() => window.removeEventListener("click", closeMenu)); - }); - return (
@@ -303,7 +286,7 @@ export default function SessionSidebar(props: SidebarProps) { > {(group) => { - const isActive = () => props.activeWorkspaceId === group.workspace.id; + const isActive = () => props.selectedWorkspaceId === group.workspace.id; const isConnecting = () => props.connectingWorkspaceId === group.workspace.id; const pathLabel = () => workspacePathLabel(group.workspace); const detailLabel = () => workspaceDetailLabel(group.workspace); @@ -319,7 +302,7 @@ export default function SessionSidebar(props: SidebarProps) { const isActivelyConnecting = () => isConnecting() && connectionStatus() === "connecting"; const hasPendingSwitch = () => { const pendingId = props.connectingWorkspaceId; - if (!pendingId || pendingId === props.activeWorkspaceId) return false; + if (!pendingId || pendingId === props.selectedWorkspaceId) return false; const pendingStatus = props.workspaceConnectionStateById[pendingId]?.status ?? "idle"; return pendingStatus === "connecting"; }; @@ -510,7 +493,7 @@ export default function SessionSidebar(props: SidebarProps) { > @@ -547,56 +532,18 @@ export default function SessionSidebar(props: SidebarProps) { }} -
(addWorkspaceMenuRef = el)}> +
- -
- - - -
-
diff --git a/apps/app/src/app/components/session/workspace-session-list.tsx b/apps/app/src/app/components/session/workspace-session-list.tsx index 4d546de9..0851ac42 100644 --- a/apps/app/src/app/components/session/workspace-session-list.tsx +++ b/apps/app/src/app/components/session/workspace-session-list.tsx @@ -14,8 +14,6 @@ import { Plus, } from "lucide-solid"; -import DesktopOnlyBadge from "../desktop-only-badge"; -import { getOpenWorkDeployment } from "../../lib/openwork-deployment"; import { DEFAULT_SESSION_TITLE, getDisplaySessionTitle } from "../../lib/session-title"; import type { WorkspaceInfo } from "../../lib/tauri"; import type { @@ -30,7 +28,7 @@ import { type Props = { workspaceSessionGroups: WorkspaceSessionGroup[]; - activeWorkspaceId: string; + selectedWorkspaceId: string; developerMode: boolean; selectedSessionId: string | null; showSessionActions?: boolean; @@ -38,10 +36,7 @@ type Props = { connectingWorkspaceId: string | null; workspaceConnectionStateById: Record; newTaskDisabled: boolean; - importingWorkspaceConfig: boolean; - onActivateWorkspace: ( - workspaceId: string, - ) => Promise | boolean | void; + onSelectWorkspace: (workspaceId: string) => Promise | boolean | void; onOpenSession: (workspaceId: string, sessionId: string) => void; onCreateTaskInWorkspace: (workspaceId: string) => void; onOpenRenameSession?: () => void; @@ -58,12 +53,10 @@ type Props = { onEditWorkspaceConnection: (workspaceId: string) => void; onForgetWorkspace: (workspaceId: string) => void; onOpenCreateWorkspace: () => void; - onOpenCreateRemoteWorkspace: () => void; - onImportWorkspaceConfig: () => void; }; const MAX_SESSIONS_PREVIEW = 6; -const COLLAPSED_SESSIONS_PREVIEW = 1; +const COLLAPSED_SESSIONS_PREVIEW = MAX_SESSIONS_PREVIEW; type SessionListItem = WorkspaceSessionGroup["sessions"][number]; type FlattenedSessionRow = { session: SessionListItem; depth: number }; @@ -193,7 +186,6 @@ export default function WorkspaceSessionList(props: Props) { const revealLabel = isWindowsPlatform() ? "Reveal in Explorer" : "Reveal in Finder"; - const newWorkspaceDesktopOnly = getOpenWorkDeployment() === "web"; const [expandedWorkspaceIds, setExpandedWorkspaceIds] = createSignal< Set >(new Set()); @@ -202,13 +194,11 @@ export default function WorkspaceSessionList(props: Props) { const [workspaceMenuId, setWorkspaceMenuId] = createSignal( null, ); - const [addWorkspaceMenuOpen, setAddWorkspaceMenuOpen] = createSignal(false); const [sessionMenuOpen, setSessionMenuOpen] = createSignal(false); const [expandedSessionIds, setExpandedSessionIds] = createSignal>( new Set(), ); let workspaceMenuRef: HTMLDivElement | undefined; - let addWorkspaceMenuRef: HTMLDivElement | undefined; let sessionMenuRef: HTMLDivElement | undefined; const isWorkspaceExpanded = (workspaceId: string) => @@ -240,11 +230,11 @@ export default function WorkspaceSessionList(props: Props) { }; onMount(() => { - expandWorkspace(props.activeWorkspaceId); + expandWorkspace(props.selectedWorkspaceId); }); createEffect(() => { - expandWorkspace(props.activeWorkspaceId); + expandWorkspace(props.selectedWorkspaceId); }); const previewCount = (workspaceId: string) => { @@ -311,18 +301,6 @@ export default function WorkspaceSessionList(props: Props) { onCleanup(() => window.removeEventListener("pointerdown", closeMenu)); }); - createEffect(() => { - if (!addWorkspaceMenuOpen()) return; - const closeMenu = (event: PointerEvent) => { - const target = event.target as Node | null; - if (addWorkspaceMenuRef && target && addWorkspaceMenuRef.contains(target)) - return; - setAddWorkspaceMenuOpen(false); - }; - window.addEventListener("pointerdown", closeMenu); - onCleanup(() => window.removeEventListener("pointerdown", closeMenu)); - }); - createEffect(() => { props.selectedSessionId; setSessionMenuOpen(false); @@ -517,7 +495,7 @@ export default function WorkspaceSessionList(props: Props) { if (group.status === "error") return taskLoadError().label; if (isConnectionActionBusy()) return "Connecting"; if (!props.developerMode) return ""; - if (props.activeWorkspaceId === workspace().id) return "Active"; + if (props.selectedWorkspaceId === workspace().id) return "Selected"; return workspaceKindLabel(workspace()); }; const statusTone = () => { @@ -536,14 +514,14 @@ export default function WorkspaceSessionList(props: Props) { role="button" tabIndex={0} class={`w-full flex items-center justify-between rounded-xl px-3.5 py-2.5 text-left text-[13px] transition-colors ${ - props.activeWorkspaceId === workspace().id + props.selectedWorkspaceId === workspace().id ? "bg-gray-2/70 text-gray-12" : "text-gray-10 hover:bg-gray-1/70 hover:text-gray-12" } ${isConnecting() ? "opacity-75" : ""}`} onClick={() => { expandWorkspace(workspace().id); void Promise.resolve( - props.onActivateWorkspace(workspace().id), + props.onSelectWorkspace(workspace().id), ); }} onKeyDown={(event) => { @@ -552,7 +530,7 @@ export default function WorkspaceSessionList(props: Props) { event.preventDefault(); expandWorkspace(workspace().id); void Promise.resolve( - props.onActivateWorkspace(workspace().id), + props.onSelectWorkspace(workspace().id), ); }} > @@ -731,29 +709,52 @@ export default function WorkspaceSessionList(props: Props) {
- 0}> - - {(row) => - renderSessionRow( + 0}> + - - } - > + > + {(row) => + renderSessionRow( + workspace().id, + row, + tree, + forcedExpandedSessionIds, + )} + + + + previewCount(workspace().id) + } + > + + + + } + >
-
(addWorkspaceMenuRef = el)} - > +
- - -
- - - -
-
); diff --git a/apps/app/src/app/components/share-workspace-modal.tsx b/apps/app/src/app/components/share-workspace-modal.tsx deleted file mode 100644 index cce48722..00000000 --- a/apps/app/src/app/components/share-workspace-modal.tsx +++ /dev/null @@ -1,486 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"; -import { - ArrowLeft, - Check, - ChevronDown, - Copy, - Eye, - EyeOff, - FolderCode, - MessageSquare, - MonitorUp, - Rocket, - X, -} from "lucide-solid"; - -type ShareField = { - label: string; - value: string; - secret?: boolean; - placeholder?: string; - hint?: string; -}; - -type ShareView = "chooser" | "template" | "access"; - -const isInviteField = (label: string) => /invite link/i.test(label); -const isCollaboratorField = (label: string) => /collaborator token/i.test(label); -const isPasswordField = (label: string) => /owner token|connected token|access token|password/i.test(label); -const isWorkerUrlField = (label: string) => /worker url/i.test(label); - -const displayFieldLabel = (field: ShareField) => { - if (isPasswordField(field.label)) return "Password"; - if (isWorkerUrlField(field.label)) return "Worker URL"; - return field.label; -}; - -export default function ShareWorkspaceModal(props: { - open: boolean; - onClose: () => void; - title?: string; - workspaceName: string; - workspaceDetail?: string | null; - fields: ShareField[]; - remoteAccess?: { - enabled: boolean; - busy: boolean; - error?: string | null; - onSave: (enabled: boolean) => void | Promise; - }; - note?: string | null; - publisherBaseUrl?: string; - onShareWorkspaceProfile?: () => void; - shareWorkspaceProfileBusy?: boolean; - shareWorkspaceProfileUrl?: string | null; - shareWorkspaceProfileError?: string | null; - shareWorkspaceProfileDisabledReason?: string | null; - onShareSkillsSet?: () => void; - onOpenSingleSkillShare?: () => void; - shareSkillsSetBusy?: boolean; - shareSkillsSetUrl?: string | null; - shareSkillsSetError?: string | null; - shareSkillsSetDisabledReason?: string | null; - onExportConfig?: () => void; - exportDisabledReason?: string | null; - onOpenBots?: () => void; -}) { - const [activeView, setActiveView] = createSignal("chooser"); - const [revealedByIndex, setRevealedByIndex] = createSignal>({}); - const [copiedKey, setCopiedKey] = createSignal(null); - const [collaboratorExpanded, setCollaboratorExpanded] = createSignal(false); - const [remoteAccessEnabled, setRemoteAccessEnabled] = createSignal(false); - - const title = createMemo(() => props.title ?? "Share workspace"); - const note = createMemo(() => props.note?.trim() ?? ""); - const accessFields = createMemo(() => props.fields.filter((field) => !isInviteField(field.label))); - const collaboratorField = createMemo(() => accessFields().find((field) => isCollaboratorField(field.label)) ?? null); - const primaryAccessFields = createMemo(() => - accessFields().filter((field) => !isCollaboratorField(field.label)), - ); - - createEffect( - on( - () => props.open, - (open) => { - if (!open) return; - setActiveView("chooser"); - setRevealedByIndex({}); - setCopiedKey(null); - setCollaboratorExpanded(false); - setRemoteAccessEnabled(props.remoteAccess?.enabled === true); - }, - ), - ); - - createEffect( - on( - () => props.remoteAccess?.enabled, - (enabled, previous) => { - if (!props.open) return; - if (enabled === previous) return; - setRemoteAccessEnabled(enabled === true); - }, - ), - ); - - createEffect(() => { - if (!props.open) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - if (activeView() === "chooser") { - props.onClose(); - return; - } - setActiveView("chooser"); - }; - window.addEventListener("keydown", handleKeyDown); - onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); - }); - - const handleCopy = async (value: string, key: string) => { - const text = value?.trim() ?? ""; - if (!text) return; - try { - await navigator.clipboard.writeText(text); - setCopiedKey(key); - window.setTimeout(() => { - setCopiedKey((current) => (current === key ? null : current)); - }, 2000); - } catch { - // ignore clipboard failures - } - }; - - const renderCredentialField = (field: ShareField, index: () => number, keyPrefix: string) => { - const key = () => `${keyPrefix}:${field.label}:${index()}`; - const isSecret = () => Boolean(field.secret); - const revealed = () => Boolean(revealedByIndex()[index()]); - return ( -
- -
- -
- - - - -
-
- -

{field.hint}

-
-
- ); - }; - - const renderGeneratedLink = ( - value: string | null | undefined, - copyKey: string, - regenerate: (() => void) | undefined, - busy: boolean | undefined, - createLabel: string, - regenerateLabel: string, - createAction: (() => void) | undefined, - disabledReason: string | null | undefined, - ) => ( - createAction?.()} - disabled={Boolean(disabledReason) || !createAction || busy} - class="mt-3 w-full rounded-full bg-dls-text px-5 py-3 text-[13px] font-medium text-dls-surface shadow-sm transition-colors hover:bg-gray-12 active:scale-[0.99] disabled:opacity-50" - > - {busy ? "Publishing..." : createLabel} - - } - > -
- - -
- -
- ); - - return ( - -
- - - ); -} diff --git a/apps/app/src/app/components/status-bar.tsx b/apps/app/src/app/components/status-bar.tsx index bd396011..8d229bf5 100644 --- a/apps/app/src/app/components/status-bar.tsx +++ b/apps/app/src/app/components/status-bar.tsx @@ -1,8 +1,8 @@ import { Show, createMemo } from "solid-js"; import { MessageCircle, Settings } from "lucide-solid"; +import { useConnections } from "../connections/provider"; import type { OpenworkServerStatus } from "../lib/openwork-server"; -import type { McpStatusMap } from "../types"; type StatusBarProps = { clientConnected: boolean; @@ -15,7 +15,6 @@ type StatusBarProps = { onOpenProviders: () => Promise | void; onOpenMcp: () => void; providerConnectedIds: string[]; - mcpStatuses: McpStatusMap; statusLabel?: string; statusDetail?: string; statusDotClass?: string; @@ -25,9 +24,10 @@ type StatusBarProps = { }; export default function StatusBar(props: StatusBarProps) { + const connections = useConnections(); const providerConnectedCount = createMemo(() => props.providerConnectedIds?.length ?? 0); const mcpConnectedCount = createMemo( - () => Object.values(props.mcpStatuses ?? {}).filter((status) => status?.status === "connected").length, + () => Object.values(connections.mcpStatuses() ?? {}).filter((status) => status?.status === "connected").length, ); const statusCopy = createMemo(() => { diff --git a/apps/app/src/app/components/status-bar.tsx.bak b/apps/app/src/app/components/status-bar.tsx.bak deleted file mode 100644 index fa94cfc2..00000000 --- a/apps/app/src/app/components/status-bar.tsx.bak +++ /dev/null @@ -1,129 +0,0 @@ -import { Show, createMemo } from "solid-js"; -import { MessageCircle, Settings } from "lucide-solid"; - -import type { OpenworkServerStatus } from "../lib/openwork-server"; -import type { McpStatusMap } from "../types"; - -type StatusBarProps = { - clientConnected: boolean; - openworkServerStatus: OpenworkServerStatus; - developerMode: boolean; - settingsOpen: boolean; - onSendFeedback: () => void; - onOpenSettings: () => void; - onOpenMessaging: () => void; - onOpenProviders: () => Promise | void; - onOpenMcp: () => void; - providerConnectedIds: string[]; - mcpStatuses: McpStatusMap; - statusLabel?: string; - statusDetail?: string; - statusDotClass?: string; - statusPingClass?: string; - statusPulse?: boolean; -}; - -export default function StatusBar(props: StatusBarProps) { - const providerConnectedCount = createMemo(() => props.providerConnectedIds?.length ?? 0); - const mcpConnectedCount = createMemo( - () => Object.values(props.mcpStatuses ?? {}).filter((status) => status?.status === "connected").length, - ); - - const statusCopy = createMemo(() => { - if (props.statusLabel) { - return { - label: props.statusLabel, - detail: props.statusDetail ?? "", - dotClass: props.statusDotClass ?? "bg-green-9", - pingClass: props.statusPingClass ?? "bg-green-9/45 animate-ping", - pulse: props.statusPulse ?? true, - }; - } - - const providers = providerConnectedCount(); - const mcp = mcpConnectedCount(); - - if (props.clientConnected) { - const detailBits: string[] = []; - if (providers > 0) { - detailBits.push(`${providers} provider${providers === 1 ? "" : "s"} connected`); - } - if (mcp > 0) { - detailBits.push(`${mcp} MCP connected`); - } - if (!detailBits.length) { - detailBits.push("Ready for new tasks"); - } - if (props.developerMode) { - detailBits.push("Developer mode"); - } - return { - label: "OpenWork Ready", - detail: detailBits.join(" · "), - dotClass: "bg-green-9", - pingClass: "bg-green-9/45 animate-ping", - pulse: true, - }; - } - - if (props.openworkServerStatus === "limited") { - return { - label: "Limited Mode", - detail: - mcp > 0 - ? `${mcp} MCP connected · reconnect for full features` - : "Reconnect to restore full OpenWork features", - dotClass: "bg-amber-9", - pingClass: "bg-amber-9/35", - pulse: false, - }; - } - - return { - label: "Disconnected", - detail: "Open settings to reconnect", - dotClass: "bg-red-9", - pingClass: "bg-red-9/35", - pulse: false, - }; - }); - - return ( -
-
-
- - - - - - - {statusCopy().label} - {statusCopy().detail} -
- -
- - -
-
-
- ); -} diff --git a/apps/app/src/app/components/status-toast.tsx b/apps/app/src/app/components/status-toast.tsx index 232d85a1..c422d6f0 100644 --- a/apps/app/src/app/components/status-toast.tsx +++ b/apps/app/src/app/components/status-toast.tsx @@ -1,6 +1,6 @@ import { Show } from "solid-js"; -import { CheckCircle2, Info, X } from "lucide-solid"; +import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from "lucide-solid"; import Button from "./button"; @@ -8,7 +8,7 @@ export type StatusToastProps = { open: boolean; title: string; description?: string | null; - tone?: "success" | "info"; + tone?: "success" | "info" | "warning" | "error"; actionLabel?: string; onAction?: () => void; dismissLabel?: string; @@ -20,16 +20,31 @@ export default function StatusToast(props: StatusToastProps) { return ( -
+
- }> + + ) : tone() === "error" ? ( + + ) : ( + + ) + } + >
diff --git a/apps/app/src/app/components/workspace-right-sidebar.tsx b/apps/app/src/app/components/workspace-right-sidebar.tsx index 2eea397e..ef661b19 100644 --- a/apps/app/src/app/components/workspace-right-sidebar.tsx +++ b/apps/app/src/app/components/workspace-right-sidebar.tsx @@ -11,7 +11,7 @@ import { Zap, } from "lucide-solid"; -import type { DashboardTab } from "../types"; +import type { SettingsTab } from "../types"; import type { OpenworkServerClient } from "../lib/openwork-server"; import InboxPanel from "./session/inbox-panel"; @@ -19,12 +19,12 @@ type Props = { expanded: boolean; mobile?: boolean; showSelection?: boolean; - tab: DashboardTab; + settingsTab?: SettingsTab; developerMode: boolean; activeWorkspaceLabel: string; activeWorkspaceType: "local" | "remote"; openworkServerClient: OpenworkServerClient | null; - openworkServerWorkspaceId: string | null; + runtimeWorkspaceId: string | null; inboxId: string; onToggleExpanded: () => void; onCloseMobile?: () => void; @@ -104,32 +104,32 @@ export default function WorkspaceRightSidebar(props: Props) { {sidebarButton( "Automations", , - showSelection() && props.tab === "scheduled", + showSelection() && props.settingsTab === "automations", props.onOpenAutomations, )} {sidebarButton( "Skills", , - showSelection() && props.tab === "skills", + showSelection() && props.settingsTab === "skills", props.onOpenSkills, )} {sidebarButton( "Extensions", , - showSelection() && (props.tab === "mcp" || props.tab === "plugins"), + showSelection() && props.settingsTab === "extensions", props.onOpenExtensions, )} {sidebarButton( "Messaging", , - showSelection() && props.tab === "identities", + showSelection() && props.settingsTab === "messaging", props.onOpenMessaging, )} {sidebarButton( "Advanced", , - showSelection() && props.tab === "config", + showSelection() && props.settingsTab === "advanced", props.onOpenAdvanced, )} @@ -140,7 +140,7 @@ export default function WorkspaceRightSidebar(props: Props) {
@@ -151,7 +151,7 @@ export default function WorkspaceRightSidebar(props: Props) { {sidebarButton( "Settings", , - showSelection() && (props.tab === "settings" || props.tab === "config" || props.tab === "identities"), + showSelection() && props.settingsTab === "general", props.onOpenSettings, )}
diff --git a/apps/app/src/app/connections/mcp-view.tsx b/apps/app/src/app/connections/mcp-view.tsx new file mode 100644 index 00000000..7d30b76e --- /dev/null +++ b/apps/app/src/app/connections/mcp-view.tsx @@ -0,0 +1,36 @@ +import PresentationalMcpView from "../pages/mcp"; + +import { useConnections } from "./provider"; + +export type ConnectionsMcpViewProps = { + busy: boolean; + selectedWorkspaceRoot: string; + isRemoteWorkspace: boolean; + showHeader?: boolean; +}; + +export default function ConnectionsMcpView(props: ConnectionsMcpViewProps) { + const connections = useConnections(); + + return ( + + ); +} diff --git a/apps/app/src/app/connections/modals.tsx b/apps/app/src/app/connections/modals.tsx new file mode 100644 index 00000000..6a66c3a0 --- /dev/null +++ b/apps/app/src/app/connections/modals.tsx @@ -0,0 +1,38 @@ +import type { Client } from "../types"; +import type { Language } from "../../i18n"; +import McpAuthModal from "../components/mcp-auth-modal"; + +import { useConnections } from "./provider"; + +export type ConnectionsModalsProps = { + client: Client | null; + projectDir: string; + language: Language; + reloadBlocked: boolean; + activeSessions: Array<{ id: string; title: string }>; + isRemoteWorkspace: boolean; + onForceStopSession: (sessionID: string) => void | Promise; + onReloadEngine: () => void | Promise; +}; + +export default function ConnectionsModals(props: ConnectionsModalsProps) { + const connections = useConnections(); + + return ( + + ); +} diff --git a/apps/app/src/app/connections/openwork-server-provider.tsx b/apps/app/src/app/connections/openwork-server-provider.tsx new file mode 100644 index 00000000..5c30332b --- /dev/null +++ b/apps/app/src/app/connections/openwork-server-provider.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +import type { OpenworkServerStore } from "./openwork-server-store"; + +const OpenworkServerContext = createContext(); + +export function OpenworkServerProvider(props: ParentProps<{ store: OpenworkServerStore }>) { + return ( + + {props.children} + + ); +} + +export function useOpenworkServer() { + const context = useContext(OpenworkServerContext); + if (!context) { + throw new Error("useOpenworkServer must be used within an OpenworkServerProvider"); + } + return context; +} diff --git a/apps/app/src/app/connections/openwork-server-store.ts b/apps/app/src/app/connections/openwork-server-store.ts new file mode 100644 index 00000000..b761e5b0 --- /dev/null +++ b/apps/app/src/app/connections/openwork-server-store.ts @@ -0,0 +1,633 @@ +import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"; + +import type { StartupPreference, WorkspaceDisplay } from "../types"; +import { isTauriRuntime } from "../utils"; +import { + openworkServerInfo, + openworkServerRestart, + opencodeRouterInfo, + orchestratorStatus, + type OpenCodeRouterInfo, + type OpenworkServerInfo, + type OrchestratorStatus, +} from "../lib/tauri"; +import { + clearOpenworkServerSettings, + createOpenworkServerClient, + normalizeOpenworkServerUrl, + writeOpenworkServerSettings, + type OpenworkAuditEntry, + type OpenworkServerCapabilities, + type OpenworkServerClient, + type OpenworkServerDiagnostics, + type OpenworkServerError, + type OpenworkServerSettings, + type OpenworkServerStatus, +} from "../lib/openwork-server"; + +export type OpenworkServerStore = ReturnType; + +type RemoteWorkspaceInput = { + openworkHostUrl: string; + openworkToken?: string | null; + directory?: string | null; + displayName?: string | null; +}; + +export function createOpenworkServerStore(options: { + startupPreference: Accessor; + documentVisible: Accessor; + developerMode: Accessor; + runtimeWorkspaceId: Accessor; + activeClient: Accessor; + selectedWorkspaceDisplay: Accessor; + restartLocalServer: () => Promise; + createRemoteWorkspaceFlow: (input: RemoteWorkspaceInput) => Promise; +}) { + const [openworkServerSettings, setOpenworkServerSettings] = createSignal({}); + const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false); + const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal(null); + const [openworkServerUrl, setOpenworkServerUrl] = createSignal(""); + const [openworkServerStatus, setOpenworkServerStatus] = createSignal("disconnected"); + const [openworkServerCapabilities, setOpenworkServerCapabilities] = + createSignal(null); + const [, setOpenworkServerCheckedAt] = createSignal(null); + const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal(null); + const [openworkServerDiagnostics, setOpenworkServerDiagnostics] = + createSignal(null); + const [openworkReconnectBusy, setOpenworkReconnectBusy] = createSignal(false); + const [opencodeRouterInfoState, setOpenCodeRouterInfoState] = + createSignal(null); + const [orchestratorStatusState, setOrchestratorStatusState] = + createSignal(null); + const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal([]); + const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle"); + const [openworkAuditError, setOpenworkAuditError] = createSignal(null); + const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal(null); + + const openworkServerBaseUrl = createMemo(() => { + const pref = options.startupPreference(); + const hostInfo = openworkServerHostInfo(); + const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; + + if (pref === "local") return hostInfo?.baseUrl ?? ""; + if (pref === "server") return settingsUrl; + return hostInfo?.baseUrl ?? settingsUrl; + }); + + const openworkServerAuth = createMemo( + () => { + const pref = options.startupPreference(); + const hostInfo = openworkServerHostInfo(); + const settingsToken = openworkServerSettings().token?.trim() ?? ""; + const clientToken = hostInfo?.clientToken?.trim() ?? ""; + const hostToken = hostInfo?.hostToken?.trim() ?? ""; + + if (pref === "local") { + return { token: clientToken || undefined, hostToken: hostToken || undefined }; + } + if (pref === "server") { + return { token: settingsToken || undefined, hostToken: undefined }; + } + if (hostInfo?.baseUrl) { + return { token: clientToken || undefined, hostToken: hostToken || undefined }; + } + return { token: settingsToken || undefined, hostToken: undefined }; + }, + undefined, + { + equals: (prev, next) => prev?.token === next.token && prev?.hostToken === next.hostToken, + }, + ); + + const openworkServerClient = createMemo(() => { + const baseUrl = openworkServerBaseUrl().trim(); + if (!baseUrl) return null; + const auth = openworkServerAuth(); + return createOpenworkServerClient({ baseUrl, token: auth.token, hostToken: auth.hostToken }); + }); + + const openworkServerReady = createMemo(() => openworkServerStatus() === "connected"); + const openworkServerWorkspaceReady = createMemo(() => Boolean(options.runtimeWorkspaceId())); + const resolvedOpenworkCapabilities = createMemo(() => openworkServerCapabilities()); + const openworkServerCanWriteSkills = createMemo( + () => + openworkServerReady() && + openworkServerWorkspaceReady() && + (resolvedOpenworkCapabilities()?.skills?.write ?? false), + ); + const openworkServerCanWritePlugins = createMemo( + () => + openworkServerReady() && + openworkServerWorkspaceReady() && + (resolvedOpenworkCapabilities()?.plugins?.write ?? false), + ); + + const updateOpenworkServerSettings = (next: OpenworkServerSettings) => { + const stored = writeOpenworkServerSettings(next); + setOpenworkServerSettings(stored); + }; + + const resetOpenworkServerSettings = () => { + clearOpenworkServerSettings(); + setOpenworkServerSettings({}); + }; + + const checkOpenworkServer = async (url: string, token?: string, hostToken?: string) => { + const client = createOpenworkServerClient({ baseUrl: url, token, hostToken }); + try { + await client.health(); + } catch (error) { + const resolved = error as OpenworkServerError | Error; + if ("status" in resolved && (resolved.status === 401 || resolved.status === 403)) { + return { status: "limited" as OpenworkServerStatus, capabilities: null }; + } + return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; + } + + if (!token) { + return { status: "limited" as OpenworkServerStatus, capabilities: null }; + } + + try { + const caps = await client.capabilities(); + return { status: "connected" as OpenworkServerStatus, capabilities: caps }; + } catch (error) { + const resolved = error as OpenworkServerError | Error; + if ("status" in resolved && (resolved.status === 401 || resolved.status === 403)) { + return { status: "limited" as OpenworkServerStatus, capabilities: null }; + } + return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; + } + }; + + createEffect(() => { + const pref = options.startupPreference(); + const info = openworkServerHostInfo(); + const hostUrl = info?.connectUrl ?? info?.lanUrl ?? info?.mdnsUrl ?? info?.baseUrl ?? ""; + const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; + + if (pref === "local") { + setOpenworkServerUrl(hostUrl); + return; + } + if (pref === "server") { + setOpenworkServerUrl(settingsUrl); + return; + } + setOpenworkServerUrl(hostUrl || settingsUrl); + }); + + createEffect(() => { + if (typeof window === "undefined") return; + if (!options.documentVisible()) return; + const url = openworkServerBaseUrl().trim(); + const auth = openworkServerAuth(); + const token = auth.token; + const hostToken = auth.hostToken; + + if (!url) { + setOpenworkServerStatus("disconnected"); + setOpenworkServerCapabilities(null); + setOpenworkServerCheckedAt(Date.now()); + return; + } + + let active = true; + let busy = false; + let timeoutId: number | undefined; + let delayMs = 10_000; + + const scheduleNext = () => { + if (!active) return; + timeoutId = window.setTimeout(run, delayMs); + }; + + const run = async () => { + if (busy) return; + busy = true; + try { + const result = await checkOpenworkServer(url, token, hostToken); + if (!active) return; + setOpenworkServerStatus(result.status); + setOpenworkServerCapabilities(result.capabilities); + delayMs = + result.status === "connected" || result.status === "limited" + ? 10_000 + : Math.min(delayMs * 2, 60_000); + } catch { + delayMs = Math.min(delayMs * 2, 60_000); + } finally { + if (!active) return; + setOpenworkServerCheckedAt(Date.now()); + busy = false; + scheduleNext(); + } + }; + + run(); + onCleanup(() => { + active = false; + if (timeoutId) window.clearTimeout(timeoutId); + }); + }); + + createEffect(() => { + if (!isTauriRuntime()) return; + if (!options.documentVisible()) return; + let active = true; + + const run = async () => { + try { + const info = await openworkServerInfo(); + if (active) setOpenworkServerHostInfo(info); + } catch { + if (active) setOpenworkServerHostInfo(null); + } + }; + + run(); + const interval = window.setInterval(run, 10_000); + onCleanup(() => { + active = false; + window.clearInterval(interval); + }); + }); + + createEffect(() => { + if (!isTauriRuntime()) return; + const hostInfo = openworkServerHostInfo(); + const port = hostInfo?.port; + if (!port) return; + + const current = openworkServerSettings(); + if (current.portOverride === port) return; + + updateOpenworkServerSettings({ + ...current, + portOverride: port, + }); + }); + + createEffect(() => { + if (typeof window === "undefined") return; + if (!options.documentVisible()) return; + if (!options.developerMode()) { + setOpenworkServerDiagnostics(null); + return; + } + + const client = openworkServerClient(); + if (!client || openworkServerStatus() === "disconnected") { + setOpenworkServerDiagnostics(null); + return; + } + + let active = true; + let busy = false; + + const run = async () => { + if (busy) return; + busy = true; + try { + const status = await client.status(); + if (active) setOpenworkServerDiagnostics(status); + } catch { + if (active) setOpenworkServerDiagnostics(null); + } finally { + busy = false; + } + }; + + run(); + const interval = window.setInterval(run, 10_000); + onCleanup(() => { + active = false; + window.clearInterval(interval); + }); + }); + + createEffect(() => { + if (!isTauriRuntime()) return; + if (!options.developerMode()) { + setOpenCodeRouterInfoState(null); + return; + } + if (!options.documentVisible()) return; + + let active = true; + + const run = async () => { + try { + const info = await opencodeRouterInfo(); + if (active) setOpenCodeRouterInfoState(info); + } catch { + if (active) setOpenCodeRouterInfoState(null); + } + }; + + run(); + const interval = window.setInterval(run, 10_000); + onCleanup(() => { + active = false; + window.clearInterval(interval); + }); + }); + + createEffect(() => { + if (!isTauriRuntime()) return; + if (!options.developerMode()) { + setOrchestratorStatusState(null); + return; + } + if (!options.documentVisible()) return; + + let active = true; + + const run = async () => { + try { + const status = await orchestratorStatus(); + if (active) setOrchestratorStatusState(status); + } catch { + if (active) setOrchestratorStatusState(null); + } + }; + + run(); + const interval = window.setInterval(run, 10_000); + onCleanup(() => { + active = false; + window.clearInterval(interval); + }); + }); + + createEffect(() => { + if (!options.developerMode()) { + setDevtoolsWorkspaceId(null); + return; + } + if (!options.documentVisible()) return; + + const client = openworkServerClient(); + if (!client) { + setDevtoolsWorkspaceId(null); + return; + } + let active = true; + + const run = async () => { + try { + const response = await client.listWorkspaces(); + if (!active) return; + const items = Array.isArray(response.items) ? response.items : []; + const activeMatch = response.activeId ? items.find((item) => item.id === response.activeId) : null; + setDevtoolsWorkspaceId(activeMatch?.id ?? items[0]?.id ?? null); + } catch { + if (active) setDevtoolsWorkspaceId(null); + } + }; + + run(); + const interval = window.setInterval(run, 20_000); + onCleanup(() => { + active = false; + window.clearInterval(interval); + }); + }); + + createEffect(() => { + if (!options.developerMode()) { + setOpenworkAuditEntries([]); + setOpenworkAuditStatus("idle"); + setOpenworkAuditError(null); + return; + } + if (!options.documentVisible()) return; + + const client = openworkServerClient(); + const workspaceId = devtoolsWorkspaceId(); + if (!client || !workspaceId) { + setOpenworkAuditEntries([]); + setOpenworkAuditStatus("idle"); + setOpenworkAuditError(null); + return; + } + + let active = true; + let busy = false; + + const run = async () => { + if (busy) return; + busy = true; + setOpenworkAuditStatus("loading"); + setOpenworkAuditError(null); + try { + const result = await client.listAudit(workspaceId, 50); + if (!active) return; + setOpenworkAuditEntries(Array.isArray(result.items) ? result.items : []); + setOpenworkAuditStatus("idle"); + } catch (error) { + if (!active) return; + setOpenworkAuditEntries([]); + setOpenworkAuditStatus("error"); + setOpenworkAuditError(error instanceof Error ? error.message : "Failed to load audit log."); + } finally { + busy = false; + } + }; + + run(); + const interval = window.setInterval(run, 15_000); + onCleanup(() => { + active = false; + window.clearInterval(interval); + }); + }); + + const testOpenworkServerConnection = async (next: OpenworkServerSettings) => { + const derived = normalizeOpenworkServerUrl(next.urlOverride ?? ""); + if (!derived) { + setOpenworkServerStatus("disconnected"); + setOpenworkServerCapabilities(null); + setOpenworkServerCheckedAt(Date.now()); + return false; + } + + const result = await checkOpenworkServer(derived, next.token); + setOpenworkServerStatus(result.status); + setOpenworkServerCapabilities(result.capabilities); + setOpenworkServerCheckedAt(Date.now()); + + const ok = result.status === "connected" || result.status === "limited"; + if (ok && !isTauriRuntime()) { + const active = options.selectedWorkspaceDisplay(); + const shouldAttach = !options.activeClient() || active.workspaceType !== "remote" || active.remoteType !== "openwork"; + if (shouldAttach) { + await options.createRemoteWorkspaceFlow({ + openworkHostUrl: derived, + openworkToken: next.token ?? null, + }).catch(() => undefined); + } + } + return ok; + }; + + const reconnectOpenworkServer = async () => { + if (openworkReconnectBusy()) return false; + setOpenworkReconnectBusy(true); + try { + let hostInfo = openworkServerHostInfo(); + if (isTauriRuntime()) { + try { + hostInfo = await openworkServerInfo(); + setOpenworkServerHostInfo(hostInfo); + } catch { + hostInfo = null; + setOpenworkServerHostInfo(null); + } + } + + if (hostInfo?.clientToken?.trim() && options.startupPreference() !== "server") { + const liveToken = hostInfo.clientToken.trim(); + const settings = openworkServerSettings(); + if ((settings.token?.trim() ?? "") !== liveToken) { + updateOpenworkServerSettings({ ...settings, token: liveToken }); + } + } + + const url = openworkServerBaseUrl().trim(); + const auth = openworkServerAuth(); + if (!url) { + setOpenworkServerStatus("disconnected"); + setOpenworkServerCapabilities(null); + setOpenworkServerCheckedAt(Date.now()); + return false; + } + + const result = await checkOpenworkServer(url, auth.token, auth.hostToken); + setOpenworkServerStatus(result.status); + setOpenworkServerCapabilities(result.capabilities); + setOpenworkServerCheckedAt(Date.now()); + return result.status === "connected" || result.status === "limited"; + } finally { + setOpenworkReconnectBusy(false); + } + }; + + async function ensureLocalOpenworkServerClient(): Promise { + let hostInfo = openworkServerHostInfo(); + if (hostInfo?.baseUrl?.trim() && hostInfo.clientToken?.trim()) { + const existing = createOpenworkServerClient({ + baseUrl: hostInfo.baseUrl.trim(), + token: hostInfo.clientToken.trim(), + hostToken: hostInfo.hostToken?.trim() || undefined, + }); + try { + await existing.health(); + if (options.startupPreference() !== "server") { + await reconnectOpenworkServer(); + } + return existing; + } catch { + // restart below + } + } + + if (!isTauriRuntime()) { + return null; + } + + try { + hostInfo = await openworkServerRestart({ + remoteAccessEnabled: openworkServerSettings().remoteAccessEnabled === true, + }); + setOpenworkServerHostInfo(hostInfo); + } catch { + return null; + } + + const baseUrl = hostInfo?.baseUrl?.trim() ?? ""; + const token = hostInfo?.clientToken?.trim() ?? ""; + const hostToken = hostInfo?.hostToken?.trim() ?? ""; + if (!baseUrl || !token) { + return null; + } + + if (options.startupPreference() !== "server") { + await reconnectOpenworkServer(); + } + + return createOpenworkServerClient({ + baseUrl, + token, + hostToken: hostToken || undefined, + }); + } + + const saveShareRemoteAccess = async (enabled: boolean) => { + if (shareRemoteAccessBusy()) return; + const previous = openworkServerSettings(); + const next: OpenworkServerSettings = { + ...previous, + remoteAccessEnabled: enabled, + }; + + setShareRemoteAccessBusy(true); + setShareRemoteAccessError(null); + updateOpenworkServerSettings(next); + + try { + if (isTauriRuntime() && options.selectedWorkspaceDisplay().workspaceType === "local") { + const restarted = await options.restartLocalServer(); + if (!restarted) { + throw new Error("Failed to restart the local worker with the updated sharing setting."); + } + await reconnectOpenworkServer(); + } + } catch (error) { + updateOpenworkServerSettings(previous); + setShareRemoteAccessError( + error instanceof Error + ? error.message + : "Failed to update remote access.", + ); + return; + } finally { + setShareRemoteAccessBusy(false); + } + }; + + return { + openworkServerSettings, + setOpenworkServerSettings, + updateOpenworkServerSettings, + resetOpenworkServerSettings, + shareRemoteAccessBusy, + shareRemoteAccessError, + saveShareRemoteAccess, + openworkServerUrl, + openworkServerBaseUrl, + openworkServerAuth, + openworkServerClient, + openworkServerStatus, + openworkServerCapabilities, + openworkServerReady, + openworkServerWorkspaceReady, + resolvedOpenworkCapabilities, + openworkServerCanWriteSkills, + openworkServerCanWritePlugins, + openworkServerHostInfo, + openworkServerDiagnostics, + openworkReconnectBusy, + opencodeRouterInfoState, + orchestratorStatusState, + openworkAuditEntries, + openworkAuditStatus, + openworkAuditError, + devtoolsWorkspaceId, + checkOpenworkServer, + testOpenworkServerConnection, + reconnectOpenworkServer, + ensureLocalOpenworkServerClient, + }; +} diff --git a/apps/app/src/app/connections/provider.tsx b/apps/app/src/app/connections/provider.tsx new file mode 100644 index 00000000..723d083b --- /dev/null +++ b/apps/app/src/app/connections/provider.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +import type { ConnectionsStore } from "./store"; + +const ConnectionsContext = createContext(); + +export function ConnectionsProvider(props: ParentProps<{ store: ConnectionsStore }>) { + return ( + + {props.children} + + ); +} + +export function useConnections() { + const context = useContext(ConnectionsContext); + if (!context) { + throw new Error("useConnections must be used within a ConnectionsProvider"); + } + return context; +} diff --git a/apps/app/src/app/connections/store.ts b/apps/app/src/app/connections/store.ts new file mode 100644 index 00000000..b6eaf2b3 --- /dev/null +++ b/apps/app/src/app/connections/store.ts @@ -0,0 +1,628 @@ +import { createEffect, createSignal } from "solid-js"; + +import { homeDir } from "@tauri-apps/api/path"; +import { parse } from "jsonc-parser"; + +import { currentLocale, t } from "../../i18n"; +import { CHROME_DEVTOOLS_MCP_ID, MCP_QUICK_CONNECT, type McpDirectoryInfo } from "../constants"; +import { createClient, unwrap } from "../lib/opencode"; +import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log"; +import { readOpencodeConfig, writeOpencodeConfig, type OpencodeConfigFile } from "../lib/tauri"; +import { + parseMcpServersFromContent, + removeMcpFromConfig, + usesChromeDevtoolsAutoConnect, + validateMcpServerName, +} from "../mcp"; +import type { Client, McpServerEntry, McpStatusMap, ReloadReason, ReloadTrigger } from "../types"; +import { isTauriRuntime, normalizeDirectoryQueryPath, safeStringify } from "../utils"; +import { createWorkspaceContextKey } from "../context/workspace-context"; +import type { OpenworkServerStore } from "./openwork-server-store"; + +export type ConnectionsStore = ReturnType; + +export function createConnectionsStore(options: { + client: () => Client | null; + setClient: (value: Client | null) => void; + projectDir: () => string; + selectedWorkspaceId: () => string; + selectedWorkspaceRoot: () => string; + workspaceType: () => "local" | "remote"; + openworkServer: OpenworkServerStore; + runtimeWorkspaceId: () => string | null; + ensureRuntimeWorkspaceId?: () => Promise; + setProjectDir?: (value: string) => void; + developerMode: () => boolean; + markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void; +}) { + const translate = (key: string) => t(key, currentLocale()); + + const [mcpServers, setMcpServers] = createSignal([]); + const [mcpStatus, setMcpStatus] = createSignal(null); + const [mcpLastUpdatedAt, setMcpLastUpdatedAt] = createSignal(null); + const [mcpStatuses, setMcpStatuses] = createSignal({}); + const [mcpConnectingName, setMcpConnectingName] = createSignal(null); + const [selectedMcp, setSelectedMcp] = createSignal(null); + + const [mcpAuthModalOpen, setMcpAuthModalOpen] = createSignal(false); + const [mcpAuthEntry, setMcpAuthEntry] = createSignal(null); + const [mcpAuthNeedsReload, setMcpAuthNeedsReload] = createSignal(false); + + const workspaceContextKey = createWorkspaceContextKey({ + selectedWorkspaceId: options.selectedWorkspaceId, + selectedWorkspaceRoot: options.selectedWorkspaceRoot, + runtimeWorkspaceId: options.runtimeWorkspaceId, + workspaceType: options.workspaceType, + }); + + const filterConfiguredStatuses = (status: McpStatusMap, entries: McpServerEntry[]) => { + const configured = new Set(entries.map((entry) => entry.name)); + return Object.fromEntries(Object.entries(status).filter(([name]) => configured.has(name))) as McpStatusMap; + }; + + const readMcpConfigFile = async (scope: "project" | "global"): Promise => { + const projectDir = options.projectDir().trim(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + options.openworkServer.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + options.openworkServer.openworkServerCapabilities()?.config?.read; + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + return openworkClient.readOpencodeConfigFile(openworkWorkspaceId, scope); + } + + if (!isTauriRuntime()) { + return null; + } + + return readOpencodeConfig(scope, projectDir); + }; + + const ensureActiveClient = async () => { + let activeClient = options.client(); + if (activeClient) { + return activeClient; + } + + const openworkBaseUrl = options.openworkServer.openworkServerBaseUrl().trim(); + const token = options.openworkServer.openworkServerAuth().token?.trim(); + if (!openworkBaseUrl || !token) { + return null; + } + + activeClient = createClient(`${openworkBaseUrl.replace(/\/+$/, "")}/opencode`, undefined, { + token, + mode: "openwork", + }); + options.setClient(activeClient); + return activeClient; + }; + + const resolveWritableOpenworkTarget = async () => { + const openworkClient = options.openworkServer.openworkServerClient(); + let openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); + if (!openworkWorkspaceId && openworkClient && options.openworkServer.openworkServerStatus() === "connected") { + openworkWorkspaceId = (await options.ensureRuntimeWorkspaceId?.()) ?? null; + } + + const canUseOpenworkServer = + options.openworkServer.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.mcp?.write; + + return { + openworkClient, + openworkWorkspaceId, + canUseOpenworkServer: Boolean(canUseOpenworkServer), + }; + }; + + const resolveProjectDir = async (activeClient: Client | null, currentProjectDir: string) => { + let resolvedProjectDir = currentProjectDir; + if (!resolvedProjectDir && activeClient) { + try { + const pathInfo = unwrap(await activeClient.path.get()); + const discoveredRaw = normalizeDirectoryQueryPath(pathInfo.directory ?? ""); + const discovered = discoveredRaw.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); + if (discovered) { + resolvedProjectDir = discovered; + options.setProjectDir?.(discovered); + } + } catch { + // ignore + } + } + + return resolvedProjectDir; + }; + + async function refreshMcpServers() { + const projectDir = options.projectDir().trim(); + const isRemoteWorkspace = options.workspaceType() === "remote"; + const isLocalWorkspace = !isRemoteWorkspace; + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + options.openworkServer.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + options.openworkServer.openworkServerCapabilities()?.mcp?.read; + + if (isRemoteWorkspace) { + if (!canUseOpenworkServer) { + setMcpStatus("OpenWork server unavailable. MCP config is read-only."); + setMcpServers([]); + setMcpStatuses({}); + return; + } + + try { + setMcpStatus(null); + const response = await openworkClient.listMcp(openworkWorkspaceId); + const next = response.items.map((entry) => ({ + name: entry.name, + config: entry.config as McpServerEntry["config"], + })); + setMcpServers(next); + setMcpLastUpdatedAt(Date.now()); + + const activeClient = options.client(); + if (activeClient && projectDir) { + try { + const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); + setMcpStatuses(filterConfiguredStatuses(status as McpStatusMap, next)); + } catch { + setMcpStatuses({}); + } + } else { + setMcpStatuses({}); + } + + if (!next.length) { + setMcpStatus("No MCP servers configured yet."); + } + } catch (e) { + setMcpServers([]); + setMcpStatuses({}); + setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers"); + } + return; + } + + if (isLocalWorkspace && canUseOpenworkServer) { + try { + setMcpStatus(null); + const response = await openworkClient.listMcp(openworkWorkspaceId); + const next = response.items.map((entry) => ({ + name: entry.name, + config: entry.config as McpServerEntry["config"], + })); + setMcpServers(next); + setMcpLastUpdatedAt(Date.now()); + + const activeClient = options.client(); + if (activeClient && projectDir) { + try { + const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); + setMcpStatuses(filterConfiguredStatuses(status as McpStatusMap, next)); + } catch { + setMcpStatuses({}); + } + } else { + setMcpStatuses({}); + } + + if (!next.length) { + setMcpStatus("No MCP servers configured yet."); + } + } catch (e) { + setMcpServers([]); + setMcpStatuses({}); + setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers"); + } + return; + } + + if (!isTauriRuntime()) { + setMcpStatus("MCP configuration is only available for local workspaces."); + setMcpServers([]); + setMcpStatuses({}); + return; + } + + if (!projectDir) { + setMcpStatus("Pick a workspace folder to load MCP servers."); + setMcpServers([]); + setMcpStatuses({}); + return; + } + + try { + setMcpStatus(null); + const config = await readOpencodeConfig("project", projectDir); + if (!config.exists || !config.content) { + setMcpServers([]); + setMcpStatuses({}); + setMcpStatus("No opencode.json found yet. Create one by connecting an MCP."); + return; + } + + const next = parseMcpServersFromContent(config.content); + setMcpServers(next); + setMcpLastUpdatedAt(Date.now()); + + const activeClient = options.client(); + if (activeClient) { + try { + const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); + setMcpStatuses(filterConfiguredStatuses(status as McpStatusMap, next)); + } catch { + setMcpStatuses({}); + } + } + + if (!next.length) { + setMcpStatus("No MCP servers configured yet."); + } + } catch (e) { + setMcpServers([]); + setMcpStatuses({}); + setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers"); + } + } + + async function connectMcp(entry: McpDirectoryInfo) { + const startedAt = perfNow(); + const isRemoteWorkspace = + options.workspaceType() === "remote" || + (!isTauriRuntime() && options.openworkServer.openworkServerStatus() === "connected"); + const projectDir = options.projectDir().trim(); + const entryType = entry.type ?? "remote"; + + recordPerfLog(options.developerMode(), "mcp.connect", "start", { + name: entry.name, + type: entryType, + workspaceType: isRemoteWorkspace ? "remote" : "local", + projectDir: projectDir || null, + }); + + const { openworkClient, openworkWorkspaceId, canUseOpenworkServer } = await resolveWritableOpenworkTarget(); + + if (isRemoteWorkspace && !canUseOpenworkServer) { + setMcpStatus("OpenWork server unavailable. MCP config is read-only."); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "openwork-server-unavailable", + }); + return; + } + + if (!canUseOpenworkServer && !isTauriRuntime()) { + setMcpStatus(translate("mcp.desktop_required")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "desktop-required", + }); + return; + } + + if (!isRemoteWorkspace && !projectDir) { + setMcpStatus(translate("mcp.pick_workspace_first")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "missing-workspace", + }); + return; + } + + const activeClient = await ensureActiveClient(); + if (!activeClient) { + setMcpStatus(translate("mcp.connect_server_first")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "no-active-client", + }); + return; + } + + const resolvedProjectDir = await resolveProjectDir(activeClient, projectDir); + if (!resolvedProjectDir) { + setMcpStatus(translate("mcp.pick_workspace_first")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "missing-workspace-after-discovery", + }); + return; + } + + const slug = entry.id ?? entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + const action = mcpServers().some((server) => server.name === slug) ? "updated" : "added"; + + try { + setMcpStatus(null); + setMcpConnectingName(entry.name); + + let mcpEnvironment: Record | undefined; + + const mcpEntryConfig: Record = { + type: entryType, + enabled: true, + }; + + if (entryType === "remote") { + if (!entry.url) { + throw new Error("Missing MCP URL."); + } + mcpEntryConfig["url"] = entry.url; + if (entry.oauth) { + mcpEntryConfig["oauth"] = {}; + } + } + + if (entryType === "local") { + if (!entry.command?.length) { + throw new Error("Missing MCP command."); + } + mcpEntryConfig["command"] = entry.command; + + if (slug === CHROME_DEVTOOLS_MCP_ID && usesChromeDevtoolsAutoConnect(entry.command) && isTauriRuntime()) { + try { + const hostHome = (await homeDir()).replace(/[\\/]+$/, ""); + if (hostHome) { + mcpEnvironment = { HOME: hostHome }; + mcpEntryConfig["environment"] = mcpEnvironment; + } + } catch { + // ignore and let the MCP use the default worker environment + } + } + } + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + await openworkClient.addMcp(openworkWorkspaceId, { + name: slug, + config: mcpEntryConfig, + }); + } else { + const configFile = await readOpencodeConfig("project", resolvedProjectDir); + + let existingConfig: Record = {}; + if (configFile.exists && configFile.content?.trim()) { + try { + existingConfig = parse(configFile.content) ?? {}; + } catch (parseErr) { + recordPerfLog(options.developerMode(), "mcp.connect", "config-parse-failed", { + error: parseErr instanceof Error ? parseErr.message : String(parseErr), + }); + existingConfig = {}; + } + } + + if (!existingConfig["$schema"]) { + existingConfig["$schema"] = "https://opencode.ai/config.json"; + } + + const mcpSection = (existingConfig["mcp"] as Record) ?? {}; + existingConfig["mcp"] = mcpSection; + mcpSection[slug] = mcpEntryConfig; + + const writeResult = await writeOpencodeConfig( + "project", + resolvedProjectDir, + `${JSON.stringify(existingConfig, null, 2)}\n`, + ); + if (!writeResult.ok) { + throw new Error(writeResult.stderr || writeResult.stdout || "Failed to write opencode.json"); + } + } + + const mcpAddConfig = + entryType === "remote" + ? { + type: "remote" as const, + url: entry.url!, + enabled: true, + ...(entry.oauth ? { oauth: {} } : {}), + } + : { + type: "local" as const, + command: entry.command!, + enabled: true, + ...(mcpEnvironment ? { environment: mcpEnvironment } : {}), + }; + + const status = unwrap( + await activeClient.mcp.add({ + directory: resolvedProjectDir, + name: slug, + config: mcpAddConfig, + }), + ); + + setMcpStatuses(status as McpStatusMap); + options.markReloadRequired?.("mcp", { type: "mcp", name: slug, action }); + await refreshMcpServers(); + + if (entry.oauth) { + setMcpAuthEntry(entry); + setMcpAuthNeedsReload(true); + setMcpAuthModalOpen(true); + } else { + setMcpStatus(translate("mcp.connected")); + } + + await refreshMcpServers(); + finishPerf(options.developerMode(), "mcp.connect", "done", startedAt, { + name: entry.name, + type: entryType, + slug, + }); + } catch (e) { + setMcpStatus(e instanceof Error ? e.message : translate("mcp.connect_failed")); + finishPerf(options.developerMode(), "mcp.connect", "error", startedAt, { + name: entry.name, + type: entryType, + error: e instanceof Error ? e.message : safeStringify(e), + }); + } finally { + setMcpConnectingName(null); + } + } + + function authorizeMcp(entry: McpServerEntry) { + if (entry.config.type !== "remote" || entry.config.oauth === false) { + setMcpStatus(translate("mcp.login_unavailable")); + return; + } + + const matchingQuickConnect = MCP_QUICK_CONNECT.find((candidate) => { + const candidateSlug = candidate.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return candidateSlug === entry.name || candidate.name === entry.name; + }); + + setMcpAuthEntry( + matchingQuickConnect ?? { + name: entry.name, + description: "", + type: "remote", + url: entry.config.url, + oauth: true, + }, + ); + setMcpAuthNeedsReload(false); + setMcpAuthModalOpen(true); + } + + async function logoutMcpAuth(name: string) { + const isRemoteWorkspace = + options.workspaceType() === "remote" || + (!isTauriRuntime() && options.openworkServer.openworkServerStatus() === "connected"); + const projectDir = options.projectDir().trim(); + + const { openworkClient, openworkWorkspaceId, canUseOpenworkServer } = await resolveWritableOpenworkTarget(); + + if (isRemoteWorkspace && !canUseOpenworkServer) { + setMcpStatus("OpenWork server unavailable. MCP auth is read-only."); + return; + } + + if (!canUseOpenworkServer && !isTauriRuntime()) { + setMcpStatus(translate("mcp.desktop_required")); + return; + } + + const activeClient = await ensureActiveClient(); + if (!activeClient) { + setMcpStatus(translate("mcp.connect_server_first")); + return; + } + + const resolvedProjectDir = await resolveProjectDir(activeClient, projectDir); + if (!resolvedProjectDir) { + setMcpStatus(translate("mcp.pick_workspace_first")); + return; + } + + const safeName = validateMcpServerName(name); + setMcpStatus(null); + + try { + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + await openworkClient.logoutMcpAuth(openworkWorkspaceId, safeName); + } else { + try { + await activeClient.mcp.disconnect({ directory: resolvedProjectDir, name: safeName }); + } catch { + // ignore + } + await activeClient.mcp.auth.remove({ directory: resolvedProjectDir, name: safeName }); + } + + try { + const status = unwrap(await activeClient.mcp.status({ directory: resolvedProjectDir })); + setMcpStatuses(status as McpStatusMap); + } catch { + // ignore + } + + await refreshMcpServers(); + setMcpStatus(translate("mcp.logout_success").replace("{server}", safeName)); + } catch (e) { + setMcpStatus(e instanceof Error ? e.message : translate("mcp.logout_failed")); + } + } + + async function removeMcp(name: string) { + try { + setMcpStatus(null); + + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + options.openworkServer.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + options.openworkServer.openworkServerCapabilities()?.mcp?.write; + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + await openworkClient.removeMcp(openworkWorkspaceId, name); + } else { + const projectDir = options.projectDir().trim(); + if (!projectDir) { + setMcpStatus(translate("mcp.pick_workspace_first")); + return; + } + await removeMcpFromConfig(projectDir, name); + } + + options.markReloadRequired?.("mcp", { type: "mcp", name, action: "removed" }); + await refreshMcpServers(); + if (selectedMcp() === name) { + setSelectedMcp(null); + } + setMcpStatus(null); + } catch (e) { + setMcpStatus(e instanceof Error ? e.message : translate("mcp.remove_failed")); + } + } + + function closeMcpAuthModal() { + setMcpAuthModalOpen(false); + setMcpAuthEntry(null); + setMcpAuthNeedsReload(false); + } + + async function completeMcpAuthModal() { + closeMcpAuthModal(); + await refreshMcpServers(); + } + + createEffect(() => { + if (!isTauriRuntime()) return; + workspaceContextKey(); + options.projectDir(); + void refreshMcpServers(); + }); + + return { + mcpServers, + mcpStatus, + mcpLastUpdatedAt, + mcpStatuses, + mcpConnectingName, + selectedMcp, + setSelectedMcp, + quickConnect: MCP_QUICK_CONNECT, + readMcpConfigFile, + refreshMcpServers, + connectMcp, + authorizeMcp, + logoutMcpAuth, + removeMcp, + mcpAuthModalOpen, + mcpAuthEntry, + mcpAuthNeedsReload, + closeMcpAuthModal, + completeMcpAuthModal, + }; +} diff --git a/apps/app/src/app/constants.ts b/apps/app/src/app/constants.ts index 5b01d3ac..2ad7080c 100644 --- a/apps/app/src/app/constants.ts +++ b/apps/app/src/app/constants.ts @@ -6,7 +6,6 @@ export const THINKING_PREF_KEY = "openwork.showThinking"; export const VARIANT_PREF_KEY = "openwork.modelVariant"; export const LANGUAGE_PREF_KEY = "openwork.language"; export const HIDE_TITLEBAR_PREF_KEY = "openwork.hideTitlebar"; -export const AUTO_COMPACT_CONTEXT_PREF_KEY = "openwork.autoCompactContext"; export const DEFAULT_MODEL: ModelRef = { providerID: "opencode", diff --git a/apps/app/src/app/context/automations.ts b/apps/app/src/app/context/automations.ts new file mode 100644 index 00000000..77966544 --- /dev/null +++ b/apps/app/src/app/context/automations.ts @@ -0,0 +1,267 @@ +import { createEffect, createMemo, createSignal } from "solid-js"; + +import type { ScheduledJob } from "../types"; +import { schedulerDeleteJob, schedulerListJobs } from "../lib/tauri"; +import { isTauriRuntime } from "../utils"; +import { createWorkspaceContextKey } from "./workspace-context"; +import type { OpenworkServerStore } from "../connections/openwork-server-store"; + +export type AutomationsStore = ReturnType; + +export type AutomationActionPlan = + | { ok: true; mode: "session_prompt"; prompt: string } + | { ok: false; error: string }; + +export type PrepareCreateAutomationInput = { + name: string; + prompt: string; + schedule: string; + workdir?: string | null; +}; + +const normalizeSentence = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (/[.!?]$/.test(trimmed)) return trimmed; + return `${trimmed}.`; +}; + +const buildCreateAutomationPrompt = ( + input: PrepareCreateAutomationInput, +): AutomationActionPlan => { + const name = input.name.trim(); + const schedule = input.schedule.trim(); + const prompt = normalizeSentence(input.prompt); + if (!schedule) { + return { ok: false, error: "Schedule is required." }; + } + if (!prompt) { + return { ok: false, error: "Prompt is required." }; + } + const workdir = (input.workdir ?? "").trim(); + const nameSegment = name ? ` named \"${name}\"` : ""; + const workdirSegment = workdir ? ` Run from ${workdir}.` : ""; + return { + ok: true, + mode: "session_prompt", + prompt: `Schedule a job${nameSegment} with cron \"${schedule}\" to ${prompt}${workdirSegment}`.trim(), + }; +}; + +const buildRunAutomationPrompt = ( + job: ScheduledJob, + fallbackWorkdir?: string | null, +): AutomationActionPlan => { + const workdir = (job.workdir ?? fallbackWorkdir ?? "").trim(); + const workdirSegment = workdir ? `\n\nRun from ${workdir}.` : ""; + + if (job.run?.prompt || job.prompt) { + const promptBody = (job.run?.prompt ?? job.prompt ?? "").trim(); + if (!promptBody) { + return { ok: false, error: "Automation prompt is empty." }; + } + return { + ok: true, + mode: "session_prompt", + prompt: `Run this automation now: ${job.name}.\nSchedule: ${job.schedule}.\n\n${promptBody}${workdirSegment}`.trim(), + }; + } + + if (job.run?.command) { + const args = job.run.arguments ? ` ${job.run.arguments}` : ""; + const command = `${job.run.command}${args}`.trim(); + return { + ok: true, + mode: "session_prompt", + prompt: `Run this automation now: ${job.name}.\nSchedule: ${job.schedule}.\n\nRun the following command:\n${command}${workdirSegment}`.trim(), + }; + } + + return { + ok: true, + mode: "session_prompt", + prompt: `Run this automation now: ${job.name}.\nSchedule: ${job.schedule}.`.trim(), + }; +}; + +export function createAutomationsStore(options: { + selectedWorkspaceId: () => string; + selectedWorkspaceRoot: () => string; + runtimeWorkspaceId: () => string | null; + openworkServer: OpenworkServerStore; + schedulerPluginInstalled: () => boolean; +}) { + const [scheduledJobs, setScheduledJobs] = createSignal([]); + const [scheduledJobsStatus, setScheduledJobsStatus] = createSignal(null); + const [scheduledJobsBusy, setScheduledJobsBusy] = createSignal(false); + const [scheduledJobsUpdatedAt, setScheduledJobsUpdatedAt] = createSignal(null); + const [pendingRefreshContextKey, setPendingRefreshContextKey] = createSignal(null); + + const serverBacked = createMemo(() => { + const client = options.openworkServer.openworkServerClient(); + const runtimeWorkspaceId = (options.runtimeWorkspaceId() ?? "").trim(); + return options.openworkServer.openworkServerStatus() === "connected" && Boolean(client && runtimeWorkspaceId); + }); + + const scheduledJobsSource = createMemo<"local" | "remote">(() => + serverBacked() ? "remote" : "local", + ); + + const scheduledJobsContextKey = createWorkspaceContextKey({ + selectedWorkspaceId: options.selectedWorkspaceId, + selectedWorkspaceRoot: options.selectedWorkspaceRoot, + runtimeWorkspaceId: options.runtimeWorkspaceId, + }); + + const scheduledJobsPollingAvailable = createMemo(() => { + if (scheduledJobsSource() === "remote") return true; + return isTauriRuntime() && options.schedulerPluginInstalled(); + }); + + const refreshScheduledJobs = async ( + _options?: { force?: boolean }, + ): Promise<"success" | "error" | "unavailable" | "skipped"> => { + const requestContextKey = scheduledJobsContextKey(); + if (!requestContextKey) return "skipped"; + + if (scheduledJobsBusy()) { + setPendingRefreshContextKey(requestContextKey); + return "skipped"; + } + + if (scheduledJobsSource() === "remote") { + const client = options.openworkServer.openworkServerClient(); + const workspaceId = (options.runtimeWorkspaceId() ?? "").trim(); + if (!client || options.openworkServer.openworkServerStatus() !== "connected" || !workspaceId) { + if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; + const status = + options.openworkServer.openworkServerStatus() === "disconnected" + ? "OpenWork server unavailable. Connect to sync scheduled tasks." + : options.openworkServer.openworkServerStatus() === "limited" + ? "OpenWork server needs a token to load scheduled tasks." + : "OpenWork server not ready."; + setScheduledJobsStatus(status); + return "unavailable"; + } + + setScheduledJobsBusy(true); + setScheduledJobsStatus(null); + try { + const response = await client.listScheduledJobs(workspaceId); + if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; + setScheduledJobs(Array.isArray(response.items) ? response.items : []); + setScheduledJobsUpdatedAt(Date.now()); + return "success"; + } catch (error) { + if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; + const message = error instanceof Error ? error.message : String(error); + setScheduledJobsStatus(message || "Failed to load scheduled tasks."); + return "error"; + } finally { + setScheduledJobsBusy(false); + } + } + + if (!isTauriRuntime() || !options.schedulerPluginInstalled()) { + if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; + setScheduledJobsStatus(null); + return "unavailable"; + } + + setScheduledJobsBusy(true); + setScheduledJobsStatus(null); + try { + const root = options.selectedWorkspaceRoot().trim(); + const jobs = await schedulerListJobs(root || undefined); + if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; + setScheduledJobs(jobs); + setScheduledJobsUpdatedAt(Date.now()); + return "success"; + } catch (error) { + if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; + const message = error instanceof Error ? error.message : String(error); + setScheduledJobsStatus(message || "Failed to load scheduled tasks."); + return "error"; + } finally { + setScheduledJobsBusy(false); + } + }; + + const deleteScheduledJob = async (name: string) => { + if (scheduledJobsSource() === "remote") { + const client = options.openworkServer.openworkServerClient(); + const workspaceId = (options.runtimeWorkspaceId() ?? "").trim(); + if (!client || !workspaceId) { + throw new Error("OpenWork server unavailable. Connect to sync scheduled tasks."); + } + const response = await client.deleteScheduledJob(workspaceId, name); + setScheduledJobs((current) => current.filter((entry) => entry.slug !== response.job.slug)); + return; + } + + if (!isTauriRuntime()) { + throw new Error("Scheduled tasks require the desktop app."); + } + const root = options.selectedWorkspaceRoot().trim(); + const job = await schedulerDeleteJob(name, root || undefined); + setScheduledJobs((current) => current.filter((entry) => entry.slug !== job.slug)); + }; + + const prepareCreateAutomation = (input: PrepareCreateAutomationInput) => + buildCreateAutomationPrompt(input); + + const prepareRunAutomation = ( + job: ScheduledJob, + fallbackWorkdir?: string | null, + ) => buildRunAutomationPrompt(job, fallbackWorkdir); + + createEffect(() => { + scheduledJobsContextKey(); + setScheduledJobs([]); + setScheduledJobsStatus(null); + setScheduledJobsUpdatedAt(null); + setPendingRefreshContextKey(null); + }); + + createEffect(() => { + const key = scheduledJobsContextKey(); + if (!key) return; + if (scheduledJobsBusy()) return; + if (scheduledJobsUpdatedAt()) return; + void refreshScheduledJobs(); + }); + + createEffect(() => { + const pending = pendingRefreshContextKey(); + if (!pending) return; + if (scheduledJobsBusy()) return; + if (pending !== scheduledJobsContextKey()) { + setPendingRefreshContextKey(scheduledJobsContextKey()); + return; + } + setPendingRefreshContextKey(null); + void refreshScheduledJobs(); + }); + + return { + scheduledJobs, + scheduledJobsStatus, + scheduledJobsBusy, + scheduledJobsUpdatedAt, + scheduledJobsSource, + scheduledJobsPollingAvailable, + scheduledJobsContextKey, + refreshScheduledJobs, + deleteScheduledJob, + jobs: scheduledJobs, + jobsStatus: scheduledJobsStatus, + jobsBusy: scheduledJobsBusy, + jobsUpdatedAt: scheduledJobsUpdatedAt, + jobsSource: scheduledJobsSource, + pollingAvailable: scheduledJobsPollingAvailable, + refresh: refreshScheduledJobs, + remove: deleteScheduledJob, + prepareCreateAutomation, + prepareRunAutomation, + }; +} diff --git a/apps/app/src/app/context/extensions.ts b/apps/app/src/app/context/extensions.ts index ba225cba..5950ce1f 100644 --- a/apps/app/src/app/context/extensions.ts +++ b/apps/app/src/app/context/extensions.ts @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js"; +import { createEffect, createMemo, createSignal } from "solid-js"; import { applyEdits, modify } from "jsonc-parser"; import { join } from "@tauri-apps/api/path"; @@ -25,34 +25,46 @@ import { writeOpencodeConfig, type OpencodeConfigFile, } from "../lib/tauri"; -import type { - OpenworkHubRepo, - OpenworkServerCapabilities, - OpenworkServerClient, - OpenworkServerStatus, -} from "../lib/openwork-server"; +import type { OpenworkHubRepo, OpenworkServerClient } from "../lib/openwork-server"; +import { createWorkspaceContextKey } from "./workspace-context"; +import type { OpenworkServerStore } from "../connections/openwork-server-store"; export type ExtensionsStore = ReturnType; export function createExtensionsStore(options: { client: () => Client | null; projectDir: () => string; - activeWorkspaceRoot: () => string; + selectedWorkspaceId: () => string; + selectedWorkspaceRoot: () => string; workspaceType: () => "local" | "remote"; - openworkServerClient: () => OpenworkServerClient | null; - openworkServerStatus: () => OpenworkServerStatus; - openworkServerCapabilities: () => OpenworkServerCapabilities | null; - openworkServerWorkspaceId: () => string | null; + openworkServer: OpenworkServerStore; + runtimeWorkspaceId: () => string | null; setBusy: (value: boolean) => void; setBusyLabel: (value: string | null) => void; setBusyStartedAt: (value: number | null) => void; setError: (value: string | null) => void; markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void; - onNotionSkillInstalled?: () => void; }) { // Translation helper that uses current language from i18n const translate = (key: string) => t(key, currentLocale()); + // ── Workspace context tracking ────────────────────── + const workspaceContextKey = createWorkspaceContextKey({ + selectedWorkspaceId: options.selectedWorkspaceId, + selectedWorkspaceRoot: options.selectedWorkspaceRoot, + runtimeWorkspaceId: options.runtimeWorkspaceId, + workspaceType: options.workspaceType, + }); + + // Per-resource staleness: tracks the context key each resource was last loaded for. + const [skillsContextKey, setSkillsContextKey] = createSignal(""); + const [pluginsContextKey, setPluginsContextKey] = createSignal(""); + const [hubSkillsContextKey, setHubSkillsContextKey] = createSignal(""); + + const skillsStale = createMemo(() => skillsContextKey() !== workspaceContextKey()); + const pluginsStale = createMemo(() => pluginsContextKey() !== workspaceContextKey()); + const hubSkillsStale = createMemo(() => hubSkillsContextKey() !== workspaceContextKey()); + const [skills, setSkills] = createSignal([]); const [skillsStatus, setSkillsStatus] = createSignal(null); @@ -211,13 +223,13 @@ export function createExtensionsStore(options: { } async function refreshHubSkills(optionsOverride?: { force?: boolean }) { - const root = options.activeWorkspaceRoot().trim(); + const root = options.selectedWorkspaceRoot().trim(); const repo = hubRepo(); const loadKey = `${root}::${repo ? hubRepoKey(repo) : "none"}`; - const openworkClient = options.openworkServerClient(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkCapabilities?.hub?.skills?.read && typeof (openworkClient as any).listHubSkills === "function"; @@ -264,6 +276,7 @@ export function createExtensionsStore(options: { if (!next.length) setHubSkillsStatus("No hub skills found."); hubSkillsLoaded = true; hubSkillsLoadKey = loadKey; + setHubSkillsContextKey(workspaceContextKey()); return; } @@ -295,6 +308,7 @@ export function createExtensionsStore(options: { if (!sorted.length) setHubSkillsStatus("No hub skills found."); hubSkillsLoaded = true; hubSkillsLoadKey = loadKey; + setHubSkillsContextKey(workspaceContextKey()); } catch (e) { if (refreshHubSkillsAborted) return; setHubSkills([]); @@ -313,11 +327,11 @@ export function createExtensionsStore(options: { } const isRemoteWorkspace = options.workspaceType() === "remote"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.hub?.skills?.install && @@ -366,14 +380,14 @@ export function createExtensionsStore(options: { }; async function refreshSkills(optionsOverride?: { force?: boolean }) { - const root = options.activeWorkspaceRoot().trim(); + const root = options.selectedWorkspaceRoot().trim(); const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.skills?.read; @@ -421,6 +435,7 @@ export function createExtensionsStore(options: { } skillsLoaded = true; skillsRoot = root; + setSkillsContextKey(workspaceContextKey()); } catch (e) { if (refreshSkillsAborted) return; setSkills([]); @@ -470,6 +485,7 @@ export function createExtensionsStore(options: { } skillsLoaded = true; skillsRoot = root; + setSkillsContextKey(workspaceContextKey()); } catch (e) { if (refreshSkillsAborted) return; setSkills([]); @@ -542,6 +558,7 @@ export function createExtensionsStore(options: { } skillsLoaded = true; skillsRoot = root; + setSkillsContextKey(workspaceContextKey()); } catch (e) { if (refreshSkillsAborted) return; setSkills([]); @@ -554,11 +571,11 @@ export function createExtensionsStore(options: { async function refreshPlugins(scopeOverride?: PluginScope) { const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.plugins?.read; @@ -600,6 +617,7 @@ export function createExtensionsStore(options: { const list = configItems.map((item) => item.spec); setPluginList(list); setSidebarPluginList(list); + setPluginsContextKey(workspaceContextKey()); if (!list.length) { setPluginStatus("No plugins configured yet."); @@ -674,6 +692,7 @@ export function createExtensionsStore(options: { } loadPluginsFromConfig(config); + setPluginsContextKey(workspaceContextKey()); } catch (e) { if (refreshPluginsAborted) return; setPluginConfig(null); @@ -694,11 +713,11 @@ export function createExtensionsStore(options: { const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.plugins?.write; @@ -719,6 +738,7 @@ export function createExtensionsStore(options: { try { setPluginStatus(null); await openworkClient.addPlugin(openworkWorkspaceId, pluginName); + options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "added" }); if (isManualInput) { setPluginInput(""); } @@ -798,11 +818,11 @@ export function createExtensionsStore(options: { const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.plugins?.write; @@ -816,6 +836,7 @@ export function createExtensionsStore(options: { try { setPluginStatus(null); await openworkClient.removePlugin(openworkWorkspaceId, name); + options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "removed" }); await refreshPlugins("project"); } catch (e) { setPluginStatus(e instanceof Error ? e.message : "Failed to remove plugin."); @@ -926,11 +947,11 @@ export function createExtensionsStore(options: { async function installSkillCreator(): Promise<{ ok: boolean; message: string }> { const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.skills?.write; @@ -983,7 +1004,7 @@ export function createExtensionsStore(options: { return { ok: false, message }; } - const targetDir = options.activeWorkspaceRoot().trim(); + const targetDir = options.selectedWorkspaceRoot().trim(); if (!targetDir) { const message = translate("skills.pick_workspace_first"); setSkillsStatus(message); @@ -1034,7 +1055,7 @@ export function createExtensionsStore(options: { return; } - const root = options.activeWorkspaceRoot().trim(); + const root = options.selectedWorkspaceRoot().trim(); if (!root) { setSkillsStatus(translate("skills.pick_workspace_first")); return; @@ -1076,7 +1097,7 @@ export function createExtensionsStore(options: { return; } - const root = options.activeWorkspaceRoot().trim(); + const root = options.selectedWorkspaceRoot().trim(); if (!root) { setSkillsStatus(translate("skills.pick_workspace_first")); return; @@ -1113,7 +1134,7 @@ export function createExtensionsStore(options: { const trimmed = name.trim(); if (!trimmed) return null; - const root = options.activeWorkspaceRoot().trim(); + const root = options.selectedWorkspaceRoot().trim(); if (!root) { setSkillsStatus(translate("skills.pick_workspace_first")); return null; @@ -1121,11 +1142,11 @@ export function createExtensionsStore(options: { const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.skills?.read && @@ -1179,7 +1200,7 @@ export function createExtensionsStore(options: { const trimmed = input.name.trim(); if (!trimmed) return; - const root = options.activeWorkspaceRoot().trim(); + const root = options.selectedWorkspaceRoot().trim(); if (!root) { setSkillsStatus(translate("skills.pick_workspace_first")); return; @@ -1187,11 +1208,11 @@ export function createExtensionsStore(options: { const isRemoteWorkspace = options.workspaceType() === "remote"; const isLocalWorkspace = options.workspaceType() === "local"; - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.openworkServerWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); + const openworkClient = options.openworkServer.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServer.openworkServerCapabilities(); const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && + options.openworkServer.openworkServerStatus() === "connected" && openworkClient && openworkWorkspaceId && openworkCapabilities?.skills?.write; @@ -1259,6 +1280,56 @@ export function createExtensionsStore(options: { refreshHubSkillsAborted = true; } + /** + * Ensure skills are fresh for the current workspace context. + * Call this from any visible surface that needs skills data. + * It will only fetch if data is stale or missing. + */ + function ensureSkillsFresh() { + if (!skillsStale()) return; + void refreshSkills({ force: true }); + } + + /** + * Ensure plugins are fresh for the current workspace context. + */ + function ensurePluginsFresh(scopeOverride?: PluginScope) { + if (!pluginsStale()) return; + void refreshPlugins(scopeOverride); + } + + /** + * Ensure hub skills are fresh for the current workspace context. + */ + function ensureHubSkillsFresh() { + if (!hubSkillsStale()) return; + void refreshHubSkills({ force: true }); + } + + // When workspace context changes, invalidate caches and refresh core + // resources (skills + plugins) that are visible across many surfaces + // (sidebar context panel, session view, dashboard panels). + // Hub skills are deferred — only refreshed when the skills panel opens. + // + // Placed after all function definitions to avoid uninitialized variable + // references (ES module strict mode does not hoist function declarations + // past their lexical position during the initial synchronous pass). + createEffect(() => { + const key = workspaceContextKey(); + // Reset in-memory cache flags so the next refresh actually fetches. + skillsLoaded = false; + hubSkillsLoaded = false; + skillsRoot = ""; + hubSkillsLoadKey = ""; + + // Skip the very first run (empty key = no workspace selected yet). + if (!key || key === "::::") return; + + // Refresh core resources that are needed across many surfaces. + void refreshSkills({ force: true }); + void refreshPlugins(); + }); + return { skills, skillsStatus, @@ -1295,5 +1366,13 @@ export function createExtensionsStore(options: { readSkill, saveSkill, abortRefreshes, + // Freshness model + workspaceContextKey, + skillsStale, + pluginsStale, + hubSkillsStale, + ensureSkillsFresh, + ensurePluginsFresh, + ensureHubSkillsFresh, }; } diff --git a/apps/app/src/app/context/global-sync.tsx b/apps/app/src/app/context/global-sync.tsx index 8409e5d3..bcc3a597 100644 --- a/apps/app/src/app/context/global-sync.tsx +++ b/apps/app/src/app/context/global-sync.tsx @@ -19,7 +19,7 @@ import type { import type { McpStatusMap, TodoItem } from "../types"; import { unwrap } from "../lib/opencode"; import { safeStringify } from "../utils"; -import { mapConfigProvidersToList } from "../utils/providers"; +import { filterProviderList, mapConfigProvidersToList } from "../utils/providers"; import { useGlobalSDK } from "./global-sdk"; export type WorkspaceState = { @@ -115,16 +115,32 @@ export function GlobalSyncProvider(props: ParentProps) { }; const refreshProviders = async () => { + let disabledProviders = globalStore.config.disabled_providers ?? []; try { - const result = unwrap(await globalSDK.client().provider.list()); + const config = unwrap(await globalSDK.client().config.get()); + disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; + } catch { + // ignore config read failures and continue with current store state + } + try { + const result = filterProviderList( + unwrap(await globalSDK.client().provider.list()), + disabledProviders, + ); setGlobalStore("provider", result); } catch { const fallback = unwrap(await globalSDK.client().config.providers()) as ConfigProvidersResponse; - setGlobalStore("provider", { - all: mapConfigProvidersToList(fallback.providers), - connected: [], - default: fallback.default, - }); + setGlobalStore( + "provider", + filterProviderList( + { + all: mapConfigProvidersToList(fallback.providers), + connected: [], + default: fallback.default, + }, + disabledProviders, + ), + ); } }; diff --git a/apps/app/src/app/context/local.tsx b/apps/app/src/app/context/local.tsx index dd50539c..d5005a1d 100644 --- a/apps/app/src/app/context/local.tsx +++ b/apps/app/src/app/context/local.tsx @@ -1,12 +1,13 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; +import { createContext, createEffect, useContext, type ParentProps } from "solid-js"; import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; -import type { DashboardTab, ModelRef, View } from "../types"; +import { THINKING_PREF_KEY } from "../constants"; +import type { ModelRef, SettingsTab, View } from "../types"; import { Persist, persisted } from "../utils/persist"; type LocalUIState = { view: View; - tab: DashboardTab; + tab: SettingsTab; }; type LocalPreferences = { @@ -29,8 +30,8 @@ export function LocalProvider(props: ParentProps) { const [ui, setUi, , uiReady] = persisted( Persist.global("local.ui", ["openwork.ui"]), createStore({ - view: "onboarding", - tab: "scheduled", + view: "settings", + tab: "general", }), ); @@ -45,6 +46,29 @@ export function LocalProvider(props: ParentProps) { const ready = () => uiReady() && prefsReady(); + createEffect(() => { + if (!prefsReady()) return; + if (typeof window === "undefined") return; + + const raw = window.localStorage.getItem(THINKING_PREF_KEY); + if (raw == null) return; + + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "boolean") { + setPrefs("showThinking", parsed); + } + } catch { + // ignore invalid legacy values + } + + try { + window.localStorage.removeItem(THINKING_PREF_KEY); + } catch { + // ignore + } + }); + const value: LocalContextValue = { ui, setUi, diff --git a/apps/app/src/app/context/model-config.ts b/apps/app/src/app/context/model-config.ts new file mode 100644 index 00000000..c180c4f6 --- /dev/null +++ b/apps/app/src/app/context/model-config.ts @@ -0,0 +1,1292 @@ +import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"; + +import { parse } from "jsonc-parser"; + +import { currentLocale, t } from "../../i18n"; +import { DEFAULT_MODEL, MODEL_PREF_KEY, SESSION_MODEL_PREF_KEY, VARIANT_PREF_KEY } from "../constants"; +import { readOpencodeConfig, writeOpencodeConfig } from "../lib/tauri"; +import { + formatGenericBehaviorLabel, + getModelBehaviorSummary, + normalizeModelBehaviorValue, + sanitizeModelBehaviorValue, +} from "../lib/model-behavior"; +import type { + OpenworkServerCapabilities, + OpenworkServerClient, + OpenworkServerStatus, +} from "../lib/openwork-server"; +import type { + Client, + MessageWithParts, + ModelOption, + ModelRef, + ProviderListItem, + WorkspaceDisplay, +} from "../types"; +import { + addOpencodeCacheHint, + formatModelLabel, + formatModelRef, + isTauriRuntime, + lastUserModelFromMessages, + modelEquals, + parseModelRef, + safeStringify, +} from "../utils"; +import { compareProviders, providerPriorityRank } from "../utils/providers"; + +export type SessionChoiceOverride = { + model?: ModelRef | null; + variant?: string | null; +}; + +export type SessionModelState = { + overrides: Record; + resolved: Record; +}; + +export type ModelPickerTarget = "default" | "session"; +export type PromptFocusReturnTarget = "none" | "composer"; + +const hasOwn = (value: object, key: K): value is Record => + Object.prototype.hasOwnProperty.call(value, key); + +const normalizeVariantOverride = (value: unknown) => { + if (typeof value === "string") return normalizeModelBehaviorValue(value); + if (value == null) return null; + return null; +}; + +const parseStoredModel = (value: unknown) => { + if (typeof value === "string") return parseModelRef(value); + if (!value || typeof value !== "object") return null; + + const record = value as Record; + if (typeof record.providerID === "string" && typeof record.modelID === "string") { + return { + providerID: record.providerID, + modelID: record.modelID, + }; + } + + return null; +}; + +const normalizeSessionChoice = (value: SessionChoiceOverride | null | undefined) => { + if (!value || typeof value !== "object") return null; + + const next: SessionChoiceOverride = {}; + if (value.model) { + next.model = value.model; + } + + if (hasOwn(value, "variant")) { + next.variant = normalizeModelBehaviorValue(value.variant ?? null); + } + + return hasOwn(next, "variant") || next.model ? next : null; +}; + +const deriveSessionModelOverrides = (choices: Record) => { + const next: Record = {}; + for (const [sessionId, choice] of Object.entries(choices)) { + if (choice.model) next[sessionId] = choice.model; + } + return next; +}; + +const applySessionModelState = ( + currentChoices: Record, + nextState: SessionModelState, +) => { + const nextChoices: Record = {}; + + for (const [sessionId, choice] of Object.entries(currentChoices)) { + if (hasOwn(choice, "variant") && !nextState.overrides[sessionId]) { + nextChoices[sessionId] = { variant: choice.variant ?? null }; + } + } + + for (const [sessionId, model] of Object.entries(nextState.overrides)) { + const current = currentChoices[sessionId]; + const nextChoice = normalizeSessionChoice({ + model, + ...(current && hasOwn(current, "variant") ? { variant: current.variant ?? null } : {}), + }); + if (nextChoice) nextChoices[sessionId] = nextChoice; + } + + return nextChoices; +}; + +const parseDefaultModelFromConfig = (content: string | null) => { + if (!content) return null; + try { + const parsed = parse(content) as Record | undefined; + const rawModel = typeof parsed?.model === "string" ? parsed.model : null; + return parseModelRef(rawModel); + } catch { + return null; + } +}; + +const formatConfigWithDefaultModel = (content: string | null, model: ModelRef) => { + let config: Record = {}; + if (content?.trim()) { + try { + const parsed = parse(content) as Record | undefined; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + config = { ...parsed }; + } + } catch { + config = {}; + } + } + + if (!config["$schema"]) { + config["$schema"] = "https://opencode.ai/config.json"; + } + + config.model = formatModelRef(model); + return `${JSON.stringify(config, null, 2)}\n`; +}; + +const parseAutoCompactContextFromConfig = (content: string | null) => { + if (!content) return null; + try { + const parsed = parse(content) as Record | undefined; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const compaction = parsed.compaction; + if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) { + return null; + } + return typeof (compaction as Record).auto === "boolean" + ? ((compaction as Record).auto as boolean) + : null; + } catch { + return null; + } +}; + +const formatConfigWithAutoCompactContext = (content: string | null, enabled: boolean) => { + let config: Record = {}; + if (content?.trim()) { + try { + const parsed = parse(content) as Record | undefined; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + config = { ...parsed }; + } + } catch { + config = {}; + } + } + + if (!config["$schema"]) { + config["$schema"] = "https://opencode.ai/config.json"; + } + + const compaction = + typeof config.compaction === "object" && config.compaction && !Array.isArray(config.compaction) + ? { ...(config.compaction as Record) } + : {}; + + compaction.auto = enabled; + config.compaction = compaction; + return `${JSON.stringify(config, null, 2)}\n`; +}; + +const getConfigSnapshot = (content: string | null) => { + if (!content?.trim()) return ""; + try { + const parsed = parse(content) as Record; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const copy = { ...parsed }; + delete copy.model; + return JSON.stringify(copy); + } + return content; + } catch { + return content; + } +}; + +const ensureRecord = (value: unknown): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +}; + +const readAutoCompactContextFromRecord = (value: unknown) => { + const compaction = ensureRecord(ensureRecord(value).compaction); + return typeof compaction.auto === "boolean" ? compaction.auto : null; +}; + +const readStoredDefaultModel = () => { + if (typeof window === "undefined") return DEFAULT_MODEL; + try { + const stored = window.localStorage.getItem(MODEL_PREF_KEY); + return parseModelRef(stored) ?? DEFAULT_MODEL; + } catch { + return DEFAULT_MODEL; + } +}; + +export const sessionModelOverridesKey = (workspaceId: string) => + `${SESSION_MODEL_PREF_KEY}.${workspaceId}`; + +export const workspaceModelVariantsKey = (workspaceId: string) => + `${VARIANT_PREF_KEY}.${workspaceId}`; + +export const parseSessionChoiceOverrides = (raw: string | null) => { + if (!raw) return {} as Record; + + try { + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {} as Record; + } + + const next: Record = {}; + for (const [sessionId, value] of Object.entries(parsed)) { + if (typeof value === "string") { + const model = parseModelRef(value); + if (model) next[sessionId] = { model }; + continue; + } + + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const record = value as Record; + const model = parseStoredModel(record.model ?? record); + const choice = normalizeSessionChoice({ + ...(model ? { model } : {}), + ...(hasOwn(record, "variant") ? { variant: normalizeVariantOverride(record.variant) } : {}), + }); + + if (choice) next[sessionId] = choice; + } + + return next; + } catch { + return {} as Record; + } +}; + +export const serializeSessionChoiceOverrides = ( + overrides: Record, +) => { + const entries = Object.entries(overrides) + .map(([sessionId, choice]) => [sessionId, normalizeSessionChoice(choice)] as const) + .filter((entry): entry is readonly [string, SessionChoiceOverride] => Boolean(entry[1])); + + if (!entries.length) return null; + + const payload: Record = {}; + for (const [sessionId, choice] of entries) { + const next: { model?: string; variant?: string | null } = {}; + if (choice.model) next.model = formatModelRef(choice.model); + if (hasOwn(choice, "variant")) next.variant = choice.variant ?? null; + payload[sessionId] = next; + } + + return JSON.stringify(payload); +}; + +export const parseWorkspaceModelVariants = ( + raw: string | null, + fallbackModel: ModelRef = DEFAULT_MODEL, +) => { + if (!raw || !raw.trim()) return {} as Record; + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + const normalized = normalizeModelBehaviorValue(raw); + return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; + } + + const next: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + const normalized = normalizeVariantOverride(value); + if (normalized) next[key] = normalized; + } + return next; + } catch { + const normalized = normalizeModelBehaviorValue(raw); + return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; + } +}; + +export function createModelConfigStore(options: { + client: Accessor; + selectedSessionId: Accessor; + messages: Accessor; + providers: Accessor; + providerDefaults: Accessor>; + providerConnectedIds: Accessor; + selectedWorkspaceId: Accessor; + selectedWorkspaceDisplay: Accessor; + selectedWorkspacePath: Accessor; + openworkServerClient: Accessor; + openworkServerStatus: Accessor; + openworkServerCapabilities: Accessor; + runtimeWorkspaceId: Accessor; + focusSessionPromptSoon: () => void; + setError: (value: string | null) => void; + setLastKnownConfigSnapshot: (value: string) => void; + markOpencodeConfigReloadRequired: () => void; +}) { + const initialDefaultModel = readStoredDefaultModel(); + + const [sessionChoiceOverrideById, setSessionChoiceOverrideById] = createSignal< + Record + >({}); + const [sessionModelById, setSessionModelById] = createSignal>({}); + const [pendingSessionChoice, setPendingSessionChoice] = createSignal( + null, + ); + const [sessionModelOverridesReady, setSessionModelOverridesReady] = createSignal(false); + const [workspaceVariantMap, setWorkspaceVariantMap] = createSignal>({}); + + const [defaultModel, setDefaultModel] = createSignal(initialDefaultModel); + const [legacyDefaultModel, setLegacyDefaultModel] = createSignal(initialDefaultModel); + const [defaultModelExplicit, setDefaultModelExplicit] = createSignal(false); + const [workspaceDefaultModelReady, setWorkspaceDefaultModelReady] = createSignal(false); + const [pendingDefaultModelByWorkspace, setPendingDefaultModelByWorkspace] = createSignal< + Record + >({}); + + const [autoCompactContextReady, setAutoCompactContextReady] = createSignal(false); + const [autoCompactContextDirty, setAutoCompactContextDirty] = createSignal(false); + const [autoCompactContextApplied, setAutoCompactContextApplied] = createSignal(true); + const [autoCompactContextSaving, setAutoCompactContextSaving] = createSignal(false); + const [autoCompactContext, setAutoCompactContext] = createSignal(true); + + const [modelPickerOpen, setModelPickerOpen] = createSignal(false); + const [modelPickerTarget, setModelPickerTarget] = createSignal("session"); + const [modelPickerQuery, setModelPickerQuery] = createSignal(""); + const [modelPickerReturnFocusTarget, setModelPickerReturnFocusTarget] = + createSignal("none"); + + const sessionModelState = createMemo(() => ({ + overrides: deriveSessionModelOverrides(sessionChoiceOverrideById()), + resolved: sessionModelById(), + })); + + const setSessionModelState = ( + updater: (current: SessionModelState) => SessionModelState, + ) => { + const next = updater(sessionModelState()); + setSessionChoiceOverrideById((current) => applySessionModelState(current, next)); + setSessionModelById(next.resolved); + return next; + }; + + const setWorkspaceVariant = (ref: ModelRef, value: string | null) => { + const key = formatModelRef(ref); + const normalized = normalizeModelBehaviorValue(value); + + setWorkspaceVariantMap((current) => { + const next = { ...current }; + if (normalized) next[key] = normalized; + else delete next[key]; + return next; + }); + }; + + const setPendingSessionModel = (model: ModelRef) => { + setPendingSessionChoice((current) => + normalizeSessionChoice({ + model, + ...(current && hasOwn(current, "variant") ? { variant: current.variant ?? null } : {}), + }), + ); + }; + + const setPendingSessionVariant = (value: string | null) => { + setPendingSessionChoice((current) => + normalizeSessionChoice({ + ...(current?.model ? { model: current.model } : {}), + variant: normalizeModelBehaviorValue(value), + }), + ); + }; + + const clearPendingSessionChoice = () => setPendingSessionChoice(null); + + const applyPendingSessionChoice = (sessionId: string) => { + const pending = normalizeSessionChoice(pendingSessionChoice()); + if (!pending) return; + + setSessionChoiceOverrideById((current) => { + const existing = current[sessionId]; + const next = normalizeSessionChoice({ + ...(existing?.model ? { model: existing.model } : {}), + ...(pending.model ? { model: pending.model } : {}), + ...(hasOwn(existing ?? {}, "variant") ? { variant: existing?.variant ?? null } : {}), + ...(hasOwn(pending, "variant") ? { variant: pending.variant ?? null } : {}), + }); + if (!next) return current; + return { ...current, [sessionId]: next }; + }); + + setPendingSessionChoice(null); + }; + + const setSessionModelOverride = (sessionId: string, model: ModelRef) => { + setSessionChoiceOverrideById((current) => { + const existing = current[sessionId]; + const preserveVariant = + existing?.model && + modelEquals(existing.model, model) && + hasOwn(existing, "variant") + ? { variant: existing.variant ?? null } + : hasOwn(existing ?? {}, "variant") && existing?.variant == null + ? { variant: null } + : {}; + + const next = normalizeSessionChoice({ model, ...preserveVariant }); + if (!next) return current; + return { ...current, [sessionId]: next }; + }); + }; + + const clearSessionModelOverride = (sessionId: string) => { + setSessionChoiceOverrideById((current) => { + const existing = current[sessionId]; + if (!existing) return current; + + const next = normalizeSessionChoice( + hasOwn(existing, "variant") ? { variant: existing.variant ?? null } : null, + ); + + const copy = { ...current }; + if (next) copy[sessionId] = next; + else delete copy[sessionId]; + return copy; + }); + }; + + const setSessionVariantOverride = (sessionId: string, value: string | null) => { + setSessionChoiceOverrideById((current) => { + const existing = current[sessionId]; + const next = normalizeSessionChoice({ + ...(existing?.model ? { model: existing.model } : {}), + variant: normalizeModelBehaviorValue(value), + }); + + if (!next) { + const copy = { ...current }; + delete copy[sessionId]; + return copy; + } + + return { ...current, [sessionId]: next }; + }); + }; + + const getWorkspaceVariantFor = (ref: ModelRef) => + workspaceVariantMap()[formatModelRef(ref)] ?? null; + + const getVariantFor = (ref: ModelRef, sessionId?: string | null) => { + if (sessionId) { + const choice = sessionChoiceOverrideById()[sessionId]; + if (choice && hasOwn(choice, "variant")) { + return choice.variant ?? null; + } + } else { + const pending = pendingSessionChoice(); + if (pending && hasOwn(pending, "variant")) { + return pending.variant ?? null; + } + } + + return getWorkspaceVariantFor(ref); + }; + + const selectedSessionModel = createMemo(() => { + const id = options.selectedSessionId(); + const pendingChoice = pendingSessionChoice(); + if (!id) return pendingChoice?.model ?? defaultModel(); + + const override = sessionChoiceOverrideById()[id]?.model; + if (override) return override; + + const known = sessionModelById()[id]; + if (known) return known; + + const fromMessages = lastUserModelFromMessages(options.messages()); + if (fromMessages) return fromMessages; + + return defaultModel(); + }); + + const modelVariant = createMemo(() => + getVariantFor(selectedSessionModel(), options.selectedSessionId()), + ); + + const resolveCodexReasoningEffort = (modelID: string, variant: string | null) => { + if (!modelID.trim().toLowerCase().includes("codex")) return undefined; + const normalized = normalizeModelBehaviorValue(variant); + if (!normalized || normalized === "none") return undefined; + if (normalized === "minimal") return "low"; + if (normalized === "xhigh" || normalized === "max") return "high"; + if (!["low", "medium", "high"].includes(normalized)) return undefined; + return normalized; + }; + + const findProviderModel = (ref: ModelRef) => { + const provider = options.providers().find((entry) => entry.id === ref.providerID); + return provider?.models?.[ref.modelID] ?? null; + }; + + const sanitizeModelVariantForRef = (ref: ModelRef, value: string | null) => { + const modelInfo = findProviderModel(ref); + if (!modelInfo) return normalizeModelBehaviorValue(value); + return sanitizeModelBehaviorValue(ref.providerID, modelInfo, value); + }; + + const getModelBehaviorCopy = (ref: ModelRef, value: string | null) => { + const modelInfo = findProviderModel(ref); + if (!modelInfo) { + return { + title: "Model behavior", + label: formatGenericBehaviorLabel(value), + description: "Choose the model first to see provider-specific behavior controls.", + options: [], + }; + } + return getModelBehaviorSummary(ref.providerID, modelInfo, value); + }; + + const selectedSessionModelLabel = createMemo(() => + formatModelLabel(selectedSessionModel(), options.providers()), + ); + + const sessionModelVariantLabel = createMemo( + () => getModelBehaviorCopy(selectedSessionModel(), modelVariant()).label, + ); + + const sessionModelBehaviorOptions = createMemo( + () => getModelBehaviorCopy(selectedSessionModel(), modelVariant()).options, + ); + + const defaultModelLabel = createMemo(() => formatModelLabel(defaultModel(), options.providers())); + const defaultModelRef = createMemo(() => formatModelRef(defaultModel())); + const defaultModelVariantLabel = createMemo( + () => getModelBehaviorCopy(defaultModel(), getWorkspaceVariantFor(defaultModel())).label, + ); + + const modelPickerCurrent = createMemo(() => + modelPickerTarget() === "default" ? defaultModel() : selectedSessionModel(), + ); + + const isHeroModel = (id: string) => id.toLowerCase().includes("gpt-5"); + + const modelOptions = createMemo(() => { + const allProviders = options.providers(); + const defaults = options.providerDefaults(); + const currentDefault = defaultModel(); + + if (!allProviders.length) { + const behavior = getModelBehaviorCopy(DEFAULT_MODEL, getWorkspaceVariantFor(DEFAULT_MODEL)); + return [ + { + providerID: DEFAULT_MODEL.providerID, + modelID: DEFAULT_MODEL.modelID, + title: DEFAULT_MODEL.modelID, + description: DEFAULT_MODEL.providerID, + footer: t("settings.model_fallback", currentLocale()), + behaviorTitle: behavior.title, + behaviorLabel: behavior.label, + behaviorDescription: behavior.description, + behaviorValue: normalizeModelBehaviorValue(getWorkspaceVariantFor(DEFAULT_MODEL)), + behaviorOptions: behavior.options, + isFree: true, + isConnected: false, + }, + ]; + } + + const sortedProviders = allProviders.slice().sort(compareProviders); + const next: ModelOption[] = []; + + for (const provider of sortedProviders) { + const defaultModelID = defaults[provider.id]; + const isConnected = options.providerConnectedIds().includes(provider.id); + const models = Object.values(provider.models ?? {}).filter((m) => m.status !== "deprecated"); + + models.sort((a, b) => { + const aFree = a.cost?.input === 0 && a.cost?.output === 0; + const bFree = b.cost?.input === 0 && b.cost?.output === 0; + if (aFree !== bFree) return aFree ? -1 : 1; + return (a.name ?? a.id).localeCompare(b.name ?? b.id); + }); + + for (const model of models) { + const isFree = model.cost?.input === 0 && model.cost?.output === 0; + const isDefault = + provider.id === currentDefault.providerID && model.id === currentDefault.modelID; + const ref = { providerID: provider.id, modelID: model.id }; + const activeVariant = + modelPickerTarget() === "session" && modelEquals(ref, selectedSessionModel()) + ? modelVariant() + : getWorkspaceVariantFor(ref); + const behavior = getModelBehaviorSummary(provider.id, model, activeVariant); + const behaviorValue = sanitizeModelBehaviorValue(provider.id, model, activeVariant); + const footerBits: string[] = []; + if (defaultModelID === model.id || isDefault) { + footerBits.push(t("settings.model_default", currentLocale())); + } + if (model.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale())); + + next.push({ + providerID: provider.id, + modelID: model.id, + title: model.name ?? model.id, + description: provider.name, + footer: footerBits.length ? footerBits.slice(0, 2).join(" · ") : undefined, + behaviorTitle: behavior.title, + behaviorLabel: behavior.label, + behaviorDescription: behavior.description, + behaviorValue, + behaviorOptions: behavior.options, + disabled: !isConnected, + isFree, + isConnected, + isRecommended: isHeroModel(model.id), + }); + } + } + + next.sort((a, b) => { + if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; + if (a.isFree !== b.isFree) return a.isFree ? -1 : 1; + const providerRankDiff = providerPriorityRank(a.providerID) - providerPriorityRank(b.providerID); + if (providerRankDiff !== 0) return providerRankDiff; + return a.title.localeCompare(b.title); + }); + + return next; + }); + + const filteredModelOptions = createMemo(() => { + const q = modelPickerQuery().trim().toLowerCase(); + const optionsList = modelOptions(); + if (!q) return optionsList; + + return optionsList.filter((opt) => { + const haystack = [ + opt.title, + opt.description ?? "", + opt.footer ?? "", + opt.behaviorTitle, + opt.behaviorLabel, + opt.behaviorDescription, + `${opt.providerID}/${opt.modelID}`, + opt.isConnected ? "connected" : "disconnected", + opt.isFree ? "free" : "paid", + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); + }); + }); + + const setPendingDefaultModelForWorkspace = (workspaceId: string, model: ModelRef | null) => { + const id = workspaceId.trim(); + if (!id) return; + setPendingDefaultModelByWorkspace((current) => { + const next = { ...current }; + if (model) { + next[id] = formatModelRef(model); + } else { + delete next[id]; + } + return next; + }); + }; + + const pendingDefaultModelForWorkspace = (workspaceId: string) => { + const id = workspaceId.trim(); + if (!id) return null; + return pendingDefaultModelByWorkspace()[id] ?? null; + }; + + const applyDefaultModelChoice = (next: ModelRef) => { + const workspaceId = options.selectedWorkspaceId().trim(); + if (workspaceId) { + setPendingDefaultModelForWorkspace(workspaceId, next); + } + setDefaultModelExplicit(true); + setDefaultModel(next); + setLegacyDefaultModel(next); + }; + + const closeModelPicker = (opts?: { restorePromptFocus?: boolean }) => { + const shouldFocusPrompt = + opts?.restorePromptFocus ?? modelPickerReturnFocusTarget() === "composer"; + setModelPickerOpen(false); + setModelPickerReturnFocusTarget("none"); + if (shouldFocusPrompt) { + options.focusSessionPromptSoon(); + } + }; + + const openSessionModelPicker = (opts?: { + returnFocusTarget?: PromptFocusReturnTarget; + }) => { + setModelPickerTarget("session"); + setModelPickerQuery(""); + setModelPickerReturnFocusTarget(opts?.returnFocusTarget ?? "composer"); + setModelPickerOpen(true); + }; + + const openDefaultModelPicker = () => { + setModelPickerTarget("default"); + setModelPickerQuery(""); + setModelPickerReturnFocusTarget("none"); + setModelPickerOpen(true); + }; + + const applyModelSelection = (next: ModelRef) => { + const target = modelPickerTarget(); + const restorePromptFocus = target === "session"; + + if (target === "default") { + applyDefaultModelChoice(next); + closeModelPicker({ restorePromptFocus: false }); + return; + } + + const id = options.selectedSessionId(); + if (!id) { + setPendingSessionModel(next); + closeModelPicker({ restorePromptFocus }); + return; + } + + setSessionModelOverride(id, next); + closeModelPicker({ restorePromptFocus }); + }; + + const setModelPickerBehavior = (model: ModelRef, value: string | null) => { + const nextValue = sanitizeModelVariantForRef(model, value); + if (modelPickerTarget() === "default") { + setWorkspaceVariant(model, nextValue); + return; + } + + const sessionId = options.selectedSessionId(); + if (sessionId) { + setSessionVariantOverride(sessionId, nextValue); + return; + } + + setPendingSessionVariant(nextValue); + }; + + const setSessionModelVariant = (value: string | null) => { + const sessionId = options.selectedSessionId(); + const nextValue = sanitizeModelVariantForRef(selectedSessionModel(), value); + if (sessionId) { + setSessionVariantOverride(sessionId, nextValue); + return; + } + setPendingSessionVariant(nextValue); + }; + + const toggleAutoCompactContext = () => { + if (autoCompactContextSaving()) return; + setAutoCompactContext((value) => !value); + setAutoCompactContextDirty(true); + }; + + const resetAppDefaults = () => { + if (typeof window !== "undefined") { + try { + const sessionOverridePrefix = `${SESSION_MODEL_PREF_KEY}.`; + const workspaceVariantPrefix = `${VARIANT_PREF_KEY}.`; + const keysToRemove: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (!key) continue; + if ( + key.startsWith(sessionOverridePrefix) || + key.startsWith(workspaceVariantPrefix) || + key === VARIANT_PREF_KEY + ) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + window.localStorage.removeItem(key); + } + } catch { + // ignore + } + } + + setDefaultModel(DEFAULT_MODEL); + setLegacyDefaultModel(DEFAULT_MODEL); + setDefaultModelExplicit(false); + setWorkspaceDefaultModelReady(false); + setPendingDefaultModelByWorkspace({}); + setAutoCompactContext(false); + setAutoCompactContextApplied(false); + setAutoCompactContextDirty(false); + setAutoCompactContextReady(false); + setAutoCompactContextSaving(false); + clearPendingSessionChoice(); + setSessionChoiceOverrideById({}); + setSessionModelById({}); + setWorkspaceVariantMap({}); + closeModelPicker({ restorePromptFocus: false }); + }; + + createEffect(() => { + if (typeof window === "undefined") return; + const workspaceId = options.selectedWorkspaceId(); + if (!workspaceId) return; + + setSessionModelOverridesReady(false); + const raw = window.localStorage.getItem(sessionModelOverridesKey(workspaceId)); + setSessionChoiceOverrideById(parseSessionChoiceOverrides(raw)); + setSessionModelOverridesReady(true); + }); + + createEffect(() => { + if (typeof window === "undefined") return; + if (!sessionModelOverridesReady()) return; + const workspaceId = options.selectedWorkspaceId(); + if (!workspaceId) return; + + const payload = serializeSessionChoiceOverrides(sessionChoiceOverrideById()); + try { + if (payload) { + window.localStorage.setItem(sessionModelOverridesKey(workspaceId), payload); + } else { + window.localStorage.removeItem(sessionModelOverridesKey(workspaceId)); + } + } catch { + // ignore + } + }); + + createEffect(() => { + if (typeof window === "undefined") return; + const workspaceId = options.selectedWorkspaceId().trim(); + if (!workspaceId) { + setWorkspaceVariantMap({}); + return; + } + + const scopedRaw = window.localStorage.getItem(workspaceModelVariantsKey(workspaceId)); + const legacyRaw = scopedRaw == null ? window.localStorage.getItem(VARIANT_PREF_KEY) : null; + setWorkspaceVariantMap(parseWorkspaceModelVariants(scopedRaw ?? legacyRaw, defaultModel())); + }); + + createEffect(() => { + if (typeof window === "undefined") return; + const workspaceId = options.selectedWorkspaceId().trim(); + if (!workspaceId) return; + + try { + const map = workspaceVariantMap(); + const key = workspaceModelVariantsKey(workspaceId); + if (Object.keys(map).length > 0) { + window.localStorage.setItem(key, JSON.stringify(map)); + } else { + window.localStorage.removeItem(key); + } + } catch { + // ignore + } + }); + + createEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MODEL_PREF_KEY, formatModelRef(defaultModel())); + } catch { + // ignore + } + }); + + createEffect(() => { + if (typeof window === "undefined") return; + const workspaceId = options.selectedWorkspaceId(); + if (!workspaceId) return; + + setWorkspaceDefaultModelReady(false); + const workspace = options.selectedWorkspaceDisplay(); + const workspaceRoot = options.selectedWorkspacePath().trim(); + const activeClient = options.client(); + const openworkClient = options.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServerCapabilities(); + const canUseOpenworkServer = + options.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.read; + + let cancelled = false; + + const applyDefault = async () => { + let configDefault: ModelRef | null = null; + let configFileContent: string | null = null; + + if (workspace.workspaceType === "local" && workspaceRoot) { + if (canUseOpenworkServer) { + try { + const config = await openworkClient.getConfig(openworkWorkspaceId); + const model = typeof config.opencode?.model === "string" ? config.opencode.model : null; + configDefault = parseModelRef(model); + } catch { + // ignore + } + } else if (isTauriRuntime()) { + try { + const configFile = await readOpencodeConfig("project", workspaceRoot); + configFileContent = configFile.content; + configDefault = parseDefaultModelFromConfig(configFile.content); + } catch { + // ignore + } + } + } else if (activeClient) { + try { + const config = await activeClient.config.get({ directory: workspaceRoot || undefined }); + const payload = "data" in config ? config.data : config; + if (typeof payload?.model === "string") { + configDefault = parseModelRef(payload.model); + } + } catch { + // ignore + } + } + + const pendingModelRef = pendingDefaultModelForWorkspace(workspaceId); + const loadedModelRef = configDefault ? formatModelRef(configDefault) : null; + + if (pendingModelRef && pendingModelRef !== loadedModelRef) { + if (workspace.workspaceType === "local" && workspaceRoot) { + options.setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); + } + + if (!cancelled) { + setWorkspaceDefaultModelReady(true); + } + return; + } + + if (pendingModelRef && loadedModelRef === pendingModelRef) { + setPendingDefaultModelForWorkspace(workspaceId, null); + } + + setDefaultModelExplicit(Boolean(configDefault)); + const nextDefault = configDefault ?? legacyDefaultModel(); + const currentDefault = defaultModel(); + if (nextDefault && !modelEquals(currentDefault, nextDefault)) { + setDefaultModel(nextDefault); + } + const currentLegacyDefault = legacyDefaultModel(); + if (nextDefault && !modelEquals(currentLegacyDefault, nextDefault)) { + setLegacyDefaultModel(nextDefault); + } + + if (workspace.workspaceType === "local" && workspaceRoot) { + options.setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); + } + + if (!cancelled) { + setWorkspaceDefaultModelReady(true); + } + }; + + void applyDefault(); + + onCleanup(() => { + cancelled = true; + }); + }); + + createEffect(() => { + if (!workspaceDefaultModelReady()) return; + if (!isTauriRuntime()) return; + if (!defaultModelExplicit()) return; + + const workspace = options.selectedWorkspaceDisplay(); + const workspaceId = options.selectedWorkspaceId().trim(); + if (workspace.workspaceType !== "local") return; + + const root = options.selectedWorkspacePath().trim(); + if (!root) return; + const nextModel = defaultModel(); + const openworkClient = options.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServerCapabilities(); + const canUseOpenworkServer = + options.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.write; + let cancelled = false; + + const writeConfig = async () => { + try { + if (canUseOpenworkServer) { + const config = await openworkClient.getConfig(openworkWorkspaceId); + const currentModel = + typeof config.opencode?.model === "string" ? parseModelRef(config.opencode.model) : null; + if (currentModel && modelEquals(currentModel, nextModel)) { + if (workspaceId) { + setPendingDefaultModelForWorkspace(workspaceId, null); + } + return; + } + + await openworkClient.patchConfig(openworkWorkspaceId, { + opencode: { model: formatModelRef(nextModel) }, + }); + if (workspaceId) { + setPendingDefaultModelForWorkspace(workspaceId, null); + } + options.markOpencodeConfigReloadRequired(); + return; + } + + const configFile = await readOpencodeConfig("project", root); + const existingModel = parseDefaultModelFromConfig(configFile.content); + if (existingModel && modelEquals(existingModel, nextModel)) { + if (workspaceId) { + setPendingDefaultModelForWorkspace(workspaceId, null); + } + return; + } + + const content = formatConfigWithDefaultModel(configFile.content, nextModel); + const result = await writeOpencodeConfig("project", root, content); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); + } + options.setLastKnownConfigSnapshot(getConfigSnapshot(content)); + if (workspaceId) { + setPendingDefaultModelForWorkspace(workspaceId, null); + } + options.markOpencodeConfigReloadRequired(); + } catch (error) { + if (cancelled) return; + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + } + }; + + void writeConfig(); + + onCleanup(() => { + cancelled = true; + }); + }); + + createEffect(() => { + const workspaceId = options.selectedWorkspaceId(); + if (!workspaceId) { + setAutoCompactContext(true); + setAutoCompactContextApplied(true); + setAutoCompactContextDirty(false); + setAutoCompactContextReady(false); + setAutoCompactContextSaving(false); + return; + } + + const workspace = options.selectedWorkspaceDisplay(); + const root = options.selectedWorkspacePath().trim(); + const activeClient = options.client(); + const openworkClient = options.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServerCapabilities(); + const canUseOpenworkServer = + options.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.read; + + let cancelled = false; + setAutoCompactContextReady(false); + setAutoCompactContextDirty(false); + + const loadAutoCompactContext = async () => { + let nextValue = true; + + if (canUseOpenworkServer) { + try { + const config = await openworkClient.getConfig(openworkWorkspaceId); + nextValue = readAutoCompactContextFromRecord(config.opencode) ?? true; + } catch { + // ignore + } + } else if (workspace.workspaceType === "local" && root && isTauriRuntime()) { + try { + const configFile = await readOpencodeConfig("project", root); + nextValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; + } catch { + // ignore + } + } else if (activeClient) { + try { + const config = await activeClient.config.get({ directory: root || undefined }); + const payload = "data" in config ? config.data : config; + nextValue = readAutoCompactContextFromRecord(payload) ?? true; + } catch { + // ignore + } + } + + if (cancelled) return; + setAutoCompactContext(nextValue); + setAutoCompactContextApplied(nextValue); + setAutoCompactContextReady(true); + }; + + void loadAutoCompactContext(); + + onCleanup(() => { + cancelled = true; + }); + }); + + createEffect(() => { + if (!autoCompactContextReady()) return; + if (!autoCompactContextDirty()) return; + + const nextValue = autoCompactContext(); + const appliedValue = autoCompactContextApplied(); + const workspace = options.selectedWorkspaceDisplay(); + const root = options.selectedWorkspacePath().trim(); + const openworkClient = options.openworkServerClient(); + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = options.openworkServerCapabilities(); + const canUseOpenworkServer = + options.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.write; + + let cancelled = false; + setAutoCompactContextSaving(true); + + const persistAutoCompactContext = async () => { + try { + if (canUseOpenworkServer) { + const config = await openworkClient.getConfig(openworkWorkspaceId); + const currentValue = readAutoCompactContextFromRecord(config.opencode) ?? true; + if (currentValue !== nextValue) { + await openworkClient.patchConfig(openworkWorkspaceId, { + opencode: { + compaction: { + auto: nextValue, + }, + }, + }); + options.markOpencodeConfigReloadRequired(); + } + if (cancelled) return; + setAutoCompactContextApplied(nextValue); + setAutoCompactContextDirty(false); + return; + } + + if (workspace.workspaceType !== "local" || !root || !isTauriRuntime()) { + throw new Error( + "Auto context compaction can only be changed for a local workspace or a writable OpenWork server workspace.", + ); + } + + const configFile = await readOpencodeConfig("project", root); + const currentValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; + if (currentValue !== nextValue) { + const content = formatConfigWithAutoCompactContext(configFile.content, nextValue); + const result = await writeOpencodeConfig("project", root, content); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); + } + options.setLastKnownConfigSnapshot(getConfigSnapshot(content)); + options.markOpencodeConfigReloadRequired(); + } + + if (cancelled) return; + setAutoCompactContextApplied(nextValue); + setAutoCompactContextDirty(false); + } catch (error) { + if (cancelled) return; + setAutoCompactContext(appliedValue); + setAutoCompactContextDirty(false); + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + } finally { + setAutoCompactContextSaving(false); + } + }; + + void persistAutoCompactContext(); + + onCleanup(() => { + cancelled = true; + }); + }); + + return { + sessionChoiceOverrideById, + setSessionChoiceOverrideById, + sessionModelById, + setSessionModelById, + sessionModelState, + setSessionModelState, + pendingSessionChoice, + setPendingSessionModel, + setPendingSessionVariant, + clearPendingSessionChoice, + applyPendingSessionChoice, + sessionModelOverridesReady, + setSessionModelOverridesReady, + workspaceVariantMap, + setWorkspaceVariantMap, + setWorkspaceVariant, + setSessionModelOverride, + clearSessionModelOverride, + setSessionVariantOverride, + getWorkspaceVariantFor, + getVariantFor, + defaultModel, + selectedSessionModel, + selectedSessionModelLabel, + defaultModelLabel, + defaultModelRef, + defaultModelVariantLabel, + modelVariant, + sessionModelVariantLabel, + sessionModelBehaviorOptions, + setSessionModelVariant, + sanitizeModelVariantForRef, + resolveCodexReasoningEffort, + modelPickerOpen, + modelPickerQuery, + setModelPickerQuery, + modelPickerTarget, + modelPickerCurrent, + modelOptions, + filteredModelOptions, + openSessionModelPicker, + openDefaultModelPicker, + closeModelPicker, + applyModelSelection, + setModelPickerBehavior, + autoCompactContext, + toggleAutoCompactContext, + autoCompactContextSaving, + resetAppDefaults, + }; +} diff --git a/apps/app/src/app/context/providers/index.ts b/apps/app/src/app/context/providers/index.ts new file mode 100644 index 00000000..b6dd2c06 --- /dev/null +++ b/apps/app/src/app/context/providers/index.ts @@ -0,0 +1,3 @@ +export { createProvidersStore } from "./store"; +export type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store"; +export { default as ProviderAuthModal } from "./provider-auth-modal"; diff --git a/apps/app/src/app/components/provider-auth-modal.tsx b/apps/app/src/app/context/providers/provider-auth-modal.tsx similarity index 90% rename from apps/app/src/app/components/provider-auth-modal.tsx rename to apps/app/src/app/context/providers/provider-auth-modal.tsx index 411e011c..3b6248cf 100644 --- a/apps/app/src/app/components/provider-auth-modal.tsx +++ b/apps/app/src/app/context/providers/provider-auth-modal.tsx @@ -1,18 +1,14 @@ -import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"; import { CheckCircle2, Loader2, X, Search, ChevronRight } from "lucide-solid"; -import type { ProviderListItem } from "../types"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; -import { isTauriRuntime } from "../utils"; -import { compareProviders } from "../utils/providers"; -import Button from "./button"; -import TextInput from "./text-input"; +import type { ProviderListItem } from "../../types"; +import { isTauriRuntime } from "../../utils"; +import { compareProviders } from "../../utils/providers"; +import Button from "../../components/button"; +import ProviderIcon from "../../components/provider-icon"; +import TextInput from "../../components/text-input"; +import type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store"; -export type ProviderAuthMethod = { - type: "oauth" | "api"; - label: string; - methodIndex?: number; -}; type ProviderAuthEntry = { id: string; name: string; @@ -21,11 +17,6 @@ type ProviderAuthEntry = { env: string[]; }; -export type ProviderOAuthStartResult = { - methodIndex: number; - authorization: ProviderAuthAuthorization; -}; - type ProviderOAuthSession = ProviderOAuthStartResult & { providerId: string; methodLabel: string; @@ -170,6 +161,13 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { if (!session) return false; return session.providerId === "openai" && session.methodLabel.toLowerCase().includes("headless"); }); + const shouldStartOauthAutoPolling = createMemo(() => { + if (!props.open || resolvedView() !== "oauth-auto" || !oauthSession()) { + return false; + } + if (!isOpenAiHeadlessSession()) return true; + return oauthBrowserOpened(); + }); const oauthDisplayCode = createMemo(() => { const instructions = oauthInstructions(); @@ -320,7 +318,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { }); createEffect(() => { - if (!props.open || resolvedView() !== "oauth-auto" || !oauthSession()) { + if (!shouldStartOauthAutoPolling()) { stopOauthAutoPolling(); return; } @@ -672,22 +670,8 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { onMouseEnter={() => setActiveEntryIndex(idx())} onClick={() => handleEntrySelect(entry)} > -
- -
- -
-
- -
- -
-
- -
- {entry.name.charAt(0).toUpperCase()} -
-
+
+
@@ -712,7 +696,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
{entry.id}
- +
{(method) => ( @@ -862,7 +846,6 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { const url = oauthSession()?.authorization.url ?? ""; void openOauthUrl(url); }} - disabled={actionDisabled()} > Open browser again @@ -914,10 +897,19 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
-
- - Checking connection status automatically... -
+ + + Checking connection status automatically... +
+ } + > +
+ Authorization checks will start after you click Open Browser. +
+
+
+
+ ); +}; + +const JobCard = (props: { + job: ScheduledJob; + busy: boolean; + sourceLabel: string; + onRun: () => void; + onDelete: () => void; +}) => { + const summary = createMemo(() => taskSummary(props.job)); + const scheduleLabel = createMemo(() => humanizeCron(props.job.schedule)); + const status = createMemo(() => props.job.lastRunStatus ?? null); + + return ( +
+
+
+ +
+
+
+

{props.job.name}

+ {statusLabel(status())} +
+

+ {summary()} +

+
+ {scheduleLabel()} + {props.sourceLabel} + + {props.job.source} + +
+
+
Last run {toRelative(props.job.lastRunAt)}
+
Created {toRelative(props.job.createdAt)}
+
+
+
+ +
+ Scheduled +
+ + +
+
+
+ ); +}; + +export default function AutomationsView(props: AutomationsViewProps) { + const automations = useAutomations(); + const platform = usePlatform(); + const statusToasts = useStatusToasts(); + + const [searchQuery, setSearchQuery] = createSignal(""); + const [activeFilter, setActiveFilter] = createSignal("all"); + const [installingScheduler, setInstallingScheduler] = createSignal(false); + const [schedulerInstallRequested, setSchedulerInstallRequested] = createSignal(false); + const [deleteTarget, setDeleteTarget] = createSignal(null); + const [deleteBusy, setDeleteBusy] = createSignal(false); + const [deleteError, setDeleteError] = createSignal(null); + const [createModalOpen, setCreateModalOpen] = createSignal(false); + const [createBusy, setCreateBusy] = createSignal(false); + const [createError, setCreateError] = createSignal(null); + const [automationName, setAutomationName] = createSignal(DEFAULT_AUTOMATION_NAME); + const [automationPrompt, setAutomationPrompt] = createSignal(DEFAULT_AUTOMATION_PROMPT); + const [scheduleMode, setScheduleMode] = createSignal("daily"); + const [scheduleTime, setScheduleTime] = createSignal(DEFAULT_SCHEDULE_TIME); + const [scheduleDays, setScheduleDays] = createSignal([...DEFAULT_SCHEDULE_DAYS]); + const [intervalHours, setIntervalHours] = createSignal(DEFAULT_INTERVAL_HOURS); + const [lastUpdatedNow, setLastUpdatedNow] = createSignal(Date.now()); + + createEffect(() => { + if (typeof window === "undefined") return; + const interval = window.setInterval(() => setLastUpdatedNow(Date.now()), 1_000); + onCleanup(() => window.clearInterval(interval)); + }); + + const showToast = (title: string, tone: AppStatusToastTone = "info") => { + statusToasts.showToast({ title, tone }); + }; + + const resetDraft = (template?: AutomationTemplate) => { + setAutomationName(template?.name ?? DEFAULT_AUTOMATION_NAME); + setAutomationPrompt(template?.prompt ?? DEFAULT_AUTOMATION_PROMPT); + setScheduleMode(template?.scheduleMode ?? "daily"); + setScheduleTime(template?.scheduleTime ?? DEFAULT_SCHEDULE_TIME); + setScheduleDays([...(template?.scheduleDays ?? DEFAULT_SCHEDULE_DAYS)]); + setIntervalHours(template?.intervalHours ?? DEFAULT_INTERVAL_HOURS); + setCreateError(null); + }; + + const supported = createMemo(() => { + if (automations.jobsSource() === "remote") return true; + return isTauriRuntime() && props.schedulerInstalled && !schedulerInstallRequested(); + }); + + const schedulerGateActive = createMemo(() => { + if (automations.jobsSource() !== "local") return false; + if (!isTauriRuntime()) return false; + return !props.schedulerInstalled || schedulerInstallRequested(); + }); + + const automationDisabled = createMemo( + () => props.newTaskDisabled || schedulerGateActive() || createBusy(), + ); + + const sourceLabel = createMemo(() => + automations.jobsSource() === "remote" ? "OpenWork server" : "Local scheduler", + ); + + const sourceDescription = createMemo(() => + automations.jobsSource() === "remote" + ? "Scheduled tasks that are currently synced from the connected OpenWork server." + : "Scheduled tasks that are currently registered on this device through the local scheduler.", + ); + + const supportNote = createMemo(() => { + if (automations.jobsSource() === "remote") return null; + if (!isTauriRuntime()) return "Automations require the desktop app or a connected OpenWork server."; + if (!props.schedulerInstalled || schedulerInstallRequested()) return null; + return null; + }); + + const lastUpdatedLabel = createMemo(() => { + lastUpdatedNow(); + if (!automations.jobsUpdatedAt()) return "Not synced yet"; + return formatRelativeTime(automations.jobsUpdatedAt() as number); + }); + + const filteredJobs = createMemo(() => { + const query = searchQuery().trim().toLowerCase(); + const items = automations.jobs(); + if (!query) return items; + return items.filter((job) => { + const summary = taskSummary(job).toLowerCase(); + const schedule = humanizeCron(job.schedule).toLowerCase(); + return ( + job.name.toLowerCase().includes(query) || + summary.includes(query) || + schedule.includes(query) + ); + }); + }); + + const filteredTemplates = createMemo(() => { + const query = searchQuery().trim().toLowerCase(); + if (!query) return automationTemplates; + return automationTemplates.filter((template) => { + return ( + template.name.toLowerCase().includes(query) || + template.description.toLowerCase().includes(query) || + template.badge.toLowerCase().includes(query) + ); + }); + }); + + const showJobsSection = createMemo(() => activeFilter() !== "templates"); + const showTemplatesSection = createMemo(() => activeFilter() !== "scheduled"); + + const cronExpression = createMemo(() => { + if (scheduleMode() === "interval") { + return buildCronFromInterval(intervalHours()); + } + return buildCronFromDaily(scheduleTime(), scheduleDays()); + }); + + const cronPreviewLabel = createMemo(() => { + const cron = cronExpression(); + return cron ? humanizeCron(cron) : null; + }); + + const openSchedulerDocs = () => { + platform.openLink("https://github.com/different-ai/opencode-scheduler"); + }; + + const refreshJobs = () => { + if (props.busy) return; + void automations.refresh({ force: true }); + }; + + const handleInstallScheduler = async () => { + if (installingScheduler() || !props.canEditPlugins) return; + setInstallingScheduler(true); + setSchedulerInstallRequested(true); + try { + await Promise.resolve(props.addPlugin("opencode-scheduler")); + showToast("Scheduler install requested.", "success"); + } finally { + setInstallingScheduler(false); + } + }; + + const openCreateModal = () => { + if (automationDisabled()) return; + resetDraft(); + setCreateModalOpen(true); + }; + + const openCreateModalFromTemplate = (template: AutomationTemplate) => { + if (automationDisabled()) return; + resetDraft(template); + setCreateModalOpen(true); + }; + + const closeCreateModal = () => { + setCreateModalOpen(false); + setCreateError(null); + setCreateBusy(false); + }; + + const handleCreateAutomation = async () => { + if (automationDisabled()) return; + const plan = automations.prepareCreateAutomation({ + name: automationName(), + prompt: automationPrompt(), + schedule: cronExpression(), + workdir: props.selectedWorkspaceRoot, + }); + if (!plan.ok) { + setCreateError(plan.error); + return; + } + + setCreateBusy(true); + setCreateError(null); + try { + props.setPrompt(plan.prompt); + await Promise.resolve(props.createSessionAndOpen()); + setCreateModalOpen(false); + showToast("Prepared automation in chat.", "success"); + } catch (error) { + setCreateError( + error instanceof Error ? error.message : "Failed to prepare automation in chat.", + ); + } finally { + setCreateBusy(false); + } + }; + + const handleRunAutomation = async (job: ScheduledJob) => { + if (!supported() || props.busy) return; + const plan = automations.prepareRunAutomation(job, props.selectedWorkspaceRoot); + if (!plan.ok) { + showToast(plan.error, "warning"); + return; + } + props.setPrompt(plan.prompt); + await Promise.resolve(props.createSessionAndOpen()); + showToast(`Prepared ${job.name} in chat.`, "success"); + }; + + const confirmDelete = async () => { + const target = deleteTarget(); + if (!target) return; + setDeleteBusy(true); + setDeleteError(null); + try { + await automations.remove(target.slug); + setDeleteTarget(null); + showToast(`Removed ${target.name}.`, "success"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setDeleteError(message || "Failed to delete automation."); + } finally { + setDeleteBusy(false); + } + }; + + const toggleDay = (id: string) => { + setScheduleDays((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return Array.from(next); + }); + }; + + const updateIntervalHours = (value: string) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return; + const bounded = Math.min(24, Math.max(1, parsed)); + setIntervalHours(bounded); + }; + + const jobsEmptyMessage = createMemo(() => { + const query = searchQuery().trim(); + if (query) return `No automations match \"${query}\".`; + if (schedulerGateActive()) return "Install the scheduler or connect to an OpenWork server to start creating automations."; + return "No automations yet. Start with a template or prepare one in chat."; + }); + + return ( +
+
+
+
+ +

Automations

+
+

+ Schedule recurring tasks for this worker, monitor what is already registered, and start from a reusable template. +

+
+ +
+ + + +
+
+ +
+
+ + setSearchQuery(event.currentTarget.value)} + placeholder="Search automations or templates" + class="w-full rounded-xl border border-dls-border bg-dls-surface py-3 pl-11 pr-4 text-[14px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" + /> +
+ +
+ + {(filter) => ( + + )} + +
+
+
+ + +
+
+
+
+ +
+
+
+ {props.schedulerInstalled + ? "Reload OpenWork to activate automations" + : "Install the scheduler to unlock automations"} +
+

+ {props.schedulerInstalled + ? "OpenCode loads plugins at startup. Reload OpenWork to activate opencode-scheduler for this workspace." + : "Automations run through the opencode-scheduler plugin today. Add it to this workspace to unlock local scheduling."} +

+
+
+
+ + +
+
+
+
+ + +
+ {supportNote()} +
+
+ + +
+ {automations.jobsStatus()} +
+
+ + +
+ {deleteError()} +
+
+ + +
+
+
+

Your automations

+

{sourceDescription()}

+
+
+ {sourceLabel()} · synced {lastUpdatedLabel()} +
+
+ + + {jobsEmptyMessage()} +
+ } + > +
+
+ + {(job) => ( + void handleRunAutomation(job)} + onDelete={() => setDeleteTarget(job)} + /> + )} + +
+
+
+
+ + + +
+
+
+

Quick start templates

+

+ Start from a proven recurring workflow, then tailor the prompt before you prepare it in chat. +

+
+
{filteredTemplates().length} templates
+
+ + + No templates match this search. +
+ } + > +
+
+ + {(template) => ( + openCreateModalFromTemplate(template)} + /> + )} + +
+
+
+
+ + + +
+
+
+
+

Remove automation?

+

+ This removes the schedule and deletes the job definition from {sourceLabel().toLowerCase()}. +

+
+ +
+ {deleteTarget()?.name} +
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+
Create automation
+

+ The form is ready for direct writes. For now, OpenWork prepares the scheduler command in chat for you. +

+
+ +
+ +
+
+ + setAutomationName(event.currentTarget.value)} + class="w-full rounded-xl border border-dls-border bg-dls-surface px-4 py-3 text-[14px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" + /> +
+ +
+ + -
- -
-
-