mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
Merge branch 'dev' of https://github.com/different-ai/openwork into dev
This commit is contained in:
BIN
.github/assets/github-banner.png
vendored
BIN
.github/assets/github-banner.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
5
.opencode/openwork.json
Normal file
5
.opencode/openwork.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"messaging": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
15
AGENTS.md
15
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
474
DESIGN-SYSTEM.md
Normal file
474
DESIGN-SYSTEM.md
Normal file
@@ -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.
|
||||
290
PRODUCT.md
290
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?
|
||||
|
||||
6
STATS.md
6
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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
45
apps/app/scripts/bundle-url-policy.ts
Normal file
45
apps/app/scripts/bundle-url-policy.ts
Normal file
@@ -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");
|
||||
492
apps/app/src/app/app-settings/authorized-folders-panel.tsx
Normal file
492
apps/app/src/app/app-settings/authorized-folders-panel.tsx
Normal file
@@ -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<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>) => {
|
||||
const permission = ensureRecord(opencodeConfig.permission);
|
||||
const externalDirectory = ensureRecord(permission.external_directory);
|
||||
const folders: string[] = [];
|
||||
const hiddenEntries: Record<string, unknown> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const next: Record<string, unknown> = { ...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<string[]>([]);
|
||||
const [authorizedFolderDraft, setAuthorizedFolderDraft] = createSignal("");
|
||||
const [authorizedFoldersLoading, setAuthorizedFoldersLoading] = createSignal(false);
|
||||
const [authorizedFoldersSaving, setAuthorizedFoldersSaving] = createSignal(false);
|
||||
const [authorizedFoldersStatus, setAuthorizedFoldersStatus] = createSignal<string | null>(null);
|
||||
const [authorizedFoldersError, setAuthorizedFoldersError] = createSignal<string | null>(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 (
|
||||
<div class={`${panelClass} space-y-4`}>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-gray-12">
|
||||
<FolderLock size={16} class="text-gray-10" />
|
||||
Authorized folders
|
||||
</div>
|
||||
<div class="text-xs text-gray-9 leading-relaxed max-w-[65ch]">
|
||||
Grant this workspace access to read and edit files in directories outside of its root.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={canReadConfig()}
|
||||
fallback={
|
||||
<div class={`${softPanelClass} px-3 py-3 text-xs text-gray-10`}>
|
||||
{authorizedFoldersHint() ??
|
||||
"Connect to a writable OpenWork server workspace to edit authorized folders."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-gray-5/60 bg-gray-1/50 shadow-sm">
|
||||
<Show when={authorizedFoldersHint()}>
|
||||
{(hint) => (
|
||||
<div class="bg-gray-2/60 px-3 py-2 text-[11px] text-gray-10 border-b border-gray-5/40">
|
||||
{hint()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={visibleAuthorizedFolders().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center p-6 text-center">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-3/30 text-blue-11 mb-3">
|
||||
<Folder size={20} />
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-11">No external folders authorized</div>
|
||||
<div class="text-[11px] text-gray-9 mt-1 max-w-[40ch]">
|
||||
Add a folder to let this workspace read and edit files outside its root directory.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col divide-y divide-gray-5/40 max-h-[300px] overflow-y-auto">
|
||||
<For each={visibleAuthorizedFolders()}>
|
||||
{(folder) => {
|
||||
const isWorkspaceRoot = folder === workspaceRootFolder();
|
||||
const folderName = folder.split(/[\/\\]/).filter(Boolean).pop() || folder;
|
||||
return (
|
||||
<div
|
||||
class={`flex items-center justify-between px-3 py-2.5 transition-colors ${
|
||||
isWorkspaceRoot ? "bg-blue-2/20" : "hover:bg-gray-2/50"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-blue-3/30 text-blue-11">
|
||||
<Folder size={15} />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium text-gray-12">{folderName}</span>
|
||||
<Show when={isWorkspaceRoot}>
|
||||
<span class="rounded-full border border-blue-7/30 bg-blue-3/25 px-2 py-0.5 text-[10px] font-medium text-blue-11">
|
||||
Workspace root
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="truncate font-mono text-[10px] text-gray-8">{folder}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={!isWorkspaceRoot}
|
||||
fallback={
|
||||
<span class="shrink-0 text-[10px] font-medium text-gray-8">
|
||||
Always available
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-6 w-6 shrink-0 !rounded-full !p-0 border-0 bg-transparent text-red-10 shadow-none hover:bg-red-3/15 hover:text-red-11 focus:ring-red-7/25"
|
||||
onClick={() => void removeAuthorizedFolder(folder)}
|
||||
disabled={
|
||||
authorizedFoldersLoading() ||
|
||||
authorizedFoldersSaving() ||
|
||||
!canWriteConfig()
|
||||
}
|
||||
aria-label={`Remove ${folderName}`}
|
||||
>
|
||||
<X size={16} class="text-current" />
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={authorizedFoldersStatus()}>
|
||||
{(status) => (
|
||||
<div class="bg-blue-2/30 px-3 py-2 text-[11px] text-blue-11 border-t border-gray-5/40">
|
||||
{status()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={authorizedFoldersError()}>
|
||||
{(error) => (
|
||||
<div class="bg-red-2/30 px-3 py-2 text-[11px] text-red-11 border-t border-gray-5/40">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<form
|
||||
class="flex items-center gap-2 bg-gray-2/60 border-t border-gray-5/60 p-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void addAuthorizedFolder();
|
||||
}}
|
||||
>
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg border border-gray-5/60 bg-gray-1 px-3 py-1.5 text-xs text-gray-12 placeholder:text-gray-8 focus:outline-none focus:ring-2 focus:ring-blue-7/30 disabled:opacity-50"
|
||||
value={authorizedFolderDraft()}
|
||||
onInput={(event) => setAuthorizedFolderDraft(event.currentTarget.value)}
|
||||
onPaste={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
placeholder="Type a folder path to authorize..."
|
||||
disabled={
|
||||
authorizedFoldersLoading() ||
|
||||
authorizedFoldersSaving() ||
|
||||
!canWriteConfig()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={canPickAuthorizedFolder()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-8 px-3 text-xs bg-gray-1 hover:bg-gray-2"
|
||||
onClick={() => void pickAuthorizedFolder()}
|
||||
disabled={
|
||||
authorizedFoldersLoading() ||
|
||||
authorizedFoldersSaving() ||
|
||||
!canWriteConfig()
|
||||
}
|
||||
>
|
||||
<FolderSearch size={13} class="mr-1.5" /> Browse
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="h-8 px-3 text-xs bg-gray-3 text-gray-12 hover:bg-gray-4 border border-gray-5/60"
|
||||
disabled={
|
||||
authorizedFoldersLoading() ||
|
||||
authorizedFoldersSaving() ||
|
||||
!canWriteConfig() ||
|
||||
!authorizedFolderDraft().trim()
|
||||
}
|
||||
>
|
||||
{authorizedFoldersSaving() ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/app/src/app/app-settings/model-controls-provider.tsx
Normal file
21
apps/app/src/app/app-settings/model-controls-provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import type { ModelControlsStore } from "./model-controls-store";
|
||||
|
||||
const ModelControlsContext = createContext<ModelControlsStore>();
|
||||
|
||||
export function ModelControlsProvider(props: ParentProps<{ store: ModelControlsStore }>) {
|
||||
return (
|
||||
<ModelControlsContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</ModelControlsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useModelControls() {
|
||||
const context = useContext(ModelControlsContext);
|
||||
if (!context) {
|
||||
throw new Error("useModelControls must be used within a ModelControlsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
24
apps/app/src/app/app-settings/model-controls-store.ts
Normal file
24
apps/app/src/app/app-settings/model-controls-store.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
|
||||
export type ModelBehaviorOption = { value: string | null; label: string };
|
||||
|
||||
export type ModelControlsStore = ReturnType<typeof createModelControlsStore>;
|
||||
|
||||
export function createModelControlsStore(options: {
|
||||
selectedSessionModelLabel: Accessor<string>;
|
||||
openSessionModelPicker: (options?: { returnFocusTarget?: "none" | "composer" }) => void;
|
||||
sessionModelVariantLabel: Accessor<string>;
|
||||
sessionModelVariant: Accessor<string | null>;
|
||||
sessionModelBehaviorOptions: Accessor<ModelBehaviorOption[]>;
|
||||
setSessionModelVariant: (value: string | null) => void;
|
||||
defaultModelLabel: Accessor<string>;
|
||||
defaultModelRef: Accessor<string>;
|
||||
openDefaultModelPicker: () => void;
|
||||
autoCompactContext: Accessor<boolean>;
|
||||
toggleAutoCompactContext: () => void;
|
||||
autoCompactContextBusy: Accessor<boolean>;
|
||||
defaultModelVariantLabel: Accessor<string>;
|
||||
editDefaultModelVariant: () => void;
|
||||
}) {
|
||||
return options;
|
||||
}
|
||||
30
apps/app/src/app/app-settings/session-display-preferences.ts
Normal file
30
apps/app/src/app/app-settings/session-display-preferences.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
21
apps/app/src/app/automations/provider.tsx
Normal file
21
apps/app/src/app/automations/provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import type { AutomationsStore } from "../context/automations";
|
||||
|
||||
const AutomationsContext = createContext<AutomationsStore>();
|
||||
|
||||
export function AutomationsProvider(props: ParentProps<{ store: AutomationsStore }>) {
|
||||
return (
|
||||
<AutomationsContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</AutomationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAutomations() {
|
||||
const context = useContext(AutomationsContext);
|
||||
if (!context) {
|
||||
throw new Error("useAutomations must be used within an AutomationsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
117
apps/app/src/app/bundles/apply.ts
Normal file
117
apps/app/src/app/bundles/apply.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown> = {
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
<Plus size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-12">Create New Worker</div>
|
||||
<div class="text-sm font-semibold text-gray-12">Create new worker</div>
|
||||
<div class="mt-1 text-sm text-gray-10">Open the existing new worker flow, then import this bundle into it.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +115,7 @@ export default function SharedBundleImportModal(props: {
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-12">Add to existing worker</div>
|
||||
<div class="mt-1 text-sm text-gray-10">Pick an existing worker and import the shared skills there.</div>
|
||||
<div class="mt-1 text-sm text-gray-10">Pick an existing worker and import this bundle there.</div>
|
||||
</div>
|
||||
<Show when={showWorkers()} fallback={<ChevronRight size={18} class="text-gray-10" />}>
|
||||
<ChevronDown size={18} class="text-gray-10" />
|
||||
6
apps/app/src/app/bundles/index.ts
Normal file
6
apps/app/src/app/bundles/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./apply";
|
||||
export * from "./publish";
|
||||
export * from "./schema";
|
||||
export * from "./sources";
|
||||
export * from "./store";
|
||||
export * from "./types";
|
||||
129
apps/app/src/app/bundles/publish.ts
Normal file
129
apps/app/src/app/bundles/publish.ts
Normal file
@@ -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<OpenworkWorkspaceExportSensitiveMode, "auto"> | 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<OpenworkWorkspaceExportSensitiveMode, "auto"> | 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 };
|
||||
}
|
||||
177
apps/app/src/app/bundles/schema.ts
Normal file
177
apps/app/src/app/bundles/schema.ts
Normal file
@@ -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<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>).workspace;
|
||||
if (!workspace || typeof workspace !== "object") return "starter";
|
||||
return readWorkspacePreset((workspace as Record<string, unknown>).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"}`);
|
||||
}
|
||||
@@ -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<void>;
|
||||
@@ -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: {
|
||||
<div class="space-y-2">
|
||||
<For each={props.workspaces}>
|
||||
{(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" ? <Globe size={16} /> : <Folder size={16} />);
|
||||
@@ -229,73 +229,77 @@ export default function SharedSkillDestinationModal(props: {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-12">{translate("share_skill_destination.new_destination")}</div>
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<Show when={props.onCreateWorker}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onCreateWorker}
|
||||
disabled={footerBusy()}
|
||||
class={`rounded-xl border border-gray-6/40 bg-transparent px-4 py-3 text-left transition-colors hover:border-gray-7/50 hover:bg-gray-2 ${footerBusy() ? "cursor-wait opacity-70" : ""}`.trim()}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-green-7/20 bg-green-7/10 text-green-11">
|
||||
<FolderPlus size={16} />
|
||||
<Show when={props.onCreateWorker || props.onConnectRemote}>
|
||||
<div class="space-y-3 border-t border-gray-6 pt-5">
|
||||
<div class="text-sm font-medium text-gray-12">{translate("share_skill_destination.more_options")}</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Show when={props.onCreateWorker}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onCreateWorker?.()}
|
||||
disabled={footerBusy()}
|
||||
class={`rounded-xl border border-indigo-7/30 bg-indigo-7/10 px-4 py-4 text-left transition hover:border-indigo-7/50 hover:bg-indigo-7/15 ${footerBusy() ? "cursor-not-allowed opacity-60" : ""}`.trim()}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full border border-indigo-7/30 bg-indigo-7/15 text-indigo-11">
|
||||
<FolderPlus size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-12">{translate("share_skill_destination.create_worker")}</div>
|
||||
<div class="mt-1 text-sm text-gray-10">{translate("share_skill_destination.create_worker_hint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">{translate("share_skill_destination.create_worker")}</div>
|
||||
<div class="mt-1 text-xs leading-relaxed text-gray-10">{translate("share_skill_destination.create_worker_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onConnectRemote}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onConnectRemote}
|
||||
disabled={footerBusy()}
|
||||
class={`rounded-xl border border-gray-6/40 bg-transparent px-4 py-3 text-left transition-colors hover:border-gray-7/50 hover:bg-gray-2 ${footerBusy() ? "cursor-wait opacity-70" : ""}`.trim()}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-sky-7/20 bg-sky-7/10 text-sky-11">
|
||||
<Globe size={16} />
|
||||
<Show when={props.onConnectRemote}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onConnectRemote?.()}
|
||||
disabled={footerBusy()}
|
||||
class={`rounded-xl border border-sky-7/30 bg-sky-7/10 px-4 py-4 text-left transition hover:border-sky-7/50 hover:bg-sky-7/15 ${footerBusy() ? "cursor-not-allowed opacity-60" : ""}`.trim()}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full border border-sky-7/30 bg-sky-7/15 text-sky-11">
|
||||
<Globe size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-12">{translate("share_skill_destination.connect_remote")}</div>
|
||||
<div class="mt-1 text-sm text-gray-10">{translate("share_skill_destination.connect_remote_hint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">{translate("share_skill_destination.connect_remote")}</div>
|
||||
<div class="mt-1 text-xs leading-relaxed text-gray-10">{translate("share_skill_destination.connect_remote_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-gray-6 bg-gray-1 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-gray-10">
|
||||
<Show when={selectedWorkspace()} fallback={<span>{translate("share_skill_destination.footer_idle")}</span>}>
|
||||
<div class="border-t border-gray-6 bg-gray-1 px-6 py-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Show when={selectedWorkspace()}>
|
||||
{(workspace) => (
|
||||
<span>
|
||||
{translate("share_skill_destination.footer_selected")} <span class="font-medium text-gray-12">{displayName(workspace())}</span>
|
||||
</span>
|
||||
<div class="min-w-0 text-sm text-gray-10">
|
||||
<span class="font-medium text-gray-12">{displayName(workspace())}</span>
|
||||
<span class="mx-2 text-gray-8">·</span>
|
||||
<span class="truncate align-middle">{subtitle(workspace())}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={props.onClose} disabled={footerBusy()}>
|
||||
{translate("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={submitSelectedWorkspace} disabled={!selectedWorkspaceId() || footerBusy()}>
|
||||
<Show when={footerBusy()} fallback={translate("share_skill_destination.confirm_button")}>
|
||||
<>
|
||||
<Loader2 size={15} class="animate-spin" />
|
||||
{translate("share_skill_destination.confirm_busy")}
|
||||
</>
|
||||
</Show>
|
||||
</Button>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button variant="ghost" onClick={props.onClose} disabled={footerBusy()}>
|
||||
{translate("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={submitSelectedWorkspace} disabled={!selectedWorkspace() || footerBusy()}>
|
||||
<Show when={footerBusy()} fallback={translate("share_skill_destination.add_to_workspace")}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
{translate("share_skill_destination.adding")}
|
||||
</span>
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
157
apps/app/src/app/bundles/sources.ts
Normal file
157
apps/app/src/app/bundles/sources.ts
Normal file
@@ -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<BundleV1> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
149
apps/app/src/app/bundles/start-modal.tsx
Normal file
149
apps/app/src/app/bundles/start-modal.tsx
Normal file
@@ -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<string | null>;
|
||||
onConfirm: (folder: string | null) => void | Promise<void>;
|
||||
}) {
|
||||
let pickFolderRef: HTMLButtonElement | undefined;
|
||||
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(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 (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-1/70 p-4 backdrop-blur-sm">
|
||||
<div class="w-full max-w-xl overflow-hidden rounded-[28px] border border-dls-border bg-dls-surface shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div class="border-b border-dls-border px-6 py-5 bg-dls-surface">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-dls-accent/10 text-dls-accent">
|
||||
<Rocket size={20} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-[18px] font-semibold text-dls-text">Start with {props.templateName}</h3>
|
||||
<p class="mt-1 text-sm leading-relaxed text-dls-secondary">
|
||||
{props.description?.trim() || "Pick a folder and OpenWork will create a workspace from this template."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={Boolean(props.busy)}
|
||||
class="rounded-full p-1 text-dls-secondary transition hover:bg-dls-hover hover:text-dls-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={visibleItems().length > 0}>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<For each={visibleItems()}>
|
||||
{(item) => (
|
||||
<span class="rounded-full border border-dls-border bg-dls-hover px-3 py-1 text-xs font-medium text-dls-text">
|
||||
{item}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={hiddenItemCount() > 0}>
|
||||
<span class="rounded-full border border-dls-border bg-dls-hover px-3 py-1 text-xs font-medium text-dls-text">
|
||||
+{hiddenItemCount()} more
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-6 py-6">
|
||||
<div class="rounded-2xl border border-dls-border bg-dls-sidebar px-5 py-4">
|
||||
<div class="text-[15px] font-semibold text-dls-text">Workspace folder</div>
|
||||
<p class="mt-1 text-sm text-dls-secondary">
|
||||
Choose where this template should live. OpenWork will create the workspace and bring in the template automatically.
|
||||
</p>
|
||||
<div class="mt-4 rounded-xl border border-dls-border bg-dls-surface px-4 py-3 text-sm text-dls-text">
|
||||
<Show when={selectedFolder()?.trim()} fallback={<span class="text-dls-secondary">No folder selected yet.</span>}>
|
||||
<span class="font-mono text-xs break-all">{selectedFolder()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
ref={pickFolderRef}
|
||||
onClick={handlePickFolder}
|
||||
disabled={pickingFolder() || Boolean(props.busy)}
|
||||
class="inline-flex items-center gap-2 rounded-full border border-dls-border bg-dls-surface px-4 py-2 text-xs font-medium text-dls-text transition-colors hover:bg-dls-hover disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
<Show when={pickingFolder()} fallback={<FolderPlus size={14} />}>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
</Show>
|
||||
{selectedFolder()?.trim() ? "Change folder" : "Select folder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-dls-border pt-4">
|
||||
<Button variant="ghost" onClick={props.onClose} disabled={Boolean(props.busy)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void props.onConfirm(selectedFolder())}
|
||||
disabled={!canSubmit()}
|
||||
>
|
||||
<Show when={props.busy} fallback="Create workspace">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Starting template...
|
||||
</span>
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
886
apps/app/src/app/bundles/store.ts
Normal file
886
apps/app/src/app/bundles/store.ts
Normal file
@@ -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<typeof createBundlesStore>;
|
||||
|
||||
export function createBundlesStore(options: {
|
||||
booting: Accessor<boolean>;
|
||||
startupPreference: Accessor<StartupPreference | null>;
|
||||
openworkServer: OpenworkServerStore;
|
||||
runtimeWorkspaceId: Accessor<string | null>;
|
||||
workspaceStore: WorkspaceStore;
|
||||
setError: (value: string | null) => void;
|
||||
error: Accessor<string | null>;
|
||||
setView: (next: View, sessionId?: string) => void;
|
||||
setSettingsTab: (nextTab: SettingsTab) => void;
|
||||
refreshActiveWorkspaceServerConfig: (workspaceId: string) => Promise<unknown>;
|
||||
refreshSkills: (input?: { force?: boolean }) => Promise<unknown>;
|
||||
refreshHubSkills: (input?: { force?: boolean }) => Promise<unknown>;
|
||||
markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void;
|
||||
showStatusToast: (toast: AppStatusToastInput) => void;
|
||||
}) {
|
||||
const [pendingBundleRequest, setPendingBundleRequest] = createSignal<BundleRequest | null>(null);
|
||||
const [bundleStartRequest, setBundleStartRequest] = createSignal<BundleStartRequest | null>(null);
|
||||
const [bundleStartBusy, setBundleStartBusy] = createSignal(false);
|
||||
const [createWorkspaceRequest, setCreateWorkspaceRequest] = createSignal<BundleCreateWorkspaceRequest | null>(null);
|
||||
const [skillDestinationRequest, setSkillDestinationRequest] = createSignal<SkillDestinationRequest | null>(null);
|
||||
const [skillDestinationBusyId, setSkillDestinationBusyId] = createSignal<string | null>(null);
|
||||
const [bundleImportChoice, setBundleImportChoice] = createSignal<BundleImportChoice | null>(null);
|
||||
const [bundleImportBusy, setBundleImportBusy] = createSignal(false);
|
||||
const [bundleImportError, setBundleImportError] = createSignal<string | null>(null);
|
||||
const [bundleNoticeShown, setBundleNoticeShown] = createSignal(false);
|
||||
const [untrustedBundleWarning, setUntrustedBundleWarning] = createSignal<UntrustedBundleWarning | null>(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<void>((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<BundleProcessResult> => {
|
||||
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<BundleImportSummary | null>(() => {
|
||||
const choice = bundleImportChoice();
|
||||
return choice ? describeBundleImport(choice.bundle) : null;
|
||||
});
|
||||
|
||||
const bundleStartItems = createMemo(() => {
|
||||
const request = bundleStartRequest();
|
||||
return request ? describeBundleImport(request.bundle).items : [];
|
||||
});
|
||||
|
||||
const createWorkspaceDefaultPreset = createMemo<WorkspacePreset>(() => 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<BundleWorkerOption[]>(() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
88
apps/app/src/app/bundles/types.ts
Normal file
88
apps/app/src/app/bundles/types.ts
Normal file
@@ -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[];
|
||||
};
|
||||
49
apps/app/src/app/bundles/url-policy.ts
Normal file
49
apps/app/src/app/bundles/url-policy.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export default function AddMcpModal(props: AddMcpModalProps) {
|
||||
const [command, setCommand] = createSignal("");
|
||||
const [oauthRequired, setOauthRequired] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(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}
|
||||
/>
|
||||
|
||||
<div class="relative w-full max-w-lg bg-gray-2 border border-gray-6 rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-gray-2 border border-gray-6 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-6">
|
||||
<div>
|
||||
@@ -159,15 +177,21 @@ export default function AddMcpModal(props: AddMcpModalProps) {
|
||||
value={url()}
|
||||
onInput={(e) => setUrl(e.currentTarget.value)}
|
||||
/>
|
||||
<label class="flex items-center gap-2 text-xs text-dls-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border border-dls-border"
|
||||
checked={oauthRequired()}
|
||||
onChange={(event) => setOauthRequired(event.currentTarget.checked)}
|
||||
/>
|
||||
{tr("mcp.oauth_optional_label")}
|
||||
</label>
|
||||
<div class="rounded-xl border border-dls-border bg-dls-hover/40 px-3 py-3">
|
||||
<div class="mb-2 text-xs font-medium text-dls-text">{tr("mcp.sign_in_section_label")}</div>
|
||||
<label class="flex items-start gap-2 text-xs text-dls-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border border-dls-border"
|
||||
checked={oauthRequired()}
|
||||
onChange={(event) => setOauthRequired(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-dls-text">{tr("mcp.oauth_optional_label")}</span>
|
||||
<span class="mt-0.5 block text-dls-secondary">{tr("mcp.oauth_optional_hint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -190,11 +214,11 @@ export default function AddMcpModal(props: AddMcpModalProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-6 bg-gray-2/50">
|
||||
<Button variant="ghost" onClick={handleClose}>
|
||||
<Button variant="ghost" onClick={handleClose} disabled={submitting()}>
|
||||
{tr("mcp.auth.cancel")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSubmit} disabled={props.busy}>
|
||||
<Show when={props.busy} fallback={<Plus size={16} />}>
|
||||
<Button variant="secondary" onClick={() => void handleSubmit()} disabled={props.busy || submitting()}>
|
||||
<Show when={props.busy || submitting()} fallback={<Plus size={16} />}>
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
</Show>
|
||||
{tr("mcp.add_server_button")}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { Globe, X } from "lucide-solid";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
|
||||
export default function CreateRemoteWorkspaceModal(props: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (input: {
|
||||
openworkHostUrl?: string | null;
|
||||
openworkToken?: string | null;
|
||||
directory?: string | null;
|
||||
displayName?: string | null;
|
||||
}) => void;
|
||||
initialValues?: {
|
||||
openworkHostUrl?: string | null;
|
||||
openworkToken?: string | null;
|
||||
directory?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
submitting?: boolean;
|
||||
error?: string | null;
|
||||
inline?: boolean;
|
||||
showClose?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
confirmLabel?: string;
|
||||
}) {
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
const translate = (key: string) => t(key, currentLocale());
|
||||
|
||||
const [openworkHostUrl, setOpenworkHostUrl] = createSignal("");
|
||||
const [openworkToken, setOpenworkToken] = createSignal("");
|
||||
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
|
||||
const [directory, setDirectory] = createSignal("");
|
||||
const [displayName, setDisplayName] = createSignal("");
|
||||
|
||||
const showClose = () => props.showClose ?? true;
|
||||
const title = () => props.title ?? translate("dashboard.create_remote_workspace_title");
|
||||
const subtitle = () => props.subtitle ?? translate("dashboard.create_remote_workspace_subtitle");
|
||||
const confirmLabel = () => props.confirmLabel ?? translate("dashboard.create_remote_workspace_confirm");
|
||||
const isInline = () => props.inline ?? false;
|
||||
const submitting = () => props.submitting ?? false;
|
||||
|
||||
const canSubmit = createMemo(() => {
|
||||
if (submitting()) return false;
|
||||
return openworkHostUrl().trim().length > 0;
|
||||
});
|
||||
const fieldLabelClass = "px-0.5 text-[13px] font-medium text-dls-text";
|
||||
const fieldHintClass = "px-0.5 text-[12px] leading-5 text-dls-secondary";
|
||||
const fieldInputClass =
|
||||
"w-full rounded-xl border border-dls-border bg-dls-surface px-4 py-3 text-sm text-dls-text outline-none transition placeholder:text-dls-secondary focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]";
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
requestAnimationFrame(() => inputRef?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
const defaults = props.initialValues ?? {};
|
||||
setOpenworkHostUrl(defaults.openworkHostUrl?.trim() ?? "");
|
||||
setOpenworkToken(defaults.openworkToken?.trim() ?? "");
|
||||
setOpenworkTokenVisible(false);
|
||||
setDirectory(defaults.directory?.trim() ?? "");
|
||||
setDisplayName(defaults.displayName?.trim() ?? "");
|
||||
});
|
||||
|
||||
const content = (
|
||||
<div class="flex max-h-[90vh] w-full max-w-[520px] flex-col overflow-hidden rounded-[24px] border border-dls-border bg-dls-surface">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-dls-border bg-dls-surface px-6 py-5">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-[18px] font-semibold text-dls-text">{title()}</h3>
|
||||
<p class="mt-1 text-sm text-dls-secondary">{subtitle()}</p>
|
||||
</div>
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
disabled={submitting()}
|
||||
class={`flex h-8 w-8 items-center justify-center rounded-full text-dls-secondary transition-colors hover:bg-dls-hover hover:text-dls-text ${submitting() ? "cursor-not-allowed opacity-50" : ""}`.trim()}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div class="rounded-xl border border-dls-border bg-dls-sidebar px-5 py-4">
|
||||
<div class="mb-4 flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-dls-border bg-dls-surface text-dls-text">
|
||||
<Globe size={17} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[15px] font-semibold text-dls-text">Remote server details</div>
|
||||
<div class="mt-1 text-[13px] leading-6 text-dls-secondary">Use the URL your OpenWork server shared with you. Add a token only if the server needs one.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<label class="grid gap-2">
|
||||
<span class={fieldLabelClass}>{translate("dashboard.openwork_host_label")}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
value={openworkHostUrl()}
|
||||
onInput={(event) => setOpenworkHostUrl(event.currentTarget.value)}
|
||||
placeholder={translate("dashboard.openwork_host_placeholder")}
|
||||
disabled={submitting()}
|
||||
class={fieldInputClass}
|
||||
/>
|
||||
<span class={fieldHintClass}>{translate("dashboard.openwork_host_hint")}</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2">
|
||||
<span class={fieldLabelClass}>{translate("dashboard.openwork_host_token_label")}</span>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-dls-border bg-dls-surface p-1.5">
|
||||
<input
|
||||
type={openworkTokenVisible() ? "text" : "password"}
|
||||
value={openworkToken()}
|
||||
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
|
||||
placeholder={translate("dashboard.openwork_host_token_placeholder")}
|
||||
disabled={submitting()}
|
||||
class="min-w-0 flex-1 border-none bg-transparent px-2 py-1.5 text-sm text-dls-text outline-none placeholder:text-dls-secondary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-dls-border bg-dls-surface px-3 py-2 text-xs font-medium text-dls-secondary transition-colors hover:bg-dls-hover hover:text-dls-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
|
||||
disabled={submitting()}
|
||||
>
|
||||
{openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
|
||||
</button>
|
||||
</div>
|
||||
<span class={fieldHintClass}>{translate("dashboard.openwork_host_token_hint")}</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="grid gap-2">
|
||||
<span class={fieldLabelClass}>{translate("dashboard.remote_directory_label")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={directory()}
|
||||
onInput={(event) => setDirectory(event.currentTarget.value)}
|
||||
placeholder={translate("dashboard.remote_directory_placeholder")}
|
||||
disabled={submitting()}
|
||||
class={fieldInputClass}
|
||||
/>
|
||||
<span class={fieldHintClass}>{translate("dashboard.remote_directory_hint")}</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2">
|
||||
<span class={fieldLabelClass}>{translate("dashboard.remote_display_name_label")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName()}
|
||||
onInput={(event) => setDisplayName(event.currentTarget.value)}
|
||||
placeholder={translate("dashboard.remote_display_name_placeholder")}
|
||||
disabled={submitting()}
|
||||
class={fieldInputClass}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 border-t border-dls-border bg-dls-surface px-6 py-5">
|
||||
<Show when={props.error}>
|
||||
<div class="rounded-lg border border-red-6 bg-red-3/50 p-3 text-sm text-red-11">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={submitting()}
|
||||
class="rounded-full border border-dls-border bg-dls-surface px-4 py-2 text-center text-xs font-medium text-dls-text transition-colors hover:bg-dls-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{translate("common.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
props.onConfirm({
|
||||
openworkHostUrl: openworkHostUrl().trim(),
|
||||
openworkToken: openworkToken().trim(),
|
||||
directory: directory().trim() ? directory().trim() : null,
|
||||
displayName: displayName().trim() ? displayName().trim() : null,
|
||||
})
|
||||
}
|
||||
disabled={!canSubmit()}
|
||||
title={!openworkHostUrl().trim() ? translate("dashboard.remote_base_url_required") : undefined}
|
||||
class="rounded-full bg-dls-accent px-6 py-2 text-xs font-medium text-white transition-colors hover:bg-[var(--dls-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{confirmLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={props.open || isInline()}>
|
||||
<div
|
||||
class={
|
||||
isInline()
|
||||
? "w-full"
|
||||
: "fixed inset-0 z-50 flex items-center justify-center bg-gray-1/60 p-4 animate-in fade-in duration-200"
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>;
|
||||
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<WorkspacePreset>(props.defaultPreset ?? "starter");
|
||||
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(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 = (
|
||||
<div class="flex max-h-[90vh] w-full max-w-[480px] flex-col overflow-hidden rounded-[24px] border border-dls-border bg-dls-surface">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-dls-border bg-dls-surface px-6 py-5">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-[18px] font-semibold text-dls-text">{title()}</h3>
|
||||
<p class="mt-1 text-sm text-dls-secondary">{subtitle()}</p>
|
||||
</div>
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
disabled={submitting()}
|
||||
class={`flex h-8 w-8 items-center justify-center rounded-full text-dls-secondary transition-colors hover:bg-dls-hover hover:text-dls-text ${submitting() ? "cursor-not-allowed opacity-50" : ""}`.trim()}
|
||||
aria-label="Close create workspace modal"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={`flex-1 overflow-y-auto px-6 py-6 transition-opacity duration-300 ${provisioning() ? "pointer-events-none opacity-40" : "opacity-100"}`}>
|
||||
<div class="rounded-xl border border-dls-border bg-dls-sidebar px-5 py-4">
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<div class="text-[15px] font-semibold text-dls-text">Workspace folder</div>
|
||||
</div>
|
||||
<div class="mb-4 text-[13px] text-gray-11">
|
||||
<Show when={hasSelectedFolder()} fallback={translate("dashboard.choose_folder_next")}>
|
||||
<span class="font-mono text-xs">{selectedFolder()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
ref={pickFolderRef}
|
||||
onClick={handlePickFolder}
|
||||
disabled={pickingFolder() || submitting()}
|
||||
class="flex items-center gap-2 rounded-full border border-dls-border bg-dls-surface px-4 py-2 text-center text-xs font-medium text-dls-text transition-colors hover:border-gray-8 hover:bg-gray-2 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
<Show when={pickingFolder()} fallback={<FolderPlus size={14} />}>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
</Show>
|
||||
{hasSelectedFolder() ? translate("dashboard.change") : "Select folder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-dls-border bg-dls-surface px-6 py-5">
|
||||
<Show when={submitting() && progress()}>
|
||||
{(p) => (
|
||||
<div class="rounded-xl border border-gray-6 bg-gray-2/50 px-4 py-3 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold text-gray-12">
|
||||
<Show when={!p().error} fallback={<XCircle size={14} class="text-red-11" />}>
|
||||
<Loader2 size={14} class="animate-spin text-indigo-11" />
|
||||
</Show>
|
||||
Sandbox setup
|
||||
</div>
|
||||
<div class="mt-1 truncate text-sm leading-snug text-gray-11">{p().stage}</div>
|
||||
<div class="mt-1 font-mono text-[10px] uppercase tracking-wider text-gray-9">{elapsedSeconds()}s</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded px-2 py-1 text-xs text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
|
||||
onClick={() => setShowProgressDetails((prev) => !prev)}
|
||||
>
|
||||
{showProgressDetails() ? "Hide logs" : "Show logs"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={p().error}>
|
||||
{(err) => (
|
||||
<div class="mt-3 rounded-lg border border-red-7/30 bg-red-2/40 px-3 py-2 text-xs text-red-11 animate-in fade-in">
|
||||
{err()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class="mt-4 grid gap-2.5">
|
||||
<For each={p().steps}>
|
||||
{(step) => {
|
||||
const icon = () => {
|
||||
if (step.status === "done") return <XCircle size={16} class="text-emerald-10" />;
|
||||
if (step.status === "active") return <Loader2 size={16} class="animate-spin text-indigo-11" />;
|
||||
if (step.status === "error") return <XCircle size={16} class="text-red-10" />;
|
||||
return <div class="h-4 w-4 rounded-full border-2 border-gray-6" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">{icon()}</div>
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<div class={`text-xs ${textClass()} transition-colors duration-200`.trim()}>{step.label}</div>
|
||||
<Show when={(step.detail ?? "").trim()}>
|
||||
<div class="max-w-[120px] truncate rounded bg-gray-3/50 px-1.5 py-0.5 font-mono text-[10px] text-gray-9">
|
||||
{step.detail}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={showProgressDetails() && (p().logs?.length ?? 0) > 0}>
|
||||
<div class="mt-3 rounded-lg border border-gray-6 bg-black/5 px-3 py-2 animate-in fade-in">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wide text-gray-10">Live Logs</div>
|
||||
</div>
|
||||
<div class="scrollbar-thin max-h-[120px] space-y-0.5 overflow-y-auto">
|
||||
<For each={p().logs.slice(-10)}>
|
||||
{(line) => <div class="break-all font-mono text-[10px] leading-tight text-gray-11">{line}</div>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={showWorkerCallout()}>
|
||||
<div class="rounded-xl border border-amber-7/30 bg-amber-2/40 px-4 py-3 text-xs text-amber-11">
|
||||
<div class="font-semibold text-amber-12">{translate("dashboard.sandbox_get_ready_title")}</div>
|
||||
<Show when={props.workerCtaDescription?.trim() || workerDisabledReason()}>
|
||||
<div class="mt-1 leading-relaxed text-amber-11">{workerDisabledReason() || props.workerCtaDescription?.trim()}</div>
|
||||
</Show>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Show when={props.onWorkerCta && props.workerCtaLabel?.trim()}>
|
||||
<Button variant="outline" onClick={props.onWorkerCta} disabled={submitting()}>
|
||||
{props.workerCtaLabel}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={props.onWorkerRetry && props.workerRetryLabel?.trim()}>
|
||||
<Button variant="ghost" onClick={props.onWorkerRetry} disabled={submitting()}>
|
||||
{props.workerRetryLabel}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={workerDebugLines().length > 0}>
|
||||
<details class="mt-3 rounded-lg border border-gray-6 bg-gray-2/60 px-3 py-2 text-[11px] text-gray-11">
|
||||
<summary class="cursor-pointer text-xs font-semibold text-gray-12">Docker debug details</summary>
|
||||
<div class="mt-2 space-y-1 break-words font-mono">
|
||||
<For each={workerDebugLines()}>
|
||||
{(line) => <div>{line}</div>}
|
||||
</For>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={submitting()}
|
||||
class="rounded-full border border-dls-border bg-dls-surface px-4 py-2 text-center text-xs font-medium text-dls-text transition-colors hover:bg-dls-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{translate("common.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onConfirmWorker}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onConfirmWorker?.(preset(), selectedFolder())}
|
||||
disabled={!selectedFolder() || submitting() || workerSubmitting() || workerDisabled()}
|
||||
title={(() => {
|
||||
if (!selectedFolder()) return translate("dashboard.choose_folder_continue");
|
||||
if (workerDisabled() && workerDisabledReason()) return workerDisabledReason();
|
||||
return undefined;
|
||||
})()}
|
||||
class="rounded-full border border-dls-border bg-dls-surface px-4 py-2 text-center text-xs font-medium text-dls-text transition-colors hover:bg-dls-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Show when={workerSubmitting()} fallback={workerLabel()}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
{translate("dashboard.sandbox_checking_docker")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onConfirm(preset(), selectedFolder())}
|
||||
disabled={!selectedFolder() || submitting()}
|
||||
title={!selectedFolder() ? translate("dashboard.choose_folder_continue") : undefined}
|
||||
class="rounded-full bg-dls-accent px-6 py-2 text-xs font-medium text-white transition-colors hover:bg-[var(--dls-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Show when={submitting()} fallback={confirmLabel()}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Creating...
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={props.open || isInline()}>
|
||||
<div
|
||||
class={
|
||||
isInline()
|
||||
? "w-full"
|
||||
: "fixed inset-0 z-50 flex items-center justify-center bg-gray-1/60 p-4 animate-in fade-in duration-200"
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
openTeamBundle: (input: {
|
||||
templateId: string;
|
||||
name: string;
|
||||
templateData: unknown;
|
||||
organizationName?: string | null;
|
||||
}) => void | Promise<void>;
|
||||
};
|
||||
|
||||
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<string | null>(null);
|
||||
const [openingTemplateId, setOpeningTemplateId] = createSignal<string | null>(null);
|
||||
const [user, setUser] = createSignal<{
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -101,16 +116,30 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
const [authError, setAuthError] = createSignal<string | null>(null);
|
||||
const [orgsError, setOrgsError] = createSignal<string | null>(null);
|
||||
const [workersError, setWorkersError] = createSignal<string | null>(null);
|
||||
const [templateActionError, setTemplateActionError] = createSignal<string | null>(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 (
|
||||
<div class="space-y-6">
|
||||
@@ -422,14 +626,14 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
<div class="space-y-2">
|
||||
<div class={headerBadgeClass}>
|
||||
<Cloud size={13} class="text-dls-secondary" />
|
||||
OpenWork Den
|
||||
OpenWork Cloud
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-dls-text">
|
||||
Sign in, pick an org, and open Den workers from Settings.
|
||||
Sign in, pick an org, and open Cloud workers or team templates.
|
||||
</div>
|
||||
<div class="mt-1 max-w-[60ch] text-xs text-dls-secondary">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,11 +649,11 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
<Show when={props.developerMode}>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<TextInput
|
||||
label="Den control plane URL"
|
||||
label="Cloud control plane URL"
|
||||
value={baseUrlDraft()}
|
||||
onInput={(event) => 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()}
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -489,9 +693,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={statusMessage() && !authError() && !workersError() && !orgsError()}>
|
||||
<Show when={statusMessage() && !authError() && !workersError() && !orgsError() && !templatesError()}>
|
||||
{(value) => (
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/60 px-3 py-2 text-xs text-gray-11">
|
||||
<div class={softNoticeClass}>
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
@@ -500,15 +704,15 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
|
||||
<Show when={!isSignedIn()}>
|
||||
<div class={`${settingsPanelClass} space-y-4`}>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-dls-text">
|
||||
Sign in to OpenWork Den
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-dls-text">
|
||||
Sign in to OpenWork Cloud
|
||||
</div>
|
||||
<div class="max-w-[54ch] text-sm text-dls-secondary">
|
||||
Sign in to OpenWork Cloud to keep your tasks alive even when your
|
||||
computer sleeps.
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-[54ch] text-sm text-dls-secondary">
|
||||
Sign in to OpenWork Den to keep your tasks alive even when your
|
||||
computer sleeps.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => openBrowserAuth("sign-in")}>
|
||||
@@ -523,8 +727,45 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
Create account
|
||||
<ArrowUpRight size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-9 px-3"
|
||||
onClick={() => {
|
||||
setManualAuthOpen((value) => !value);
|
||||
setAuthError(null);
|
||||
}}
|
||||
disabled={authBusy() || sessionBusy()}
|
||||
>
|
||||
{manualAuthOpen() ? "Hide sign-in code" : "Paste sign-in code"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={manualAuthOpen()}>
|
||||
<div class={`${settingsPanelSoftClass} space-y-3`}>
|
||||
<TextInput
|
||||
label="Sign-in link or one-time code"
|
||||
value={manualAuthInput()}
|
||||
onInput={(event) => 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."
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="text-xs h-9 px-3"
|
||||
onClick={() => void submitManualAuth()}
|
||||
disabled={authBusy() || sessionBusy() || !manualAuthInput().trim()}
|
||||
>
|
||||
{authBusy() ? "Finishing..." : "Finish sign-in"}
|
||||
</Button>
|
||||
<div class="text-[11px] text-dls-secondary">
|
||||
Accepts an <span class="font-mono">openwork://den-auth</span> link or the raw one-time grant.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={authError()}>
|
||||
{(value) => (
|
||||
<div class="rounded-xl border border-red-7/30 bg-red-1/40 px-3 py-2 text-xs text-red-11">
|
||||
@@ -544,14 +785,14 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
<div class="space-y-6">
|
||||
<div class={`${settingsPanelClass} space-y-4`}>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-dls-text">Den Account</div>
|
||||
<div class="text-sm font-medium text-dls-text">Cloud account</div>
|
||||
<div class="mt-1 text-xs text-dls-secondary">
|
||||
Manage your connected account and organization.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-gray-6/60 bg-gray-1/40 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="ow-soft-card-quiet flex flex-col gap-3 rounded-xl p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium text-dls-text">
|
||||
{user()?.name || user()?.email}
|
||||
@@ -562,7 +803,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 px-3 text-xs shrink-0"
|
||||
class={`h-10 px-4 text-sm shrink-0 ${quietControlClass}`}
|
||||
onClick={() => void signOut()}
|
||||
disabled={authBusy() || sessionBusy()}
|
||||
>
|
||||
@@ -571,21 +812,30 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-gray-6/60 bg-gray-1/40 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="ow-soft-card-quiet flex flex-col gap-3 rounded-xl p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-dls-text">Active org</div>
|
||||
<div class="truncate text-xs text-dls-secondary">
|
||||
Workers are scoped to the selected org.
|
||||
Cloud workers and team templates are scoped to the selected org.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
class="max-w-[220px] rounded-lg border border-dls-border bg-dls-surface px-3 py-1.5 text-xs text-dls-text shadow-sm focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.2)]"
|
||||
class={`ow-input h-10 max-w-[260px] rounded-xl px-4 py-2 text-sm font-medium text-dls-text ${quietControlClass}`}
|
||||
value={activeOrgId()}
|
||||
onChange={(event) => {
|
||||
setActiveOrgId(event.currentTarget.value);
|
||||
const nextId = event.currentTarget.value;
|
||||
const nextOrg = orgs().find((org) => org.id === nextId) ?? null;
|
||||
setActiveOrgId(nextId);
|
||||
writeDenSettings({
|
||||
baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL,
|
||||
authToken: authToken() || null,
|
||||
activeOrgId: nextId || null,
|
||||
activeOrgSlug: nextOrg?.slug ?? null,
|
||||
activeOrgName: nextOrg?.name ?? null,
|
||||
});
|
||||
setStatusMessage(
|
||||
`Switched to ${activeOrg()?.name ?? "the selected org"}.`,
|
||||
`Switched to ${nextOrg?.name ?? "the selected org"}.`,
|
||||
);
|
||||
}}
|
||||
disabled={orgsBusy() || orgs().length === 0}
|
||||
@@ -600,7 +850,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 px-3 text-xs"
|
||||
class={`h-10 px-4 text-sm ${quietControlClass}`}
|
||||
onClick={() => void refreshOrgs()}
|
||||
disabled={orgsBusy()}
|
||||
>
|
||||
@@ -624,7 +874,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-dls-text">
|
||||
<Server size={15} class="text-dls-secondary" />
|
||||
Den workers
|
||||
Cloud workers
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-dls-secondary">
|
||||
Open workers directly into OpenWork using the same
|
||||
@@ -632,9 +882,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1.5 rounded-full border border-gray-6/60 bg-gray-1/40 px-2.5 py-1 text-[11px] font-medium text-gray-11">
|
||||
<div class={sectionPillClass}>
|
||||
<Users size={12} />
|
||||
{activeOrg()?.name || "No org selected"}
|
||||
{activeOrgName()}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -659,7 +909,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
<Show when={!workersBusy() && workers().length === 0}>
|
||||
<div class={`${settingsPanelSoftClass} border-dashed py-6 text-center text-sm text-dls-secondary`}>
|
||||
No cloud workers are visible for this org yet. Create one in
|
||||
Den, then refresh this tab.
|
||||
Cloud, then refresh this tab.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -668,7 +918,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
{(worker) => {
|
||||
const status = createMemo(() => workerStatusMeta(worker.status));
|
||||
return (
|
||||
<div class="flex items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] transition-colors hover:bg-gray-2/60">
|
||||
<div class="flex items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] transition-colors hover:bg-[#f8fafc]">
|
||||
<div class="min-w-0 pr-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium text-dls-text">
|
||||
@@ -680,7 +930,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
{status().label}
|
||||
</span>
|
||||
<Show when={worker.isMine}>
|
||||
<span class="inline-flex items-center rounded-full border border-gray-6/60 bg-gray-1/40 px-2 py-0.5 text-[10px] font-medium text-gray-11">
|
||||
<span class={sectionPillClass}>
|
||||
Mine
|
||||
</span>
|
||||
</Show>
|
||||
@@ -709,6 +959,93 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={`${settingsPanelClass} space-y-4`}>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-dls-text">
|
||||
<Boxes size={15} class="text-dls-secondary" />
|
||||
Team templates
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-dls-secondary">
|
||||
Open reusable workspace templates shared with this organization.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class={sectionPillClass}>
|
||||
<Users size={12} />
|
||||
{activeOrgName()}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 px-3 text-xs"
|
||||
onClick={() => void refreshTemplates()}
|
||||
disabled={templatesBusy() || !activeOrg()?.slug?.trim()}
|
||||
>
|
||||
<RefreshCcw size={13} class={templatesBusy() ? "animate-spin" : ""} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={templatesError()}>
|
||||
{(value) => (
|
||||
<div class="rounded-xl border border-red-7/30 bg-red-1/40 px-3 py-2 text-xs text-red-11">
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={!templatesBusy() && templates().length === 0}>
|
||||
<div class={`${settingsPanelSoftClass} border-dashed py-6 text-center text-sm text-dls-secondary`}>
|
||||
<Show
|
||||
when={activeOrg()?.slug?.trim()}
|
||||
fallback={"Choose an org to view team templates."}
|
||||
>
|
||||
No team templates yet. Use Share -> Template -> Share with team.
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-1">
|
||||
<For each={templates()}>
|
||||
{(template) => {
|
||||
const isMine = () => template.creator?.userId === user()?.id;
|
||||
const opening = () => openingTemplateId() === template.id;
|
||||
return (
|
||||
<div class="flex items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] transition-colors hover:bg-[#f8fafc]">
|
||||
<div class="min-w-0 pr-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium text-dls-text">
|
||||
{template.name}
|
||||
</span>
|
||||
<span class={sectionPillClass}>
|
||||
Team template
|
||||
</span>
|
||||
<Show when={isMine()}>
|
||||
<span class={sectionPillClass}>
|
||||
Mine
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-dls-secondary">
|
||||
by {templateCreatorLabel(template)} · {formatTemplateTimestamp(template.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-8 px-4 text-xs shrink-0"
|
||||
onClick={() => void handleOpenTemplate(template)}
|
||||
disabled={openingTemplateId() !== null}
|
||||
>
|
||||
{opening() ? "Opening..." : "Open"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +328,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
|
||||
</h3>
|
||||
<p class="text-sm text-gray-11 mt-1">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>;
|
||||
}) {
|
||||
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 (
|
||||
<div class="bg-gray-2 border border-gray-6 rounded-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div class="p-6 flex-1 overflow-y-auto space-y-8">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm font-medium text-gray-12">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs">1</div>
|
||||
Select Folder
|
||||
</div>
|
||||
<div class="ml-9">
|
||||
<div
|
||||
class={`w-full border border-dashed border-gray-6 bg-gray-1/40 rounded-xl p-4 text-left transition ${
|
||||
pickingFolder() ? "opacity-70" : "hover:border-dls-active"
|
||||
}`.trim()}
|
||||
>
|
||||
<div class="flex items-center gap-3 text-dls-text">
|
||||
<FolderPlus size={20} class="text-dls-secondary" />
|
||||
<input
|
||||
class="flex-1 min-w-0 bg-transparent text-sm font-medium text-dls-text placeholder:text-dls-secondary focus:outline-none"
|
||||
value={selectedFolder()}
|
||||
onInput={(e) => setSelectedFolder(e.currentTarget.value)}
|
||||
placeholder={props.defaultPath}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFolder}
|
||||
disabled={pickingFolder()}
|
||||
class="text-xs text-dls-secondary hover:text-dls-text transition-colors"
|
||||
>
|
||||
<Show
|
||||
when={pickingFolder()}
|
||||
fallback={<span>Choose</span>}
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
Opening...
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm font-medium text-gray-12">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs">2</div>
|
||||
Choose Preset
|
||||
</div>
|
||||
<div class={`ml-9 grid gap-3 ${!canContinue() ? "opacity-50" : ""}`.trim()}>
|
||||
<For each={options()}>
|
||||
{(opt) => (
|
||||
<div
|
||||
onClick={() => {
|
||||
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()}
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div
|
||||
class={`font-medium text-sm ${
|
||||
preset() === opt.id ? "text-indigo-400" : "text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{opt.name}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 mt-1">{opt.desc}</div>
|
||||
</div>
|
||||
<Show when={preset() === opt.id}>
|
||||
<CheckCircle2 size={16} class="text-indigo-500" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export default function ReloadWorkspaceToast(props: ReloadWorkspaceToastProps) {
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-white/70 bg-white/92 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.28)] backdrop-blur-xl animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div class="w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-dls-border bg-dls-surface shadow-[var(--dls-shell-shadow)] backdrop-blur-xl animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div class="flex items-start gap-3 px-4 py-4">
|
||||
<div
|
||||
class={`flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border ${
|
||||
|
||||
74
apps/app/src/app/components/session/composer-notice.tsx
Normal file
74
apps/app/src/app/components/session/composer-notice.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Show } from "solid-js";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert, Info } from "lucide-solid";
|
||||
|
||||
import type { AppStatusToastTone } from "../../shell/status-toasts";
|
||||
|
||||
export type ComposerNotice = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
tone?: AppStatusToastTone;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
};
|
||||
|
||||
export default function ComposerNotice(props: { notice: ComposerNotice | null }) {
|
||||
const tone = () => props.notice?.tone ?? "info";
|
||||
|
||||
return (
|
||||
<Show when={props.notice}>
|
||||
{(notice) => (
|
||||
<div class="absolute bottom-full right-0 mb-3 z-30 w-[min(26rem,calc(100vw-2rem))] max-w-full overflow-hidden rounded-[1.2rem] border border-dls-border bg-dls-surface px-4 py-3 shadow-[var(--dls-shell-shadow)] backdrop-blur-xl animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border ${
|
||||
tone() === "success"
|
||||
? "border-emerald-6/40 bg-emerald-4/80 text-emerald-11"
|
||||
: tone() === "warning"
|
||||
? "border-amber-6/40 bg-amber-4/80 text-amber-11"
|
||||
: tone() === "error"
|
||||
? "border-red-6/40 bg-red-4/80 text-red-11"
|
||||
: "border-sky-6/40 bg-sky-4/80 text-sky-11"
|
||||
}`.trim()}
|
||||
>
|
||||
<Show
|
||||
when={tone() === "success"}
|
||||
fallback={
|
||||
tone() === "warning" ? (
|
||||
<AlertTriangle size={18} />
|
||||
) : tone() === "error" ? (
|
||||
<CircleAlert size={18} />
|
||||
) : (
|
||||
<Info size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium leading-relaxed text-dls-text">
|
||||
{notice().title}
|
||||
</div>
|
||||
<Show when={notice().description?.trim()}>
|
||||
<p class="mt-1 text-[12px] leading-relaxed text-dls-secondary">
|
||||
{notice().description}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={notice().actionLabel && notice().onAction}>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex items-center justify-center rounded-full border border-dls-border bg-dls-surface px-3 py-1.5 text-[12px] font-medium text-dls-text transition-colors hover:bg-dls-hover"
|
||||
onClick={() => notice().onAction?.()}
|
||||
>
|
||||
{notice().actionLabel}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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<Agent[]>;
|
||||
recentFiles: string[];
|
||||
searchFiles: (query: string) => Promise<string[]>;
|
||||
@@ -475,11 +473,7 @@ export default function Composer(props: ComposerProps) {
|
||||
const [attachments, setAttachments] = createSignal<ComposerAttachment[]>([]);
|
||||
const [draftText, setDraftText] = createSignal(normalizeText(props.prompt));
|
||||
const [mode, setMode] = createSignal<PromptMode>("prompt");
|
||||
const [historySnapshot, setHistorySnapshot] = createSignal<ComposerDraft | null>(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) {
|
||||
</Show>
|
||||
|
||||
<div class="p-5 md:p-6">
|
||||
<Show when={props.showNotionBanner}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full mb-2 flex items-center justify-between gap-3 rounded-xl border border-green-7/20 bg-green-7/10 px-3 py-2 text-left text-sm text-green-12 transition-colors hover:bg-green-7/15"
|
||||
onClick={props.onNotionBannerClick}
|
||||
>
|
||||
<span>Try it now: set up my CRM in Notion</span>
|
||||
<span class="text-xs text-green-12 font-medium">Insert prompt</span>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={attachments().length}>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<For each={attachments()}>
|
||||
@@ -1742,22 +1671,7 @@ export default function Composer(props: ComposerProps) {
|
||||
</Show>
|
||||
|
||||
<div class="relative min-h-[120px]">
|
||||
<Show when={props.toast}>
|
||||
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-gray-6 bg-gray-1 px-3 py-2 text-xs text-gray-11 shadow-lg backdrop-blur-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<span>{props.toast}</span>
|
||||
<Show when={showInboxUploadAction() && props.onUploadInboxFiles}>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md border border-gray-6 bg-gray-2 px-2 py-1 text-[10px] text-gray-11 hover:bg-gray-3"
|
||||
onClick={() => inboxFileInputRef?.click()}
|
||||
>
|
||||
Upload to shared folder
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<ComposerNotice notice={props.notice} />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
@@ -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) {
|
||||
<div class="px-4 pb-4 pt-1">
|
||||
<div class="space-y-2">
|
||||
<Show
|
||||
when={props.mcpServers.length}
|
||||
when={connections.mcpServers().length}
|
||||
fallback={
|
||||
<div class="text-xs text-gray-9">
|
||||
{props.mcpStatus ?? "No MCP servers loaded."}
|
||||
{connections.mcpStatus() ?? "No MCP servers loaded."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.mcpServers}>
|
||||
<For each={connections.mcpServers()}>
|
||||
{(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"
|
||||
|
||||
@@ -833,7 +833,7 @@ export default function MessageList(props: MessageListProps) {
|
||||
<div class="text-[14px] text-gray-9">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-2 text-left transition-colors hover:text-dls-text disabled:cursor-default"
|
||||
class="w-full text-left transition-colors hover:text-dls-text disabled:cursor-default"
|
||||
aria-expanded={expandable() ? expanded() : undefined}
|
||||
disabled={!expandable()}
|
||||
onClick={() => {
|
||||
@@ -841,15 +841,15 @@ export default function MessageList(props: MessageListProps) {
|
||||
toggleSteps(rowProps.id);
|
||||
}}
|
||||
>
|
||||
<div class="min-w-0 flex-1 leading-relaxed">
|
||||
<span>{headline()}</span>
|
||||
</div>
|
||||
<Show when={expandable()} fallback={<span class="mt-[2px] w-[14px] shrink-0" />}>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
class={`mt-[2px] shrink-0 text-gray-8 transition-transform ${expanded() ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</Show>
|
||||
<span class="inline-flex max-w-[720px] items-start gap-1.5 leading-relaxed align-top">
|
||||
<span class="min-w-0 break-words">{headline()}</span>
|
||||
<Show when={expandable()}>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
class={`mt-[2px] shrink-0 text-gray-8 transition-transform ${expanded() ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div class="mt-3 ml-[22px] space-y-3">
|
||||
|
||||
213
apps/app/src/app/components/session/scroll-controller.ts
Normal file
213
apps/app/src/app/components/session/scroll-controller.ts
Normal file
@@ -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<string | null>;
|
||||
renderedMessages: Accessor<unknown>;
|
||||
containerRef: Accessor<HTMLDivElement | undefined>;
|
||||
contentRef: Accessor<HTMLDivElement | undefined>;
|
||||
};
|
||||
|
||||
export function createSessionScrollController(
|
||||
options: SessionScrollControllerOptions,
|
||||
) {
|
||||
const [mode, setMode] = createSignal<SessionScrollMode>("follow-latest");
|
||||
const [topClippedMessageId, setTopClippedMessageId] = createSignal<string | null>(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<HTMLDivElement, Event> = (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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, WorkspaceConnectionState>;
|
||||
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<string, boolean>
|
||||
>({});
|
||||
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 (
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="px-4 pt-4 shrink-0">
|
||||
@@ -303,7 +286,7 @@ export default function SessionSidebar(props: SidebarProps) {
|
||||
>
|
||||
<For each={props.workspaceGroups}>
|
||||
{(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) {
|
||||
>
|
||||
<span
|
||||
class={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||
props.sessionStatusById[session.id] === "running"
|
||||
props.sessionStatusById[session.id] === "running" || props.sessionStatusById[session.id] === "retry"
|
||||
? "border-amber-7/50 text-amber-11 bg-amber-2/50"
|
||||
: "border-gray-7/50 text-gray-10 bg-gray-2/50"
|
||||
}`}
|
||||
@@ -519,7 +502,9 @@ export default function SessionSidebar(props: SidebarProps) {
|
||||
class={`w-1 h-1 rounded-full ${
|
||||
props.sessionStatusById[session.id] === "running"
|
||||
? "bg-amber-9 animate-pulse"
|
||||
: "bg-gray-9"
|
||||
: props.sessionStatusById[session.id] === "retry"
|
||||
? "bg-amber-9"
|
||||
: "bg-gray-9"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
@@ -547,56 +532,18 @@ export default function SessionSidebar(props: SidebarProps) {
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="relative" ref={(el) => (addWorkspaceMenuRef = el)}>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-gray-11 border border-dashed border-gray-6 hover:border-gray-7 hover:text-gray-12 hover:bg-gray-2 transition-colors"
|
||||
onClick={() => setAddWorkspaceMenuOpen((prev) => !prev)}
|
||||
class="w-full flex items-center justify-center gap-2 rounded-[18px] border border-dls-border bg-dls-surface px-3.5 py-2.5 text-[12px] font-medium text-gray-11 shadow-[var(--dls-card-shadow)] transition-colors hover:bg-gray-2"
|
||||
onClick={props.onCreateWorkspace}
|
||||
onDragOver={(event) => handleDragOver(event, null)}
|
||||
onDragLeave={() => handleDragLeave(null)}
|
||||
onDrop={(event) => handleDrop(event, null)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add new workspace
|
||||
Add workspace
|
||||
</button>
|
||||
<Show when={addWorkspaceMenuOpen()}>
|
||||
<div class="mt-2 rounded-lg border border-gray-6 bg-gray-1 shadow-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-11 hover:bg-gray-2 transition-colors"
|
||||
onClick={() => {
|
||||
props.onCreateWorkspace();
|
||||
setAddWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New worker
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-11 hover:bg-gray-2 transition-colors"
|
||||
onClick={() => {
|
||||
props.onCreateRemoteWorkspace();
|
||||
setAddWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Connect remote
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-11 hover:bg-gray-2 transition-colors disabled:opacity-60"
|
||||
disabled={props.importingWorkspaceConfig}
|
||||
onClick={() => {
|
||||
props.onImportWorkspace();
|
||||
setAddWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Import config
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, WorkspaceConnectionState>;
|
||||
newTaskDisabled: boolean;
|
||||
importingWorkspaceConfig: boolean;
|
||||
onActivateWorkspace: (
|
||||
workspaceId: string,
|
||||
) => Promise<boolean> | boolean | void;
|
||||
onSelectWorkspace: (workspaceId: string) => Promise<boolean> | 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<string>
|
||||
>(new Set());
|
||||
@@ -202,13 +194,11 @@ export default function WorkspaceSessionList(props: Props) {
|
||||
const [workspaceMenuId, setWorkspaceMenuId] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addWorkspaceMenuOpen, setAddWorkspaceMenuOpen] = createSignal(false);
|
||||
const [sessionMenuOpen, setSessionMenuOpen] = createSignal(false);
|
||||
const [expandedSessionIds, setExpandedSessionIds] = createSignal<Set<string>>(
|
||||
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) {
|
||||
|
||||
<div class="mt-3 px-1 pb-1">
|
||||
<div class="relative flex flex-col gap-1 pl-2.5 before:absolute before:bottom-2 before:left-0 before:top-2 before:w-[2px] before:bg-gray-3 before:content-['']">
|
||||
<Show
|
||||
when={isWorkspaceExpanded(workspace().id)}
|
||||
fallback={
|
||||
<Show when={group.sessions.length > 0}>
|
||||
<For
|
||||
each={previewSessions(
|
||||
workspace().id,
|
||||
group.sessions,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
>
|
||||
{(row) =>
|
||||
renderSessionRow(
|
||||
<Show
|
||||
when={isWorkspaceExpanded(workspace().id)}
|
||||
fallback={
|
||||
<Show when={group.sessions.length > 0}>
|
||||
<For
|
||||
each={previewSessions(
|
||||
workspace().id,
|
||||
row,
|
||||
group.sessions,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
>
|
||||
{(row) =>
|
||||
renderSessionRow(
|
||||
workspace().id,
|
||||
row,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={
|
||||
getRootSessions(group.sessions).length >
|
||||
previewCount(workspace().id)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
|
||||
onClick={() =>
|
||||
showMoreSessions(
|
||||
workspace().id,
|
||||
getRootSessions(group.sessions).length,
|
||||
)
|
||||
}
|
||||
>
|
||||
{showMoreLabel(
|
||||
workspace().id,
|
||||
getRootSessions(group.sessions).length,
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
group.status === "loading" &&
|
||||
@@ -856,70 +857,15 @@ export default function WorkspaceSessionList(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mt-auto border-t border-dls-border/80 bg-dls-sidebar pt-3"
|
||||
ref={(el) => (addWorkspaceMenuRef = el)}
|
||||
>
|
||||
<div class="relative mt-auto border-t border-dls-border/80 bg-dls-sidebar pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 rounded-[18px] border border-dls-border bg-dls-surface px-3.5 py-2.5 text-[12px] font-medium text-gray-11 shadow-[var(--dls-card-shadow)] transition-colors hover:bg-gray-2"
|
||||
onClick={() => setAddWorkspaceMenuOpen((prev) => !prev)}
|
||||
onClick={props.onOpenCreateWorkspace}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add workspace
|
||||
</button>
|
||||
|
||||
<Show when={addWorkspaceMenuOpen()}>
|
||||
<div class="absolute left-0 right-0 bottom-full z-20 mb-2 overflow-hidden rounded-[18px] border border-dls-border bg-dls-surface p-1.5 shadow-[var(--dls-shell-shadow)]">
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full flex items-center gap-2 rounded-xl px-3 py-2 text-xs transition-colors ${
|
||||
newWorkspaceDesktopOnly
|
||||
? "cursor-not-allowed text-gray-9 opacity-70"
|
||||
: "text-gray-11 hover:bg-gray-2 hover:text-gray-12"
|
||||
}`}
|
||||
disabled={newWorkspaceDesktopOnly}
|
||||
title={
|
||||
newWorkspaceDesktopOnly
|
||||
? "Create local workspaces in the desktop app."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
props.onOpenCreateWorkspace();
|
||||
setAddWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span class="flex-1 text-left">New workspace</span>
|
||||
<Show when={newWorkspaceDesktopOnly}>
|
||||
<DesktopOnlyBadge />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-gray-11 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
onClick={() => {
|
||||
props.onOpenCreateRemoteWorkspace();
|
||||
setAddWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Connect remote workspace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-gray-11 transition-colors hover:bg-gray-2 hover:text-gray-12 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={props.importingWorkspaceConfig}
|
||||
onClick={() => {
|
||||
props.onImportWorkspaceConfig();
|
||||
setAddWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Import config
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
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<ShareView>("chooser");
|
||||
const [revealedByIndex, setRevealedByIndex] = createSignal<Record<number, boolean>>({});
|
||||
const [copiedKey, setCopiedKey] = createSignal<string | null>(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 (
|
||||
<div class="group">
|
||||
<label class="text-[11px] uppercase tracking-wider font-medium text-gray-10 mb-1.5 block">
|
||||
{displayFieldLabel(field)}
|
||||
</label>
|
||||
<div class="relative flex items-center">
|
||||
<input
|
||||
type={isSecret() && !revealed() ? "password" : "text"}
|
||||
readonly
|
||||
value={field.value || field.placeholder || ""}
|
||||
class="w-full bg-transparent border border-dls-border rounded-md py-2 pl-3 pr-20 text-[12px] font-mono text-dls-text transition-colors outline-none focus:border-[rgba(var(--dls-accent-rgb),0.45)] focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.18)]"
|
||||
/>
|
||||
<div class="absolute right-1 flex items-center gap-0.5">
|
||||
<Show when={isSecret()}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRevealedByIndex((prev) => ({
|
||||
...prev,
|
||||
[index()]: !prev[index()],
|
||||
}))
|
||||
}
|
||||
disabled={!field.value}
|
||||
class="p-1.5 text-gray-10 hover:text-gray-12 hover:bg-gray-3 rounded-md transition-colors disabled:opacity-50"
|
||||
title={revealed() ? "Hide password" : "Reveal password"}
|
||||
>
|
||||
<Show when={revealed()} fallback={<Eye size={14} />}>
|
||||
<EyeOff size={14} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => handleCopy(field.value, key())}
|
||||
disabled={!field.value}
|
||||
class="p-1.5 text-gray-10 hover:text-gray-12 hover:bg-gray-3 rounded-md transition-colors disabled:opacity-50"
|
||||
title="Copy"
|
||||
>
|
||||
<Show when={copiedKey() === key()} fallback={<Copy size={14} />}>
|
||||
<Check size={14} class="text-emerald-10" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={field.hint && field.hint.trim()}>
|
||||
<p class="text-[11px] text-gray-9 mt-1.5">{field.hint}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
) => (
|
||||
<Show
|
||||
when={value?.trim()}
|
||||
fallback={
|
||||
<button
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2 animate-in fade-in zoom-in-95 duration-200">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={value!}
|
||||
class="flex-1 bg-transparent border border-dls-border rounded-md py-1.5 px-2.5 text-[12px] font-mono text-gray-11 outline-none focus:border-[rgba(var(--dls-accent-rgb),0.45)] focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.18)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopy(value ?? "", copyKey)}
|
||||
class="p-1.5 hover:bg-gray-3 text-gray-11 hover:text-gray-12 rounded-md transition-colors"
|
||||
title="Copy link"
|
||||
>
|
||||
<Show when={copiedKey() === copyKey} fallback={<Copy size={14} />}>
|
||||
<Check size={14} class="text-emerald-10" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => regenerate?.()}
|
||||
disabled={busy}
|
||||
class="mt-3 w-full rounded-full bg-gray-2 px-4 py-2 text-[12px] font-medium text-gray-11 transition-colors hover:bg-gray-3 hover:text-gray-12"
|
||||
>
|
||||
{busy ? "Publishing..." : regenerateLabel}
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center bg-black/45 px-3 pt-[12vh] md:px-6 font-sans animate-in fade-in duration-200">
|
||||
<div
|
||||
class="w-full max-w-[580px] rounded-2xl border border-dls-border bg-dls-surface shadow-[0_20px_70px_rgba(0,0,0,0.45)] overflow-hidden animate-in fade-in zoom-in-95 duration-300 relative flex flex-col max-h-[75vh]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="border-b border-dls-border px-4 py-3 relative shrink-0">
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="absolute top-3 right-3 p-1 text-gray-9 hover:text-gray-12 hover:bg-gray-3 rounded-md transition-colors"
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<Show when={activeView() !== "chooser"}>
|
||||
<button
|
||||
onClick={() => setActiveView("chooser")}
|
||||
class="absolute top-3 left-3 p-1 text-gray-9 hover:text-gray-12 hover:bg-gray-3 rounded-md transition-colors"
|
||||
aria-label="Back"
|
||||
title="Back to share options"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2" classList={{ "ml-6": activeView() !== "chooser" }}>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-[14px] font-medium text-dls-text tracking-tight truncate">
|
||||
<Show when={activeView() === "chooser"}>{title()}</Show>
|
||||
<Show when={activeView() === "template"}>Share a template</Show>
|
||||
<Show when={activeView() === "access"}>Access workspace remotely</Show>
|
||||
</h2>
|
||||
<div class="mt-0.5 text-[12px] text-gray-10 truncate">{props.workspaceName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-6 flex-1 overflow-y-auto scrollbar-hide">
|
||||
<Show when={activeView() === "chooser"}>
|
||||
<div class="space-y-2 pt-4 animate-in fade-in slide-in-from-bottom-3 duration-300">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveView("template")}
|
||||
class="w-full text-left rounded-xl p-3 hover:bg-dls-hover transition-colors group flex items-start gap-3"
|
||||
>
|
||||
<div class="mt-0.5 text-gray-10 group-hover:text-gray-12 transition-colors shrink-0">
|
||||
<Rocket size={18} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[13px] font-medium text-dls-text">Share a template</h3>
|
||||
<p class="text-[12px] text-gray-10 leading-snug mt-0.5 pr-4">
|
||||
Share your setup and defaults so someone else can start from the same environment.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveView("access")}
|
||||
class="w-full text-left rounded-xl p-3 hover:bg-dls-hover transition-colors group flex items-start gap-3"
|
||||
>
|
||||
<div class="mt-0.5 text-gray-10 group-hover:text-gray-12 transition-colors shrink-0">
|
||||
<MonitorUp size={18} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[13px] font-medium text-dls-text">Access workspace remotely</h3>
|
||||
<p class="text-[12px] text-gray-10 leading-snug mt-0.5 pr-4">
|
||||
Copy the connection details needed to reach this live workspace from another machine or messaging surface.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "template"}>
|
||||
<div class="space-y-6 pt-4 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div class="text-[12px] text-gray-10">
|
||||
Share a reusable setup without granting live access to this running workspace.
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<FolderCode size={16} class="text-gray-9 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[13px] font-medium text-dls-text">Workspace template</h3>
|
||||
<p class="text-[12px] text-gray-10 leading-tight mt-0.5">Share the core setup and workspace defaults.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.shareWorkspaceProfileError?.trim()}>
|
||||
<div class="rounded-md border border-red-6/40 bg-red-3/30 px-3 py-2 mb-2 text-[12px] text-red-11">
|
||||
{props.shareWorkspaceProfileError}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.shareWorkspaceProfileDisabledReason?.trim()}>
|
||||
<div class="text-[12px] text-gray-9 mb-2">{props.shareWorkspaceProfileDisabledReason}</div>
|
||||
</Show>
|
||||
|
||||
{renderGeneratedLink(
|
||||
props.shareWorkspaceProfileUrl,
|
||||
"share-workspace-profile",
|
||||
props.onShareWorkspaceProfile,
|
||||
props.shareWorkspaceProfileBusy,
|
||||
"Create Template Link",
|
||||
"Regenerate Link",
|
||||
props.onShareWorkspaceProfile,
|
||||
props.shareWorkspaceProfileDisabledReason,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "access"}>
|
||||
<div class="space-y-6 pt-4 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div class="rounded-md border border-amber-6/40 bg-amber-3/30 px-3 py-2 text-[12px] text-amber-11 flex items-start gap-2">
|
||||
<span class="mt-0.5">⚠️</span>
|
||||
<span class="leading-relaxed">
|
||||
<Show
|
||||
when={props.remoteAccess}
|
||||
fallback={
|
||||
"Share with trusted people only. These credentials grant live access to this workspace."
|
||||
}
|
||||
>
|
||||
These credentials grant live access to this workspace. Sharing this workspace remotely may allow anyone with access to your network to control your worker.
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.remoteAccess}>
|
||||
{(remoteAccess) => {
|
||||
const hasPendingChange = () =>
|
||||
remoteAccessEnabled() !== remoteAccess().enabled;
|
||||
return (
|
||||
<div class="rounded-[20px] border border-dls-border bg-gray-2/30 px-4 py-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-medium text-dls-text">Remote access</h3>
|
||||
<p class="text-[12px] text-gray-10 mt-0.5 leading-relaxed">
|
||||
Off by default. Turn this on only when you want this worker reachable from another machine.
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
checked={remoteAccessEnabled()}
|
||||
onInput={(event) =>
|
||||
setRemoteAccessEnabled(event.currentTarget.checked)}
|
||||
disabled={remoteAccess().busy}
|
||||
/>
|
||||
<div class="w-11 h-6 rounded-full bg-gray-6 transition-colors peer-checked:bg-amber-8 peer-disabled:opacity-50 after:absolute after:top-[2px] after:left-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-transform peer-checked:after:translate-x-5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-[12px] text-gray-10">
|
||||
{remoteAccess().enabled
|
||||
? "Remote access is currently enabled."
|
||||
: "Remote access is currently disabled."}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remoteAccess().onSave(remoteAccessEnabled())}
|
||||
disabled={remoteAccess().busy || !hasPendingChange()}
|
||||
class="px-3 py-1.5 bg-gray-2 hover:bg-gray-3 rounded-md text-[12px] font-medium text-dls-text transition-colors disabled:opacity-50"
|
||||
>
|
||||
{remoteAccess().busy ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={remoteAccess().error?.trim()}>
|
||||
<div class="rounded-md border border-red-6/40 bg-red-3/30 px-3 py-2 text-[12px] text-red-11">
|
||||
{remoteAccess().error}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-[20px] border border-dls-border bg-gray-2/30 px-3 py-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MessageSquare size={16} class="text-gray-9 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-[13px] font-medium text-dls-text">Connect messaging</h4>
|
||||
<p class="text-[12px] text-gray-10 mt-0.5 truncate">Use this workspace from Slack, Telegram, and others.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => props.onOpenBots?.()}
|
||||
disabled={!props.onOpenBots}
|
||||
class="px-3 py-1.5 bg-gray-2 hover:bg-gray-3 rounded-md text-[12px] font-medium text-dls-text transition-colors disabled:opacity-50"
|
||||
>
|
||||
Setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Show when={primaryAccessFields().length > 0} fallback={
|
||||
<div class="rounded-[20px] border border-dls-border bg-gray-2/20 px-4 py-4 text-[12px] text-gray-10 leading-relaxed">
|
||||
Enable remote access and click Save to restart the worker and reveal the live connection details for this workspace.
|
||||
</div>
|
||||
}>
|
||||
<For each={primaryAccessFields()}>
|
||||
{(field, index) => renderCredentialField(field, index, "primary")}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={collaboratorField()}>
|
||||
{(field) => (
|
||||
<div class="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-dls-border/70 bg-gray-2/30 px-3 py-1.5 text-[11px] font-medium text-gray-10 transition-colors hover:border-dls-border hover:bg-gray-2/60 hover:text-gray-11"
|
||||
onClick={() => setCollaboratorExpanded((value) => !value)}
|
||||
aria-expanded={collaboratorExpanded()}
|
||||
>
|
||||
<span>Optional collaborator access</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
class={`shrink-0 transition-transform ${collaboratorExpanded() ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<Show when={collaboratorExpanded()}>
|
||||
<div class="mt-3 rounded-[20px] border border-dls-border bg-gray-2/30 px-3 py-3">
|
||||
<div class="mb-2 text-[11px] text-gray-9">Routine access without permission approvals.</div>
|
||||
{renderCredentialField(field(), () => 0, "collaborator")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={note()}>
|
||||
<div class="px-1 text-[11px] text-gray-9">{note()}</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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> | 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(() => {
|
||||
|
||||
@@ -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> | 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 (
|
||||
<div class="border-t border-dls-border bg-dls-surface">
|
||||
<div class="flex h-12 items-center justify-between gap-3 px-4 md:px-6 text-[12px] text-dls-secondary">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span class="relative flex h-2.5 w-2.5 shrink-0 items-center justify-center">
|
||||
<Show when={statusCopy().pulse}>
|
||||
<span class={`absolute inline-flex h-full w-full rounded-full ${statusCopy().pingClass}`} />
|
||||
</Show>
|
||||
<span class={`relative inline-flex h-2.5 w-2.5 rounded-full ${statusCopy().dotClass}`} />
|
||||
</span>
|
||||
<span class="shrink-0 font-medium text-dls-text">{statusCopy().label}</span>
|
||||
<span class="truncate text-dls-secondary">{statusCopy().detail}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-dls-secondary transition-colors hover:bg-dls-hover hover:text-dls-text"
|
||||
onClick={props.onSendFeedback}
|
||||
title="Send feedback"
|
||||
aria-label="Send feedback"
|
||||
>
|
||||
<MessageCircle class="h-4 w-4" />
|
||||
<span class="text-[11px] font-medium">Feedback</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-dls-secondary transition-colors hover:bg-dls-hover hover:text-dls-text"
|
||||
onClick={props.onOpenSettings}
|
||||
title={props.settingsOpen ? "Back to previous screen" : "Settings"}
|
||||
aria-label={props.settingsOpen ? "Back to previous screen" : "Settings"}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Show when={props.open}>
|
||||
<div class="w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-white/70 bg-white/92 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.28)] backdrop-blur-xl">
|
||||
<div class="w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-dls-border bg-dls-surface shadow-[var(--dls-shell-shadow)] backdrop-blur-xl animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div class="flex items-start gap-3 px-4 py-4">
|
||||
<div
|
||||
class={`mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border ${
|
||||
tone() === "success"
|
||||
? "border-emerald-6/40 bg-emerald-4/80 text-emerald-11"
|
||||
: tone() === "warning"
|
||||
? "border-amber-6/40 bg-amber-4/80 text-amber-11"
|
||||
: tone() === "error"
|
||||
? "border-red-6/40 bg-red-4/80 text-red-11"
|
||||
: "border-sky-6/40 bg-sky-4/80 text-sky-11"
|
||||
}`.trim()}
|
||||
>
|
||||
<Show when={tone() === "success"} fallback={<Info size={18} />}>
|
||||
<Show
|
||||
when={tone() === "success"}
|
||||
fallback={
|
||||
tone() === "warning" ? (
|
||||
<AlertTriangle size={18} />
|
||||
) : tone() === "error" ? (
|
||||
<CircleAlert size={18} />
|
||||
) : (
|
||||
<Info size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
<History size={18} />,
|
||||
showSelection() && props.tab === "scheduled",
|
||||
showSelection() && props.settingsTab === "automations",
|
||||
props.onOpenAutomations,
|
||||
)}
|
||||
{sidebarButton(
|
||||
"Skills",
|
||||
<Zap size={18} />,
|
||||
showSelection() && props.tab === "skills",
|
||||
showSelection() && props.settingsTab === "skills",
|
||||
props.onOpenSkills,
|
||||
)}
|
||||
{sidebarButton(
|
||||
"Extensions",
|
||||
<Box size={18} />,
|
||||
showSelection() && (props.tab === "mcp" || props.tab === "plugins"),
|
||||
showSelection() && props.settingsTab === "extensions",
|
||||
props.onOpenExtensions,
|
||||
)}
|
||||
{sidebarButton(
|
||||
"Messaging",
|
||||
<MessageCircle size={18} />,
|
||||
showSelection() && props.tab === "identities",
|
||||
showSelection() && props.settingsTab === "messaging",
|
||||
props.onOpenMessaging,
|
||||
)}
|
||||
<Show when={props.developerMode}>
|
||||
{sidebarButton(
|
||||
"Advanced",
|
||||
<SlidersHorizontal size={18} />,
|
||||
showSelection() && props.tab === "config",
|
||||
showSelection() && props.settingsTab === "advanced",
|
||||
props.onOpenAdvanced,
|
||||
)}
|
||||
</Show>
|
||||
@@ -140,7 +140,7 @@ export default function WorkspaceRightSidebar(props: Props) {
|
||||
<InboxPanel
|
||||
id={props.inboxId}
|
||||
client={props.openworkServerClient}
|
||||
workspaceId={props.openworkServerWorkspaceId}
|
||||
workspaceId={props.runtimeWorkspaceId}
|
||||
onToast={props.onInboxToast}
|
||||
/>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@ export default function WorkspaceRightSidebar(props: Props) {
|
||||
{sidebarButton(
|
||||
"Settings",
|
||||
<Settings size={18} />,
|
||||
showSelection() && (props.tab === "settings" || props.tab === "config" || props.tab === "identities"),
|
||||
showSelection() && props.settingsTab === "general",
|
||||
props.onOpenSettings,
|
||||
)}
|
||||
</div>
|
||||
|
||||
36
apps/app/src/app/connections/mcp-view.tsx
Normal file
36
apps/app/src/app/connections/mcp-view.tsx
Normal file
@@ -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 (
|
||||
<PresentationalMcpView
|
||||
showHeader={props.showHeader}
|
||||
busy={props.busy}
|
||||
selectedWorkspaceRoot={props.selectedWorkspaceRoot}
|
||||
isRemoteWorkspace={props.isRemoteWorkspace}
|
||||
readConfigFile={connections.readMcpConfigFile}
|
||||
mcpServers={connections.mcpServers()}
|
||||
mcpStatus={connections.mcpStatus()}
|
||||
mcpLastUpdatedAt={connections.mcpLastUpdatedAt()}
|
||||
mcpStatuses={connections.mcpStatuses()}
|
||||
mcpConnectingName={connections.mcpConnectingName()}
|
||||
selectedMcp={connections.selectedMcp()}
|
||||
setSelectedMcp={connections.setSelectedMcp}
|
||||
quickConnect={connections.quickConnect}
|
||||
connectMcp={connections.connectMcp}
|
||||
authorizeMcp={connections.authorizeMcp}
|
||||
logoutMcpAuth={connections.logoutMcpAuth}
|
||||
removeMcp={connections.removeMcp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
apps/app/src/app/connections/modals.tsx
Normal file
38
apps/app/src/app/connections/modals.tsx
Normal file
@@ -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<void>;
|
||||
onReloadEngine: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function ConnectionsModals(props: ConnectionsModalsProps) {
|
||||
const connections = useConnections();
|
||||
|
||||
return (
|
||||
<McpAuthModal
|
||||
open={connections.mcpAuthModalOpen()}
|
||||
client={props.client}
|
||||
entry={connections.mcpAuthEntry()}
|
||||
projectDir={props.projectDir}
|
||||
language={props.language}
|
||||
reloadRequired={connections.mcpAuthNeedsReload()}
|
||||
reloadBlocked={props.reloadBlocked}
|
||||
activeSessions={props.activeSessions}
|
||||
isRemoteWorkspace={props.isRemoteWorkspace}
|
||||
onForceStopSession={props.onForceStopSession}
|
||||
onClose={connections.closeMcpAuthModal}
|
||||
onComplete={connections.completeMcpAuthModal}
|
||||
onReloadEngine={props.onReloadEngine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
apps/app/src/app/connections/openwork-server-provider.tsx
Normal file
21
apps/app/src/app/connections/openwork-server-provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import type { OpenworkServerStore } from "./openwork-server-store";
|
||||
|
||||
const OpenworkServerContext = createContext<OpenworkServerStore>();
|
||||
|
||||
export function OpenworkServerProvider(props: ParentProps<{ store: OpenworkServerStore }>) {
|
||||
return (
|
||||
<OpenworkServerContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</OpenworkServerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOpenworkServer() {
|
||||
const context = useContext(OpenworkServerContext);
|
||||
if (!context) {
|
||||
throw new Error("useOpenworkServer must be used within an OpenworkServerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
633
apps/app/src/app/connections/openwork-server-store.ts
Normal file
633
apps/app/src/app/connections/openwork-server-store.ts
Normal file
@@ -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<typeof createOpenworkServerStore>;
|
||||
|
||||
type RemoteWorkspaceInput = {
|
||||
openworkHostUrl: string;
|
||||
openworkToken?: string | null;
|
||||
directory?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export function createOpenworkServerStore(options: {
|
||||
startupPreference: Accessor<StartupPreference | null>;
|
||||
documentVisible: Accessor<boolean>;
|
||||
developerMode: Accessor<boolean>;
|
||||
runtimeWorkspaceId: Accessor<string | null>;
|
||||
activeClient: Accessor<unknown | null>;
|
||||
selectedWorkspaceDisplay: Accessor<WorkspaceDisplay>;
|
||||
restartLocalServer: () => Promise<boolean>;
|
||||
createRemoteWorkspaceFlow: (input: RemoteWorkspaceInput) => Promise<boolean>;
|
||||
}) {
|
||||
const [openworkServerSettings, setOpenworkServerSettings] = createSignal<OpenworkServerSettings>({});
|
||||
const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false);
|
||||
const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal<string | null>(null);
|
||||
const [openworkServerUrl, setOpenworkServerUrl] = createSignal("");
|
||||
const [openworkServerStatus, setOpenworkServerStatus] = createSignal<OpenworkServerStatus>("disconnected");
|
||||
const [openworkServerCapabilities, setOpenworkServerCapabilities] =
|
||||
createSignal<OpenworkServerCapabilities | null>(null);
|
||||
const [, setOpenworkServerCheckedAt] = createSignal<number | null>(null);
|
||||
const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal<OpenworkServerInfo | null>(null);
|
||||
const [openworkServerDiagnostics, setOpenworkServerDiagnostics] =
|
||||
createSignal<OpenworkServerDiagnostics | null>(null);
|
||||
const [openworkReconnectBusy, setOpenworkReconnectBusy] = createSignal(false);
|
||||
const [opencodeRouterInfoState, setOpenCodeRouterInfoState] =
|
||||
createSignal<OpenCodeRouterInfo | null>(null);
|
||||
const [orchestratorStatusState, setOrchestratorStatusState] =
|
||||
createSignal<OrchestratorStatus | null>(null);
|
||||
const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal<OpenworkAuditEntry[]>([]);
|
||||
const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle");
|
||||
const [openworkAuditError, setOpenworkAuditError] = createSignal<string | null>(null);
|
||||
const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal<string | null>(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<OpenworkServerClient | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
21
apps/app/src/app/connections/provider.tsx
Normal file
21
apps/app/src/app/connections/provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import type { ConnectionsStore } from "./store";
|
||||
|
||||
const ConnectionsContext = createContext<ConnectionsStore>();
|
||||
|
||||
export function ConnectionsProvider(props: ParentProps<{ store: ConnectionsStore }>) {
|
||||
return (
|
||||
<ConnectionsContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</ConnectionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConnections() {
|
||||
const context = useContext(ConnectionsContext);
|
||||
if (!context) {
|
||||
throw new Error("useConnections must be used within a ConnectionsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
628
apps/app/src/app/connections/store.ts
Normal file
628
apps/app/src/app/connections/store.ts
Normal file
@@ -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<typeof createConnectionsStore>;
|
||||
|
||||
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<string | null | undefined>;
|
||||
setProjectDir?: (value: string) => void;
|
||||
developerMode: () => boolean;
|
||||
markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void;
|
||||
}) {
|
||||
const translate = (key: string) => t(key, currentLocale());
|
||||
|
||||
const [mcpServers, setMcpServers] = createSignal<McpServerEntry[]>([]);
|
||||
const [mcpStatus, setMcpStatus] = createSignal<string | null>(null);
|
||||
const [mcpLastUpdatedAt, setMcpLastUpdatedAt] = createSignal<number | null>(null);
|
||||
const [mcpStatuses, setMcpStatuses] = createSignal<McpStatusMap>({});
|
||||
const [mcpConnectingName, setMcpConnectingName] = createSignal<string | null>(null);
|
||||
const [selectedMcp, setSelectedMcp] = createSignal<string | null>(null);
|
||||
|
||||
const [mcpAuthModalOpen, setMcpAuthModalOpen] = createSignal(false);
|
||||
const [mcpAuthEntry, setMcpAuthEntry] = createSignal<McpDirectoryInfo | null>(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<OpencodeConfigFile | null> => {
|
||||
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<string, string> | undefined;
|
||||
|
||||
const mcpEntryConfig: Record<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>) ?? {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
267
apps/app/src/app/context/automations.ts
Normal file
267
apps/app/src/app/context/automations.ts
Normal file
@@ -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<typeof createAutomationsStore>;
|
||||
|
||||
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<ScheduledJob[]>([]);
|
||||
const [scheduledJobsStatus, setScheduledJobsStatus] = createSignal<string | null>(null);
|
||||
const [scheduledJobsBusy, setScheduledJobsBusy] = createSignal(false);
|
||||
const [scheduledJobsUpdatedAt, setScheduledJobsUpdatedAt] = createSignal<number | null>(null);
|
||||
const [pendingRefreshContextKey, setPendingRefreshContextKey] = createSignal<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof createExtensionsStore>;
|
||||
|
||||
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<SkillCard[]>([]);
|
||||
const [skillsStatus, setSkillsStatus] = createSignal<string | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<LocalUIState>({
|
||||
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,
|
||||
|
||||
1292
apps/app/src/app/context/model-config.ts
Normal file
1292
apps/app/src/app/context/model-config.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
apps/app/src/app/context/providers/index.ts
Normal file
3
apps/app/src/app/context/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createProvidersStore } from "./store";
|
||||
export type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store";
|
||||
export { default as ProviderAuthModal } from "./provider-auth-modal";
|
||||
@@ -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)}
|
||||
>
|
||||
<div class="flex-shrink-0 w-8 h-8 mt-0.5 rounded-full bg-gray-2 border border-gray-5/60 shadow-sm flex items-center justify-center text-[13px] font-medium overflow-hidden">
|
||||
<Show when={entry.id === "openai"}>
|
||||
<div class="w-full h-full bg-white flex items-center justify-center text-black">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.073zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.0993 3.8558L12.5967 8.3829V6.0505a.0757.0757 0 0 1 .0332-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66v5.5826l-.142-.0804-4.7828-2.7582a.7712.7712 0 0 0-.7753 0zM13.2599 1.562a4.4755 4.4755 0 0 1 2.8669 1.0408l-.1419.0804-4.7784 2.7582a.7948.7948 0 0 0-.3927.6813v6.7369l-2.02-1.1686a.071.071 0 0 1-.0379-.052V6.0558A4.504 4.504 0 0 1 13.2599 1.562zm-3.0042 14.1554-2.8214-1.6258V10.84l2.8214-1.6258 2.8214 1.6258v3.2516l-2.8214 1.6258z"/></svg>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={entry.id === "anthropic"}>
|
||||
<div class="w-full h-full bg-[#F7F4EE] flex items-center justify-center text-[#191919]">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"><path d="M17.373 20.301h5.086l-9.09-15.637h-2.618l-9.213 15.637h5.086l1.644-2.821h8.423l1.082 2.821Zm-3.155-8.152H10.15l2.008-3.447h.03l2.03 3.447Z"/></svg>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={entry.id !== "openai" && entry.id !== "anthropic"}>
|
||||
<div class="w-full h-full bg-gray-3/80 flex items-center justify-center text-gray-11">
|
||||
{entry.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-5/60 bg-gray-2 shadow-sm overflow-hidden">
|
||||
<ProviderIcon providerId={entry.id} size={18} class="text-gray-12" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -712,7 +696,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-9 font-mono truncate mt-0.5 opacity-60 group-hover:opacity-80 transition-opacity">{entry.id}</div>
|
||||
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<For each={entry.methods}>
|
||||
{(method) => (
|
||||
@@ -862,7 +846,6 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
|
||||
const url = oauthSession()?.authorization.url ?? "";
|
||||
void openOauthUrl(url);
|
||||
}}
|
||||
disabled={actionDisabled()}
|
||||
>
|
||||
Open browser again
|
||||
</Button>
|
||||
@@ -914,10 +897,19 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-9">
|
||||
<Loader2 size={14} class={props.submitting || pollingBusy() || oauthAutoBusy() ? "animate-spin" : ""} />
|
||||
<span>Checking connection status automatically...</span>
|
||||
</div>
|
||||
<Show
|
||||
when={isOpenAiHeadlessSession() && !oauthBrowserOpened()}
|
||||
fallback={
|
||||
<div class="flex items-center gap-2 text-xs text-gray-9">
|
||||
<Loader2 size={14} class={props.submitting || pollingBusy() || oauthAutoBusy() ? "animate-spin" : ""} />
|
||||
<span>Checking connection status automatically...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-9">
|
||||
<span>Authorization checks will start after you click Open Browser.</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -925,7 +917,6 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
|
||||
const url = oauthSession()?.authorization.url ?? "";
|
||||
void openOauthUrl(url);
|
||||
}}
|
||||
disabled={actionDisabled()}
|
||||
>
|
||||
{isOpenAiHeadlessSession()
|
||||
? oauthBrowserOpened()
|
||||
562
apps/app/src/app/context/providers/store.ts
Normal file
562
apps/app/src/app/context/providers/store.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { createMemo, createSignal, type Accessor } from "solid-js";
|
||||
|
||||
import type { ProviderAuthAuthorization, ProviderListResponse } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { unwrap, waitForHealthy } from "../../lib/opencode";
|
||||
import type { Client, ProviderListItem, WorkspaceDisplay } from "../../types";
|
||||
import { safeStringify } from "../../utils";
|
||||
import { filterProviderList, mapConfigProvidersToList } from "../../utils/providers";
|
||||
|
||||
type ProviderReturnFocusTarget = "none" | "composer";
|
||||
|
||||
export type ProviderAuthMethod = {
|
||||
type: "oauth" | "api";
|
||||
label: string;
|
||||
methodIndex?: number;
|
||||
};
|
||||
|
||||
export type ProviderOAuthStartResult = {
|
||||
methodIndex: number;
|
||||
authorization: ProviderAuthAuthorization;
|
||||
};
|
||||
|
||||
type CreateProvidersStoreOptions = {
|
||||
client: Accessor<Client | null>;
|
||||
providers: Accessor<ProviderListItem[]>;
|
||||
providerDefaults: Accessor<Record<string, string>>;
|
||||
providerConnectedIds: Accessor<string[]>;
|
||||
disabledProviders: Accessor<string[]>;
|
||||
selectedWorkspaceDisplay: Accessor<WorkspaceDisplay>;
|
||||
setProviders: (value: ProviderListItem[]) => void;
|
||||
setProviderDefaults: (value: Record<string, string>) => void;
|
||||
setProviderConnectedIds: (value: string[]) => void;
|
||||
setDisabledProviders: (value: string[]) => void;
|
||||
markOpencodeConfigReloadRequired: () => void;
|
||||
focusPromptSoon?: () => void;
|
||||
};
|
||||
|
||||
export function createProvidersStore(options: CreateProvidersStoreOptions) {
|
||||
const [providerAuthModalOpen, setProviderAuthModalOpen] = createSignal(false);
|
||||
const [providerAuthBusy, setProviderAuthBusy] = createSignal(false);
|
||||
const [providerAuthError, setProviderAuthError] = createSignal<string | null>(null);
|
||||
const [providerAuthMethods, setProviderAuthMethods] = createSignal<Record<string, ProviderAuthMethod[]>>({});
|
||||
const [providerAuthPreferredProviderId, setProviderAuthPreferredProviderId] = createSignal<string | null>(null);
|
||||
const [providerAuthReturnFocusTarget, setProviderAuthReturnFocusTarget] =
|
||||
createSignal<ProviderReturnFocusTarget>("none");
|
||||
|
||||
const providerAuthWorkerType = createMemo<"local" | "remote">(() =>
|
||||
options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local",
|
||||
);
|
||||
|
||||
const applyProviderListState = (value: ProviderListResponse) => {
|
||||
options.setProviders(value.all ?? []);
|
||||
options.setProviderDefaults(value.default ?? {});
|
||||
options.setProviderConnectedIds(value.connected ?? []);
|
||||
};
|
||||
|
||||
const removeProviderFromState = (providerId: string) => {
|
||||
const resolved = providerId.trim();
|
||||
if (!resolved) return;
|
||||
options.setProviders(options.providers().filter((provider) => provider.id !== resolved));
|
||||
options.setProviderConnectedIds(options.providerConnectedIds().filter((id) => id !== resolved));
|
||||
options.setProviderDefaults(
|
||||
Object.fromEntries(
|
||||
Object.entries(options.providerDefaults()).filter(([id]) => id !== resolved),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
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<string, unknown>[] = [];
|
||||
const root = error && typeof error === "object" ? (error as Record<string, unknown>) : null;
|
||||
if (root) {
|
||||
records.push(root);
|
||||
if (root.data && typeof root.data === "object") records.push(root.data as Record<string, unknown>);
|
||||
if (root.cause && typeof root.cause === "object") {
|
||||
const cause = root.cause as Record<string, unknown>;
|
||||
records.push(cause);
|
||||
if (cause.data && typeof cause.data === "object") records.push(cause.data as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const buildProviderAuthMethods = (
|
||||
methods: Record<string, ProviderAuthMethod[]>,
|
||||
availableProviders: ProviderListItem[],
|
||||
workerType: "local" | "remote",
|
||||
) => {
|
||||
const merged = Object.fromEntries(
|
||||
Object.entries(methods ?? {}).map(([id, providerMethods]) => [
|
||||
id,
|
||||
(providerMethods ?? []).map((method, methodIndex) => ({
|
||||
...method,
|
||||
methodIndex,
|
||||
})),
|
||||
]),
|
||||
) as Record<string, ProviderAuthMethod[]>;
|
||||
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 = options.client();
|
||||
if (!c) {
|
||||
throw new Error("Not connected to a server");
|
||||
}
|
||||
const methods = unwrap(await c.provider.auth());
|
||||
return buildProviderAuthMethods(
|
||||
methods as Record<string, ProviderAuthMethod[]>,
|
||||
options.providers(),
|
||||
workerType,
|
||||
);
|
||||
};
|
||||
|
||||
async function startProviderAuth(
|
||||
providerId?: string,
|
||||
methodIndex?: number,
|
||||
): Promise<ProviderOAuthStartResult> {
|
||||
setProviderAuthError(null);
|
||||
const c = options.client();
|
||||
if (!c) {
|
||||
throw new Error("Not connected to a server");
|
||||
}
|
||||
try {
|
||||
const cachedMethods = providerAuthMethods();
|
||||
const authMethods = Object.keys(cachedMethods).length
|
||||
? cachedMethods
|
||||
: await loadProviderAuthMethods(providerAuthWorkerType());
|
||||
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(optionsArg?: { dispose?: boolean }) {
|
||||
const c = options.client();
|
||||
if (!c) return null;
|
||||
|
||||
if (optionsArg?.dispose) {
|
||||
try {
|
||||
unwrap(await c.instance.dispose());
|
||||
} catch {
|
||||
// ignore dispose failures and try reading current state anyway
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForHealthy(options.client() ?? c, { timeoutMs: 8_000, pollMs: 250 });
|
||||
} catch {
|
||||
// ignore health wait failures and still attempt provider reads
|
||||
}
|
||||
}
|
||||
|
||||
const activeClient = options.client() ?? c;
|
||||
let disabledProviders = options.disabledProviders() ?? [];
|
||||
try {
|
||||
const config = unwrap(await activeClient.config.get());
|
||||
disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : [];
|
||||
options.setDisabledProviders(disabledProviders);
|
||||
} catch {
|
||||
// ignore config read failures and continue with current store state
|
||||
}
|
||||
try {
|
||||
const updated = filterProviderList(
|
||||
unwrap(await activeClient.provider.list()),
|
||||
disabledProviders,
|
||||
);
|
||||
applyProviderListState(updated);
|
||||
return updated;
|
||||
} catch {
|
||||
try {
|
||||
const fallback = unwrap(await activeClient.config.providers());
|
||||
const mapped = mapConfigProvidersToList(fallback.providers);
|
||||
const next = filterProviderList(
|
||||
{
|
||||
all: mapped,
|
||||
connected: options.providerConnectedIds().filter((id) => mapped.some((provider) => provider.id === id)),
|
||||
default: fallback.default,
|
||||
},
|
||||
disabledProviders,
|
||||
);
|
||||
applyProviderListState(next);
|
||||
return next;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function completeProviderAuthOAuth(providerId: string, methodIndex: number, code?: string) {
|
||||
setProviderAuthError(null);
|
||||
const c = options.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 = options.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 = options.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 provider = options.providers().find((entry) => entry.id === resolved) as
|
||||
| (ProviderListItem & { source?: string })
|
||||
| undefined;
|
||||
const canDisableProvider =
|
||||
provider?.source === "config" || provider?.source === "custom";
|
||||
|
||||
const removeProviderAuth = async () => {
|
||||
const authClient = c.auth as unknown as {
|
||||
remove?: (options: { providerID: string }) => Promise<unknown>;
|
||||
set?: (options: { providerID: string; auth: unknown }) => Promise<unknown>;
|
||||
};
|
||||
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<unknown> } })
|
||||
.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.");
|
||||
};
|
||||
|
||||
const disableProvider = async () => {
|
||||
const config = unwrap(await c.config.get());
|
||||
const disabledProviders = Array.isArray(config.disabled_providers)
|
||||
? config.disabled_providers
|
||||
: [];
|
||||
if (disabledProviders.includes(resolved)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = [...disabledProviders, resolved];
|
||||
options.setDisabledProviders(next);
|
||||
try {
|
||||
const result = await c.config.update({
|
||||
config: {
|
||||
...config,
|
||||
disabled_providers: next,
|
||||
},
|
||||
});
|
||||
assertNoClientError(result);
|
||||
options.markOpencodeConfigReloadRequired();
|
||||
} catch (error) {
|
||||
options.setDisabledProviders(disabledProviders);
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
await removeProviderAuth();
|
||||
let updated = await refreshProviders({ dispose: true });
|
||||
if (
|
||||
canDisableProvider &&
|
||||
Array.isArray(updated?.connected) &&
|
||||
updated.connected.includes(resolved)
|
||||
) {
|
||||
const disabled = await disableProvider();
|
||||
if (disabled && updated) {
|
||||
updated = filterProviderList(updated, options.disabledProviders() ?? []);
|
||||
applyProviderListState(updated);
|
||||
}
|
||||
if (!Array.isArray(updated?.connected) || !updated.connected.includes(resolved)) {
|
||||
return disabled
|
||||
? `Disconnected ${resolved} and disabled it in OpenCode config.`
|
||||
: `Disconnected ${resolved}.`;
|
||||
}
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
removeProviderFromState(resolved);
|
||||
return `Disconnected ${resolved}`;
|
||||
} catch (error) {
|
||||
const message = describeProviderError(error, "Failed to disconnect provider");
|
||||
setProviderAuthError(message);
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openProviderAuthModal(optionsArg?: {
|
||||
returnFocusTarget?: ProviderReturnFocusTarget;
|
||||
preferredProviderId?: string;
|
||||
}) {
|
||||
setProviderAuthReturnFocusTarget(optionsArg?.returnFocusTarget ?? "none");
|
||||
setProviderAuthPreferredProviderId(optionsArg?.preferredProviderId?.trim() || null);
|
||||
setProviderAuthBusy(true);
|
||||
setProviderAuthError(null);
|
||||
try {
|
||||
const methods = await loadProviderAuthMethods(providerAuthWorkerType());
|
||||
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(optionsArg?: { restorePromptFocus?: boolean }) {
|
||||
const shouldFocusPrompt =
|
||||
optionsArg?.restorePromptFocus ??
|
||||
providerAuthReturnFocusTarget() === "composer";
|
||||
setProviderAuthModalOpen(false);
|
||||
setProviderAuthError(null);
|
||||
setProviderAuthPreferredProviderId(null);
|
||||
setProviderAuthReturnFocusTarget("none");
|
||||
if (shouldFocusPrompt) {
|
||||
options.focusPromptSoon?.();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providerAuthModalOpen,
|
||||
providerAuthBusy,
|
||||
providerAuthError,
|
||||
providerAuthMethods,
|
||||
providerAuthPreferredProviderId,
|
||||
providerAuthWorkerType,
|
||||
startProviderAuth,
|
||||
refreshProviders,
|
||||
completeProviderAuthOAuth,
|
||||
submitProviderApiKey,
|
||||
disconnectProvider,
|
||||
openProviderAuthModal,
|
||||
closeProviderAuthModal,
|
||||
};
|
||||
}
|
||||
@@ -12,13 +12,16 @@ import type {
|
||||
PendingPermission,
|
||||
PendingQuestion,
|
||||
PlaceholderAssistantMessage,
|
||||
PlaceholderMessageInfo,
|
||||
ReloadReason,
|
||||
ReloadTrigger,
|
||||
SessionCompactionState,
|
||||
SessionErrorTurn,
|
||||
TodoItem,
|
||||
} from "../types";
|
||||
import {
|
||||
addOpencodeCacheHint,
|
||||
isVisibleTextPart,
|
||||
modelFromUserMessage,
|
||||
normalizeDirectoryPath,
|
||||
normalizeEvent,
|
||||
@@ -38,6 +41,10 @@ export type SessionModelState = {
|
||||
|
||||
export type SessionStore = ReturnType<typeof createSessionStore>;
|
||||
|
||||
type BlueprintSeedMessage = { role?: "assistant" | "user" | null; text?: string | null };
|
||||
|
||||
const SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX = "blueprint-seed:";
|
||||
|
||||
type StoreState = {
|
||||
sessions: Session[];
|
||||
sessionInfoById: Record<string, Session>;
|
||||
@@ -49,6 +56,7 @@ type StoreState = {
|
||||
pendingPermissions: PendingPermission[];
|
||||
pendingQuestions: PendingQuestion[];
|
||||
events: OpencodeEvent[];
|
||||
sessionCompaction: Record<string, SessionCompactionState>;
|
||||
};
|
||||
|
||||
const sortById = <T extends { id: string }>(list: T[]) =>
|
||||
@@ -145,9 +153,10 @@ const appendPartDelta = (list: Part[], partID: string, field: string, delta: str
|
||||
|
||||
export function createSessionStore(options: {
|
||||
client: () => Client | null;
|
||||
activeWorkspaceRoot: () => string;
|
||||
selectedWorkspaceRoot: () => string;
|
||||
selectedSessionId: () => string | null;
|
||||
setSelectedSessionId: (id: string | null) => void;
|
||||
setPrompt: (value: string) => void;
|
||||
sessionModelState: () => SessionModelState;
|
||||
setSessionModelState: (updater: (current: SessionModelState) => SessionModelState) => SessionModelState;
|
||||
lastUserModelFromMessages: (messages: MessageWithParts[]) => ModelRef | null;
|
||||
@@ -198,14 +207,19 @@ export function createSessionStore(options: {
|
||||
pendingPermissions: [],
|
||||
pendingQuestions: [],
|
||||
events: [],
|
||||
sessionCompaction: {},
|
||||
});
|
||||
const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false);
|
||||
const [blueprintSeedMessagesBySessionId, setBlueprintSeedMessagesBySessionId] = createSignal<
|
||||
Record<string, BlueprintSeedMessage[]>
|
||||
>({});
|
||||
const [messageLimitBySession, setMessageLimitBySession] = createSignal<Record<string, number>>({});
|
||||
const [messageCompleteBySession, setMessageCompleteBySession] = createSignal<Record<string, boolean>>({});
|
||||
const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal<Record<string, boolean>>({});
|
||||
const [loadedScopeRoot, setLoadedScopeRoot] = createSignal("");
|
||||
const reloadDetectionSet = new Set<string>();
|
||||
const invalidToolDetectionSet = new Set<string>();
|
||||
const pendingCompactionModeBySession = new Map<string, "auto" | "manual">();
|
||||
const syntheticContinueEventTimesBySession = new Map<string, number[]>();
|
||||
const syntheticTaskSummaryEventTimesBySession = new Map<string, number[]>();
|
||||
const syntheticContinueLoopLastWarnAtBySession = new Map<string, number>();
|
||||
@@ -350,7 +364,7 @@ export function createSessionStore(options: {
|
||||
if (!options.markReloadRequired) return;
|
||||
if (!part?.id || !part.messageID) return;
|
||||
|
||||
const root = normalizeDirectoryPath(options.activeWorkspaceRoot());
|
||||
const root = normalizeDirectoryPath(options.selectedWorkspaceRoot());
|
||||
if (root) {
|
||||
const session = store.sessions.find((candidate) => candidate.id === part.sessionID) ?? null;
|
||||
const sessionRoot = normalizeDirectoryPath(session?.directory ?? "");
|
||||
@@ -727,6 +741,143 @@ export function createSessionStore(options: {
|
||||
return store.sessionInfoById[id] ?? store.sessions.find((session) => session.id === id) ?? null;
|
||||
};
|
||||
|
||||
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 createSyntheticBlueprintSeedMessage = (
|
||||
sessionID: string,
|
||||
index: number,
|
||||
seed: BlueprintSeedMessage,
|
||||
): MessageWithParts => {
|
||||
const messageId = `${SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX}${sessionID}:${index}`;
|
||||
const role = seed.role === "user" ? "user" : "assistant";
|
||||
const text = seed.text?.trim() ?? "";
|
||||
const createdAt = Math.max(1, index + 1);
|
||||
const info: PlaceholderMessageInfo = {
|
||||
id: messageId,
|
||||
sessionID,
|
||||
role,
|
||||
time: { created: createdAt, completed: createdAt },
|
||||
parentID: index > 0 ? `${SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX}${sessionID}:${index - 1}` : "",
|
||||
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: `${messageId}:text`,
|
||||
sessionID,
|
||||
messageID: messageId,
|
||||
type: "text",
|
||||
text,
|
||||
} as Part,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const insertSyntheticBlueprintSeedMessages = (
|
||||
list: MessageWithParts[],
|
||||
sessionID: string | null,
|
||||
seeds: BlueprintSeedMessage[],
|
||||
) => {
|
||||
if (!sessionID || seeds.length === 0) return list;
|
||||
if (list.length > 0) return list;
|
||||
const existingIds = new Set(list.map((message) => messageIdFromInfo(message)));
|
||||
const synthetic = seeds
|
||||
.map((seed, index) => createSyntheticBlueprintSeedMessage(sessionID, index, seed))
|
||||
.filter((message) => !existingIds.has(messageIdFromInfo(message)));
|
||||
if (!synthetic.length) return list;
|
||||
return [...synthetic, ...list];
|
||||
};
|
||||
|
||||
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) {
|
||||
setStore("sessions", sortSessionsByActivity([...current, next as Session]));
|
||||
rememberSession(next as Session);
|
||||
return;
|
||||
}
|
||||
|
||||
const copy = current.slice();
|
||||
copy[index] = next as Session;
|
||||
rememberSession(next as Session);
|
||||
setStore("sessions", sortSessionsByActivity(copy));
|
||||
};
|
||||
|
||||
const messagesBySessionId = (id: string | null): MessageWithParts[] => {
|
||||
if (!id) return [];
|
||||
const list = store.messages[id] ?? [];
|
||||
@@ -753,6 +904,42 @@ export function createSessionStore(options: {
|
||||
return messagesBySessionId(options.selectedSessionId());
|
||||
});
|
||||
|
||||
const blueprintSeedMessagesForSelectedSession = createMemo(() => {
|
||||
const sessionID = options.selectedSessionId();
|
||||
if (!sessionID) return [] as BlueprintSeedMessage[];
|
||||
return blueprintSeedMessagesBySessionId()[sessionID] ?? [];
|
||||
});
|
||||
|
||||
const visibleMessages = createMemo(() => {
|
||||
const sessionID = options.selectedSessionId();
|
||||
const errorTurns = sessionID ? store.sessionErrorTurns[sessionID] ?? [] : [];
|
||||
const blueprintSeeds = blueprintSeedMessagesForSelectedSession();
|
||||
const list = messages().filter((message) => {
|
||||
const id = messageIdFromInfo(message);
|
||||
return !id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX) && !id.startsWith(SYNTHETIC_BLUEPRINT_SEED_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(
|
||||
insertSyntheticBlueprintSeedMessages(visible, sessionID, blueprintSeeds),
|
||||
sessionID,
|
||||
errorTurns,
|
||||
);
|
||||
});
|
||||
|
||||
const restorePromptFromUserMessage = (message: MessageWithParts) => {
|
||||
const text = message.parts
|
||||
.filter(isVisibleTextPart)
|
||||
.map((part) => String((part as { text?: string }).text ?? ""))
|
||||
.join("");
|
||||
options.setPrompt(text);
|
||||
};
|
||||
|
||||
const todos = createMemo<TodoItem[]>(() => {
|
||||
const id = options.selectedSessionId();
|
||||
if (!id) return [];
|
||||
@@ -787,8 +974,8 @@ export function createSessionStore(options: {
|
||||
scopeScope: describeDirectoryScope(scopeRoot),
|
||||
queryDirectory: queryDirectory ?? null,
|
||||
queryScope: describeDirectoryScope(queryDirectory),
|
||||
activeWorkspaceRoot: options.activeWorkspaceRoot?.() ?? null,
|
||||
activeWorkspaceScope: describeDirectoryScope(options.activeWorkspaceRoot?.() ?? null),
|
||||
selectedWorkspaceRoot: options.selectedWorkspaceRoot?.() ?? null,
|
||||
activeWorkspaceScope: describeDirectoryScope(options.selectedWorkspaceRoot?.() ?? null),
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
@@ -930,8 +1117,11 @@ export function createSessionStore(options: {
|
||||
if (!c) return;
|
||||
|
||||
const perfEnabled = options.developerMode();
|
||||
options.setSelectedSessionId(sessionID);
|
||||
options.setError(null);
|
||||
batch(() => {
|
||||
setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: true }));
|
||||
options.setSelectedSessionId(sessionID);
|
||||
options.setError(null);
|
||||
});
|
||||
|
||||
const existing = selectInFlightBySession.get(sessionID);
|
||||
if (existing) {
|
||||
@@ -977,7 +1167,6 @@ export function createSessionStore(options: {
|
||||
|
||||
const existingLimit = messageLimitBySession()[sessionID] ?? 0;
|
||||
const requestLimit = Math.max(INITIAL_SESSION_MESSAGE_LIMIT, existingLimit);
|
||||
setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: true }));
|
||||
mark("calling session.messages", { limit: requestLimit });
|
||||
const msgs = unwrap(
|
||||
await withTimeout(c.session.messages({ sessionID, limit: requestLimit }), 12000, "session.messages"),
|
||||
@@ -1183,6 +1372,68 @@ export function createSessionStore(options: {
|
||||
});
|
||||
};
|
||||
|
||||
const setSessionCompaction = (sessionID: string, next: SessionCompactionState) => {
|
||||
setStore("sessionCompaction", sessionID, next);
|
||||
};
|
||||
|
||||
const stopSessionCompaction = (sessionID: string) => {
|
||||
const current = store.sessionCompaction[sessionID];
|
||||
pendingCompactionModeBySession.delete(sessionID);
|
||||
if (!current?.running) return;
|
||||
setSessionCompaction(sessionID, {
|
||||
...current,
|
||||
running: false,
|
||||
messageID: null,
|
||||
});
|
||||
};
|
||||
|
||||
const startSessionCompaction = (sessionID: string, messageID: string) => {
|
||||
const current = store.sessionCompaction[sessionID];
|
||||
if (current?.running && current.messageID === messageID) return;
|
||||
const startedAt = Date.now();
|
||||
const mode = pendingCompactionModeBySession.get(sessionID) ?? current?.mode ?? null;
|
||||
pendingCompactionModeBySession.delete(sessionID);
|
||||
setSessionCompaction(sessionID, {
|
||||
running: true,
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
mode,
|
||||
messageID,
|
||||
});
|
||||
if (options.developerMode()) {
|
||||
appendDebugEvent({
|
||||
type: "session.compaction.started",
|
||||
properties: { sessionID, messageID, mode, startedAt },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finishSessionCompaction = (sessionID: string) => {
|
||||
const current = store.sessionCompaction[sessionID];
|
||||
const finishedAt = Date.now();
|
||||
pendingCompactionModeBySession.delete(sessionID);
|
||||
setSessionCompaction(sessionID, {
|
||||
running: false,
|
||||
startedAt: current?.startedAt ?? null,
|
||||
finishedAt,
|
||||
mode: current?.mode ?? null,
|
||||
messageID: null,
|
||||
});
|
||||
if (options.developerMode()) {
|
||||
appendDebugEvent({
|
||||
type: "session.compaction.finished",
|
||||
properties: {
|
||||
sessionID,
|
||||
mode: current?.mode ?? null,
|
||||
startedAt: current?.startedAt ?? null,
|
||||
finishedAt,
|
||||
durationMs:
|
||||
typeof current?.startedAt === "number" ? Math.max(0, finishedAt - current.startedAt) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const compactDebugEvent = (event: OpencodeEvent) => {
|
||||
if (event.type === "message.part.updated") {
|
||||
const record = event.properties as Record<string, unknown> | undefined;
|
||||
@@ -1281,9 +1532,11 @@ export function createSessionStore(options: {
|
||||
syntheticContinueLoopLastWarnAtBySession.delete(info.id);
|
||||
syntheticLoopLastAbortAtByKey.delete(`task-summary:${info.id}`);
|
||||
syntheticLoopLastAbortAtByKey.delete(`compaction-continue:${info.id}`);
|
||||
pendingCompactionModeBySession.delete(info.id);
|
||||
setStore(
|
||||
produce((draft: StoreState) => {
|
||||
delete draft.sessionInfoById[info.id];
|
||||
delete draft.sessionCompaction[info.id];
|
||||
}),
|
||||
);
|
||||
setStore("sessions", (current) => removeSession(current, info.id));
|
||||
@@ -1303,6 +1556,9 @@ export function createSessionStore(options: {
|
||||
if (sessionID) {
|
||||
const normalized = normalizeSessionStatus(record.status);
|
||||
setStore("sessionStatus", sessionID, normalized);
|
||||
if (normalized === "idle") {
|
||||
stopSessionCompaction(sessionID);
|
||||
}
|
||||
if (sessionID === options.selectedSessionId() && normalized !== "idle") {
|
||||
options.setError(null);
|
||||
}
|
||||
@@ -1316,6 +1572,7 @@ export function createSessionStore(options: {
|
||||
const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
|
||||
if (sessionID) {
|
||||
setStore("sessionStatus", sessionID, "idle");
|
||||
stopSessionCompaction(sessionID);
|
||||
const c = options.client();
|
||||
if (c) {
|
||||
try {
|
||||
@@ -1340,6 +1597,7 @@ export function createSessionStore(options: {
|
||||
const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
|
||||
if (sessionID) {
|
||||
setStore("sessionStatus", sessionID, "idle");
|
||||
stopSessionCompaction(sessionID);
|
||||
}
|
||||
const errorObj = record.error as Record<string, unknown> | undefined;
|
||||
if (errorObj) {
|
||||
@@ -1374,6 +1632,7 @@ export function createSessionStore(options: {
|
||||
const record = event.properties as Record<string, unknown>;
|
||||
if (record.info && typeof record.info === "object") {
|
||||
const info = record.info as Message;
|
||||
const messageRecord = info as Message & Record<string, unknown>;
|
||||
const model = modelFromUserMessage(info as MessageInfo);
|
||||
if (model) {
|
||||
options.setSessionModelState((current) => ({
|
||||
@@ -1390,6 +1649,14 @@ export function createSessionStore(options: {
|
||||
}
|
||||
|
||||
setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info));
|
||||
|
||||
if (
|
||||
messageRecord.role === "assistant" &&
|
||||
messageRecord.mode === "compaction" &&
|
||||
messageRecord.summary === true
|
||||
) {
|
||||
startSessionCompaction(info.sessionID, info.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1414,6 +1681,13 @@ export function createSessionStore(options: {
|
||||
const delta = typeof record.delta === "string" ? record.delta : null;
|
||||
const partUpdatedStartedAt = perfNow();
|
||||
|
||||
if (part.type === "compaction") {
|
||||
pendingCompactionModeBySession.set(
|
||||
part.sessionID,
|
||||
(part as Part & { auto?: unknown }).auto === true ? "auto" : "manual",
|
||||
);
|
||||
}
|
||||
|
||||
setStore(
|
||||
produce((draft: StoreState) => {
|
||||
const list = draft.messages[part.sessionID] ?? [];
|
||||
@@ -1511,6 +1785,16 @@ export function createSessionStore(options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
if (event.properties && typeof event.properties === "object") {
|
||||
const record = event.properties as Record<string, unknown>;
|
||||
const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
|
||||
if (sessionID) {
|
||||
finishSessionCompaction(sessionID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked" || event.type === "permission.replied") {
|
||||
try {
|
||||
await refreshPendingPermissions();
|
||||
@@ -1747,8 +2031,19 @@ export function createSessionStore(options: {
|
||||
sessionStatusById,
|
||||
selectedSession,
|
||||
selectedSessionStatus,
|
||||
messageIdFromInfo,
|
||||
visibleMessages,
|
||||
blueprintSeedMessagesForSelectedSession,
|
||||
restorePromptFromUserMessage,
|
||||
upsertLocalSession,
|
||||
setBlueprintSeedMessagesBySessionId,
|
||||
selectedSessionCompactionState: createMemo(() => {
|
||||
const sessionID = options.selectedSessionId();
|
||||
return sessionID ? store.sessionCompaction[sessionID] ?? null : null;
|
||||
}),
|
||||
messages,
|
||||
messagesBySessionId,
|
||||
sessionCompactionById: (sessionID: string | null) => (sessionID ? store.sessionCompaction[sessionID] ?? null : null),
|
||||
todos,
|
||||
pendingPermissions,
|
||||
permissionReplyBusy,
|
||||
|
||||
225
apps/app/src/app/context/sidebar-sessions.ts
Normal file
225
apps/app/src/app/context/sidebar-sessions.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { createClient, type OpencodeAuth, unwrap } from "../lib/opencode";
|
||||
import type { WorkspaceInfo, EngineInfo } from "../lib/tauri";
|
||||
import type { SidebarSessionItem, WorkspaceSessionGroup } from "../types";
|
||||
import {
|
||||
normalizeDirectoryPath,
|
||||
normalizeDirectoryQueryPath,
|
||||
safeStringify,
|
||||
} from "../utils";
|
||||
import { toSessionTransportDirectory } from "../lib/session-scope";
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
type SidebarWorkspaceSessionsStatus = WorkspaceSessionGroup["status"];
|
||||
|
||||
export function createSidebarSessionsStore(options: {
|
||||
workspaces: () => WorkspaceInfo[];
|
||||
engine: () => EngineInfo | null;
|
||||
}) {
|
||||
const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = createSignal<
|
||||
Record<string, SidebarSessionItem[]>
|
||||
>({});
|
||||
const [statusByWorkspaceId, setStatusByWorkspaceId] = createSignal<
|
||||
Record<string, SidebarWorkspaceSessionsStatus>
|
||||
>({});
|
||||
const [errorByWorkspaceId, setErrorByWorkspaceId] = createSignal<Record<string, string | null>>({});
|
||||
|
||||
const pruneState = (workspaceIds: Set<string>) => {
|
||||
setSessionsByWorkspaceId((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, SidebarSessionItem[]> = {};
|
||||
for (const [id, list] of Object.entries(prev)) {
|
||||
if (!workspaceIds.has(id)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[id] = list;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setStatusByWorkspaceId((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, SidebarWorkspaceSessionsStatus> = {};
|
||||
for (const [id, status] of Object.entries(prev)) {
|
||||
if (!workspaceIds.has(id)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[id] = status;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setErrorByWorkspaceId((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, string | null> = {};
|
||||
for (const [id, error] of Object.entries(prev)) {
|
||||
if (!workspaceIds.has(id)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[id] = error;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
|
||||
const resolveClientConfig = (workspaceId: string) => {
|
||||
const workspace = options.workspaces().find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) return null;
|
||||
|
||||
if (workspace.workspaceType === "local") {
|
||||
const info = options.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") {
|
||||
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 refreshSeqByWorkspaceId: Record<string, number> = {};
|
||||
const SIDEBAR_SESSION_LIMIT = 200;
|
||||
|
||||
const refreshWorkspaceSessions = async (workspaceId: string) => {
|
||||
const id = workspaceId.trim();
|
||||
if (!id) return;
|
||||
|
||||
const config = resolveClientConfig(id);
|
||||
if (!config) return;
|
||||
|
||||
if (!config.baseUrl) {
|
||||
setStatusByWorkspaceId((prev) => (prev[id] === "idle" ? prev : { ...prev, [id]: "idle" }));
|
||||
setErrorByWorkspaceId((prev) => ((prev[id] ?? null) === null ? prev : { ...prev, [id]: null }));
|
||||
return;
|
||||
}
|
||||
|
||||
refreshSeqByWorkspaceId[id] = (refreshSeqByWorkspaceId[id] ?? 0) + 1;
|
||||
const seq = refreshSeqByWorkspaceId[id];
|
||||
|
||||
setStatusByWorkspaceId((prev) => ({ ...prev, [id]: "loading" }));
|
||||
setErrorByWorkspaceId((prev) => ({ ...prev, [id]: null }));
|
||||
|
||||
try {
|
||||
let directory = config.directory;
|
||||
let client = createClient(config.baseUrl, directory || undefined, config.auth);
|
||||
|
||||
if (!directory) {
|
||||
try {
|
||||
const pathInfo = unwrap(await client.path.get());
|
||||
const discovered = toSessionTransportDirectory(pathInfo.directory ?? "");
|
||||
if (discovered) {
|
||||
directory = discovered;
|
||||
client = createClient(config.baseUrl, directory, config.auth);
|
||||
}
|
||||
} catch {
|
||||
// Ignore discovery failures and continue with the configured directory.
|
||||
}
|
||||
}
|
||||
|
||||
const queryDirectory = normalizeDirectoryQueryPath(directory) || undefined;
|
||||
const list = unwrap(
|
||||
await client.session.list({ directory: queryDirectory, roots: false, limit: SIDEBAR_SESSION_LIMIT }),
|
||||
);
|
||||
if (refreshSeqByWorkspaceId[id] !== seq) return;
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
setSessionsByWorkspaceId((prev) => ({
|
||||
...prev,
|
||||
[id]: items,
|
||||
}));
|
||||
setStatusByWorkspaceId((prev) => ({ ...prev, [id]: "ready" }));
|
||||
} catch (error) {
|
||||
if (refreshSeqByWorkspaceId[id] !== seq) return;
|
||||
const message = error instanceof Error ? error.message : safeStringify(error);
|
||||
setStatusByWorkspaceId((prev) => ({ ...prev, [id]: "error" }));
|
||||
setErrorByWorkspaceId((prev) => ({ ...prev, [id]: message }));
|
||||
}
|
||||
};
|
||||
|
||||
let lastFingerprintByWorkspaceId: Record<string, string> = {};
|
||||
createEffect(() => {
|
||||
const engineInfo = options.engine();
|
||||
const engineBaseUrl = engineInfo?.baseUrl?.trim() ?? "";
|
||||
const engineUser = engineInfo?.opencodeUsername?.trim() ?? "";
|
||||
const enginePass = engineInfo?.opencodePassword?.trim() ?? "";
|
||||
const workspaces = options.workspaces();
|
||||
const workspaceIds = new Set(workspaces.map((workspace) => workspace.id));
|
||||
pruneState(workspaceIds);
|
||||
|
||||
const nextFingerprintByWorkspaceId: Record<string, string> = {};
|
||||
for (const workspace of workspaces) {
|
||||
const root = workspace.workspaceType === "local" ? workspace.path?.trim() ?? "" : workspace.directory?.trim() ?? "";
|
||||
const base = workspace.workspaceType === "local" ? engineBaseUrl : workspace.baseUrl?.trim() ?? "";
|
||||
const remoteType = workspace.workspaceType === "remote" ? (workspace.remoteType ?? "") : "";
|
||||
const token = workspace.remoteType === "openwork" ? (workspace.openworkToken?.trim() ?? "") : "";
|
||||
const authKey = workspace.workspaceType === "local" ? `${engineUser}:${enginePass}` : token;
|
||||
nextFingerprintByWorkspaceId[workspace.id] = [workspace.workspaceType, remoteType, root, base, authKey].join("|");
|
||||
}
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const nextFingerprint = nextFingerprintByWorkspaceId[workspace.id];
|
||||
if (lastFingerprintByWorkspaceId[workspace.id] === nextFingerprint) continue;
|
||||
void refreshWorkspaceSessions(workspace.id).catch(() => undefined);
|
||||
}
|
||||
|
||||
lastFingerprintByWorkspaceId = nextFingerprintByWorkspaceId;
|
||||
});
|
||||
|
||||
const workspaceGroups = createMemo<WorkspaceSessionGroup[]>(() => {
|
||||
const workspaces = options.workspaces();
|
||||
const sessions = sessionsByWorkspaceId();
|
||||
const statuses = statusByWorkspaceId();
|
||||
const errors = errorByWorkspaceId();
|
||||
return workspaces.map((workspace) => ({
|
||||
workspace,
|
||||
sessions: sessions[workspace.id] ?? [],
|
||||
status: statuses[workspace.id] ?? "idle",
|
||||
error: errors[workspace.id] ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceGroups,
|
||||
refreshWorkspaceSessions,
|
||||
};
|
||||
}
|
||||
18
apps/app/src/app/context/workspace-context.ts
Normal file
18
apps/app/src/app/context/workspace-context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createMemo } from "solid-js";
|
||||
|
||||
import { normalizeDirectoryPath } from "../utils";
|
||||
|
||||
export function createWorkspaceContextKey(options: {
|
||||
selectedWorkspaceId: () => string;
|
||||
selectedWorkspaceRoot: () => string;
|
||||
runtimeWorkspaceId?: () => string | null;
|
||||
workspaceType?: () => "local" | "remote";
|
||||
}) {
|
||||
return createMemo(() => {
|
||||
const workspaceId = options.selectedWorkspaceId().trim();
|
||||
const root = normalizeDirectoryPath(options.selectedWorkspaceRoot().trim());
|
||||
const runtimeWorkspaceId = (options.runtimeWorkspaceId?.() ?? "").trim();
|
||||
const workspaceType = options.workspaceType?.() ?? "local";
|
||||
return `${workspaceType}:${workspaceId}:${root}:${runtimeWorkspaceId}`;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
21
apps/app/src/app/extensions/provider.tsx
Normal file
21
apps/app/src/app/extensions/provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import type { ExtensionsStore } from "../context/extensions";
|
||||
|
||||
const ExtensionsContext = createContext<ExtensionsStore>();
|
||||
|
||||
export function ExtensionsProvider(props: ParentProps<{ store: ExtensionsStore }>) {
|
||||
return (
|
||||
<ExtensionsContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</ExtensionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useExtensions() {
|
||||
const context = useContext(ExtensionsContext);
|
||||
if (!context) {
|
||||
throw new Error("useExtensions must be used within an ExtensionsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -74,6 +74,142 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.ow-soft-shell {
|
||||
border: 1px solid var(--dls-border);
|
||||
background: var(--dls-surface);
|
||||
border-radius: 2rem;
|
||||
box-shadow: var(--dls-shell-shadow);
|
||||
}
|
||||
|
||||
.ow-soft-card {
|
||||
border: 1px solid var(--dls-border);
|
||||
background: var(--dls-surface);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: var(--dls-card-shadow);
|
||||
}
|
||||
|
||||
.ow-soft-card-quiet {
|
||||
border: 1px solid var(--dls-border);
|
||||
background: var(--dls-sidebar);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.ow-button-primary {
|
||||
display: inline-flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--dls-accent);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 20px -16px rgba(var(--dls-accent-rgb), 0.45);
|
||||
}
|
||||
|
||||
.ow-button-primary:hover:not(:disabled) {
|
||||
background: var(--dls-accent-hover);
|
||||
}
|
||||
|
||||
.ow-button-secondary {
|
||||
display: inline-flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--dls-border);
|
||||
background: var(--dls-surface);
|
||||
color: var(--dls-text-primary);
|
||||
box-shadow: var(--dls-card-shadow);
|
||||
}
|
||||
|
||||
.ow-button-secondary:hover:not(:disabled) {
|
||||
background: var(--dls-hover);
|
||||
}
|
||||
|
||||
.ow-button-primary,
|
||||
.ow-button-secondary {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background-color 150ms ease, color 150ms ease, transform 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.ow-button-primary:active:not(:disabled),
|
||||
.ow-button-secondary:active:not(:disabled) {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.ow-button-primary:disabled,
|
||||
.ow-button-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ow-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ow-status-pill-positive {
|
||||
border: 1px solid rgba(16, 185, 129, 0.16);
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.ow-status-pill-warning {
|
||||
border: 1px solid rgba(245, 158, 11, 0.16);
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.ow-status-pill-neutral {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ow-icon-tile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.85rem;
|
||||
background: #f4f6f8;
|
||||
color: #011627;
|
||||
}
|
||||
|
||||
.ow-icon-tile-muted {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.85rem;
|
||||
background: #f1f5f9;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ow-input {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: #fbfbfc;
|
||||
box-shadow: inset 0 0 0 1px #eceef1;
|
||||
color: var(--dls-text-primary);
|
||||
}
|
||||
|
||||
.ow-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.ow-input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--dls-accent-rgb), 0.28), 0 0 0 3px rgba(var(--dls-accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
/* Global clickable elements pointer */
|
||||
button,
|
||||
[role="button"],
|
||||
|
||||
131
apps/app/src/app/lib/den-template-cache.ts
Normal file
131
apps/app/src/app/lib/den-template-cache.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
import { createDenClient, type DenTemplate } from "./den";
|
||||
|
||||
type DenTemplateCacheKeyInput = {
|
||||
baseUrl?: string | null;
|
||||
token?: string | null;
|
||||
orgSlug?: string | null;
|
||||
};
|
||||
|
||||
type DenTemplateCacheEntry = {
|
||||
templates: DenTemplate[];
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
loadedAt: number | null;
|
||||
promise: Promise<DenTemplate[]> | null;
|
||||
};
|
||||
|
||||
const templateCache = new Map<string, DenTemplateCacheEntry>();
|
||||
const [templateCacheVersion, setTemplateCacheVersion] = createSignal(0);
|
||||
|
||||
function getCacheKey(input: DenTemplateCacheKeyInput): string | null {
|
||||
const baseUrl = input.baseUrl?.trim() ?? "";
|
||||
const token = input.token?.trim() ?? "";
|
||||
const orgSlug = input.orgSlug?.trim() ?? "";
|
||||
if (!baseUrl || !token || !orgSlug) return null;
|
||||
return `${baseUrl}::${orgSlug}::${token}`;
|
||||
}
|
||||
|
||||
function readEntry(key: string | null): DenTemplateCacheEntry {
|
||||
if (!key) {
|
||||
return {
|
||||
templates: [],
|
||||
busy: false,
|
||||
error: null,
|
||||
loadedAt: null,
|
||||
promise: null,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
templateCache.get(key) ?? {
|
||||
templates: [],
|
||||
busy: false,
|
||||
error: null,
|
||||
loadedAt: null,
|
||||
promise: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function writeEntry(key: string, next: DenTemplateCacheEntry) {
|
||||
templateCache.set(key, next);
|
||||
setTemplateCacheVersion((value) => value + 1);
|
||||
}
|
||||
|
||||
function toMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function readDenTemplateCacheSnapshot(input: DenTemplateCacheKeyInput) {
|
||||
templateCacheVersion();
|
||||
const key = getCacheKey(input);
|
||||
const entry = readEntry(key);
|
||||
return {
|
||||
key,
|
||||
templates: entry.templates,
|
||||
busy: entry.busy,
|
||||
error: entry.error,
|
||||
loadedAt: entry.loadedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDenTemplateCache(
|
||||
input: DenTemplateCacheKeyInput,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DenTemplate[]> {
|
||||
const key = getCacheKey(input);
|
||||
if (!key) return [];
|
||||
|
||||
const current = readEntry(key);
|
||||
if (current.promise) {
|
||||
return current.promise;
|
||||
}
|
||||
if (!options.force && current.loadedAt && !current.error) {
|
||||
return current.templates;
|
||||
}
|
||||
|
||||
const request = createDenClient({
|
||||
baseUrl: input.baseUrl?.trim() ?? "",
|
||||
token: input.token?.trim() ?? "",
|
||||
})
|
||||
.listTemplates(input.orgSlug?.trim() ?? "")
|
||||
.then((templates) => {
|
||||
writeEntry(key, {
|
||||
templates,
|
||||
busy: false,
|
||||
error: null,
|
||||
loadedAt: Date.now(),
|
||||
promise: null,
|
||||
});
|
||||
return templates;
|
||||
})
|
||||
.catch((error) => {
|
||||
const latest = readEntry(key);
|
||||
writeEntry(key, {
|
||||
templates: latest.templates,
|
||||
busy: false,
|
||||
error: toMessage(error, "Failed to load team templates."),
|
||||
loadedAt: latest.loadedAt,
|
||||
promise: null,
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
writeEntry(key, {
|
||||
templates: current.templates,
|
||||
busy: true,
|
||||
error: null,
|
||||
loadedAt: current.loadedAt,
|
||||
promise: request,
|
||||
});
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export function clearDenTemplateCache() {
|
||||
if (templateCache.size === 0) return;
|
||||
templateCache.clear();
|
||||
setTemplateCacheVersion((value) => value + 1);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { isDesktopDeployment } from "./openwork-deployment";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
|
||||
const STORAGE_BASE_URL = "openwork.den.baseUrl";
|
||||
const STORAGE_API_BASE_URL = "openwork.den.apiBaseUrl";
|
||||
const STORAGE_AUTH_TOKEN = "openwork.den.authToken";
|
||||
const STORAGE_ACTIVE_ORG_ID = "openwork.den.activeOrgId";
|
||||
const STORAGE_ACTIVE_ORG_SLUG = "openwork.den.activeOrgSlug";
|
||||
const STORAGE_ACTIVE_ORG_NAME = "openwork.den.activeOrgName";
|
||||
const DEFAULT_DEN_TIMEOUT_MS = 12_000;
|
||||
|
||||
export const DEFAULT_DEN_AUTH_NAME = "OpenWork User";
|
||||
@@ -18,6 +21,8 @@ export type DenSettings = {
|
||||
apiBaseUrl?: string;
|
||||
authToken?: string | null;
|
||||
activeOrgId?: string | null;
|
||||
activeOrgSlug?: string | null;
|
||||
activeOrgName?: string | null;
|
||||
};
|
||||
|
||||
type DenBaseUrls = {
|
||||
@@ -56,6 +61,25 @@ export type DenWorkerTokens = {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
export type DenTemplateCreator = {
|
||||
memberId: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
export type DenTemplate = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
templateData: unknown;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
creator: DenTemplateCreator | null;
|
||||
};
|
||||
|
||||
export type DenBillingPrice = {
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
@@ -222,6 +246,16 @@ export function resolveDenBaseUrls(input: { baseUrl?: string | null; apiBaseUrl?
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDenAuthUrl(baseUrl: string, mode: "sign-in" | "sign-up"): string {
|
||||
const target = new URL(resolveDenBaseUrls(baseUrl).baseUrl);
|
||||
target.searchParams.set("mode", mode);
|
||||
if (isDesktopDeployment()) {
|
||||
target.searchParams.set("desktopAuth", "1");
|
||||
target.searchParams.set("desktopScheme", "openwork");
|
||||
}
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function resolveRequestBaseUrl(baseUrls: DenBaseUrls, path: string): string {
|
||||
return path.startsWith("/api/") ? baseUrls.baseUrl : baseUrls.apiBaseUrl;
|
||||
}
|
||||
@@ -240,6 +274,8 @@ export function readDenSettings(): DenSettings {
|
||||
...baseUrls,
|
||||
authToken: (window.localStorage.getItem(STORAGE_AUTH_TOKEN) ?? "").trim() || null,
|
||||
activeOrgId: (window.localStorage.getItem(STORAGE_ACTIVE_ORG_ID) ?? "").trim() || null,
|
||||
activeOrgSlug: (window.localStorage.getItem(STORAGE_ACTIVE_ORG_SLUG) ?? "").trim() || null,
|
||||
activeOrgName: (window.localStorage.getItem(STORAGE_ACTIVE_ORG_NAME) ?? "").trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +287,8 @@ export function writeDenSettings(next: DenSettings) {
|
||||
const { baseUrl, apiBaseUrl } = resolveDenBaseUrls(next);
|
||||
const authToken = next.authToken?.trim() ?? "";
|
||||
const activeOrgId = next.activeOrgId?.trim() ?? "";
|
||||
const activeOrgSlug = next.activeOrgSlug?.trim() ?? "";
|
||||
const activeOrgName = next.activeOrgName?.trim() ?? "";
|
||||
|
||||
window.localStorage.setItem(STORAGE_BASE_URL, baseUrl);
|
||||
window.localStorage.setItem(STORAGE_API_BASE_URL, apiBaseUrl);
|
||||
@@ -265,6 +303,18 @@ export function writeDenSettings(next: DenSettings) {
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_ID);
|
||||
}
|
||||
|
||||
if (activeOrgSlug) {
|
||||
window.localStorage.setItem(STORAGE_ACTIVE_ORG_SLUG, activeOrgSlug);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG);
|
||||
}
|
||||
|
||||
if (activeOrgName) {
|
||||
window.localStorage.setItem(STORAGE_ACTIVE_ORG_NAME, activeOrgName);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDenSession(options?: { includeBaseUrls?: boolean }) {
|
||||
@@ -279,6 +329,8 @@ export function clearDenSession(options?: { includeBaseUrls?: boolean }) {
|
||||
|
||||
window.localStorage.removeItem(STORAGE_AUTH_TOKEN);
|
||||
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_ID);
|
||||
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG);
|
||||
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME);
|
||||
}
|
||||
|
||||
function getErrorMessage(payload: unknown, fallback: string): string {
|
||||
@@ -393,6 +445,64 @@ function getWorkerTokens(payload: unknown): DenWorkerTokens | null {
|
||||
};
|
||||
}
|
||||
|
||||
function getTemplateCreator(value: unknown): DenTemplateCreator | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role = value.role;
|
||||
if (
|
||||
typeof value.memberId !== "string" ||
|
||||
typeof value.userId !== "string" ||
|
||||
(role !== "owner" && role !== "admin" && role !== "member")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
memberId: value.memberId,
|
||||
role,
|
||||
userId: value.userId,
|
||||
name: typeof value.name === "string" ? value.name : null,
|
||||
email: typeof value.email === "string" ? value.email : null,
|
||||
image: typeof value.image === "string" ? value.image : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getTemplate(value: unknown): DenTemplate | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.organizationId !== "string" ||
|
||||
typeof value.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
organizationId: value.organizationId,
|
||||
name: value.name,
|
||||
templateData: value.templateData,
|
||||
createdAt: typeof value.createdAt === "string" ? value.createdAt : null,
|
||||
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null,
|
||||
creator: getTemplateCreator(value.creator),
|
||||
};
|
||||
}
|
||||
|
||||
function getTemplates(payload: unknown): DenTemplate[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.templates)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.templates
|
||||
.map((entry) => getTemplate(entry))
|
||||
.filter((entry): entry is DenTemplate => entry !== null);
|
||||
}
|
||||
|
||||
function getBillingPrice(value: unknown): DenBillingPrice | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
@@ -650,6 +760,58 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul
|
||||
return tokens;
|
||||
},
|
||||
|
||||
async listTemplates(orgSlug: string): Promise<DenTemplate[]> {
|
||||
const payload = await requestJson<unknown>(
|
||||
baseUrls,
|
||||
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates`,
|
||||
{
|
||||
method: "GET",
|
||||
token,
|
||||
},
|
||||
);
|
||||
return getTemplates(payload);
|
||||
},
|
||||
|
||||
async createTemplate(
|
||||
orgSlug: string,
|
||||
input: { name: string; templateData: unknown },
|
||||
): Promise<DenTemplate> {
|
||||
const payload = await requestJson<unknown>(
|
||||
baseUrls,
|
||||
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates`,
|
||||
{
|
||||
method: "POST",
|
||||
token,
|
||||
body: {
|
||||
name: input.name.trim(),
|
||||
templateData: input.templateData,
|
||||
},
|
||||
},
|
||||
);
|
||||
const template = isRecord(payload) ? getTemplate(payload.template) : null;
|
||||
if (!template) {
|
||||
throw new DenApiError(500, "invalid_template_payload", "Template response was missing template details.");
|
||||
}
|
||||
return template;
|
||||
},
|
||||
|
||||
async deleteTemplate(orgSlug: string, templateId: string): Promise<void> {
|
||||
const raw = await requestJsonRaw(
|
||||
baseUrls,
|
||||
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates/${encodeURIComponent(templateId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
token,
|
||||
},
|
||||
);
|
||||
if (!raw.ok) {
|
||||
const payload = raw.json;
|
||||
const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : "request_failed";
|
||||
const message = getErrorMessage(payload, `Request failed with ${raw.status}.`);
|
||||
throw new DenApiError(raw.status, code, message, isRecord(payload) ? payload.details : undefined);
|
||||
}
|
||||
},
|
||||
|
||||
async getBillingStatus(options: { includeCheckout?: boolean; includePortal?: boolean; includeInvoices?: boolean } = {}): Promise<DenBillingSummary> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.includeCheckout) {
|
||||
|
||||
206
apps/app/src/app/lib/openwork-links.ts
Normal file
206
apps/app/src/app/lib/openwork-links.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { DEFAULT_DEN_BASE_URL, normalizeDenBaseUrl } from "./den";
|
||||
import { normalizeOpenworkServerUrl } from "./openwork-server";
|
||||
import { normalizeBundleImportIntent, parseBundleDeepLink } from "../bundles/sources";
|
||||
import type { BundleRequest } from "../bundles/types";
|
||||
|
||||
export type RemoteWorkspaceDefaults = {
|
||||
openworkHostUrl?: string | null;
|
||||
openworkToken?: string | null;
|
||||
directory?: string | null;
|
||||
displayName?: string | null;
|
||||
autoConnect?: boolean;
|
||||
};
|
||||
|
||||
export type DenAuthDeepLink = {
|
||||
grant: string;
|
||||
denBaseUrl: string;
|
||||
};
|
||||
|
||||
function isSupportedDeepLinkProtocol(protocol: string): boolean {
|
||||
const normalized = protocol.toLowerCase();
|
||||
return normalized === "openwork:" || normalized === "openwork-dev:" || normalized === "https:" || normalized === "http:";
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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 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(?:labs\.com|\.software)\/b\/[^\s"'<>]+/i);
|
||||
if (bareShareMatch) return `https://${bareShareMatch[0]}`;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseDebugDeepLinkInput(rawValue: string):
|
||||
| { kind: "bundle"; link: BundleRequest }
|
||||
| { 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 bundleLink = parseBundleDeepLink(normalized);
|
||||
if (bundleLink) {
|
||||
return { kind: "bundle", link: bundleLink };
|
||||
}
|
||||
|
||||
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: normalizeBundleImportIntent(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(?:labs\.com|\.software)\/b\/([^\s/?#"'<>]+)/i);
|
||||
if (shareIdMatch?.[1]) {
|
||||
return {
|
||||
kind: "bundle",
|
||||
link: {
|
||||
bundleUrl: `https://share.openworklabs.com/b/${shareIdMatch[1]}`,
|
||||
intent: "new_worker",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -43,7 +43,8 @@ export type OpenworkServerDiagnostics = {
|
||||
approval: { mode: "manual" | "auto"; timeoutMs: number };
|
||||
corsOrigins: string[];
|
||||
workspaceCount: number;
|
||||
activeWorkspaceId: string | null;
|
||||
activeWorkspaceId?: string | null;
|
||||
selectedWorkspaceId?: string | null;
|
||||
workspace: OpenworkWorkspaceInfo | null;
|
||||
authorizedRoots: string[];
|
||||
server: { host: string; port: number; configPath?: string | null };
|
||||
@@ -157,85 +158,6 @@ export type OpenworkWorkspaceFileWriteResult = {
|
||||
revision?: string;
|
||||
};
|
||||
|
||||
export type OpenworkFileSession = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
ttlMs: number;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export type OpenworkFileCatalogEntry = {
|
||||
path: string;
|
||||
kind: "file" | "dir";
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type OpenworkFileSessionEvent = {
|
||||
id: string;
|
||||
seq: number;
|
||||
workspaceId: string;
|
||||
type: "write" | "delete" | "rename" | "mkdir";
|
||||
path: string;
|
||||
toPath?: string;
|
||||
revision?: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type OpenworkFileReadBatchResult = {
|
||||
items: Array<
|
||||
| {
|
||||
ok: true;
|
||||
path: string;
|
||||
kind: "file";
|
||||
bytes: number;
|
||||
updatedAt: number;
|
||||
revision: string;
|
||||
contentBase64: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
path: string;
|
||||
code: string;
|
||||
message: string;
|
||||
maxBytes?: number;
|
||||
size?: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type OpenworkFileWriteBatchResult = {
|
||||
items: Array<
|
||||
| {
|
||||
ok: true;
|
||||
path: string;
|
||||
bytes: number;
|
||||
updatedAt: number;
|
||||
revision: string;
|
||||
previousRevision?: string | null;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
path: string;
|
||||
code: string;
|
||||
message: string;
|
||||
expectedRevision?: string;
|
||||
currentRevision?: string | null;
|
||||
maxBytes?: number;
|
||||
size?: number;
|
||||
}
|
||||
>;
|
||||
cursor: number;
|
||||
};
|
||||
|
||||
export type OpenworkFileOpsBatchResult = {
|
||||
items: Array<Record<string, unknown>>;
|
||||
cursor: number;
|
||||
};
|
||||
|
||||
export type OpenworkCommandItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -450,6 +372,22 @@ export type OpenworkWorkspaceExport = {
|
||||
openwork?: Record<string, unknown>;
|
||||
skills?: Array<{ name: string; description?: string; trigger?: string; content: string }>;
|
||||
commands?: Array<{ name: string; description?: string; template?: string }>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
};
|
||||
|
||||
export type OpenworkWorkspaceExportSensitiveMode = "auto" | "include" | "exclude";
|
||||
|
||||
export type OpenworkWorkspaceExportWarning = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type OpenworkBlueprintSessionsMaterializeResult = {
|
||||
ok: boolean;
|
||||
created: Array<{ templateId: string; sessionId: string; title: string }>;
|
||||
existing: Array<{ templateId: string; sessionId: string }>;
|
||||
openSessionId: string | null;
|
||||
};
|
||||
|
||||
export type OpenworkArtifactItem = {
|
||||
@@ -522,6 +460,8 @@ export type OpenworkReloadEvent = {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
// Fallback for explicit server-mode URL derivation. Desktop local workers replace this
|
||||
// with the persisted runtime-discovered port once the host reports it.
|
||||
export const DEFAULT_OPENWORK_SERVER_PORT = 8787;
|
||||
|
||||
const STORAGE_URL_OVERRIDE = "openwork.server.urlOverride";
|
||||
@@ -585,17 +525,10 @@ export function buildOpenworkWorkspaceBaseUrl(hostUrl: string, workspaceId?: str
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_OPENWORK_CONNECT_APP_URL = "https://app.openwork.software";
|
||||
|
||||
const OPENWORK_INVITE_PARAM_URL = "ow_url";
|
||||
const OPENWORK_INVITE_PARAM_TOKEN = "ow_token";
|
||||
const OPENWORK_INVITE_PARAM_STARTUP = "ow_startup";
|
||||
const OPENWORK_INVITE_PARAM_AUTO_CONNECT = "ow_auto_connect";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE = "ow_bundle";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_INTENT = "ow_intent";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_SOURCE = "ow_source";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_ORG = "ow_org";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_LABEL = "ow_label";
|
||||
|
||||
export type OpenworkConnectInvite = {
|
||||
url: string;
|
||||
@@ -604,69 +537,6 @@ export type OpenworkConnectInvite = {
|
||||
autoConnect?: boolean;
|
||||
};
|
||||
|
||||
export type OpenworkBundleInviteIntent = "new_worker" | "import_current";
|
||||
|
||||
export type OpenworkBundleInvite = {
|
||||
bundleUrl: string;
|
||||
intent: OpenworkBundleInviteIntent;
|
||||
source?: string;
|
||||
orgId?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function normalizeOpenworkBundleInviteIntent(value: string | null | undefined): OpenworkBundleInviteIntent {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") {
|
||||
return "new_worker";
|
||||
}
|
||||
return "import_current";
|
||||
}
|
||||
|
||||
export function buildOpenworkConnectInviteUrl(input: {
|
||||
workspaceUrl: string;
|
||||
token?: string | null;
|
||||
appUrl?: string | null;
|
||||
startup?: "server";
|
||||
autoConnect?: boolean;
|
||||
}) {
|
||||
const workspaceUrl = normalizeOpenworkServerUrl(input.workspaceUrl ?? "") ?? "";
|
||||
if (!workspaceUrl) return "";
|
||||
|
||||
const base = normalizeOpenworkServerUrl(input.appUrl ?? "") ?? DEFAULT_OPENWORK_CONNECT_APP_URL;
|
||||
|
||||
try {
|
||||
const url = new URL(base);
|
||||
const search = new URLSearchParams(url.search);
|
||||
search.set(OPENWORK_INVITE_PARAM_URL, workspaceUrl);
|
||||
|
||||
const token = input.token?.trim() ?? "";
|
||||
if (token) {
|
||||
search.set(OPENWORK_INVITE_PARAM_TOKEN, token);
|
||||
}
|
||||
|
||||
const startup = input.startup ?? "server";
|
||||
search.set(OPENWORK_INVITE_PARAM_STARTUP, startup);
|
||||
if (input.autoConnect) {
|
||||
search.set(OPENWORK_INVITE_PARAM_AUTO_CONNECT, "1");
|
||||
}
|
||||
|
||||
url.search = search.toString();
|
||||
return url.toString();
|
||||
} catch {
|
||||
const search = new URLSearchParams();
|
||||
search.set(OPENWORK_INVITE_PARAM_URL, workspaceUrl);
|
||||
const token = input.token?.trim() ?? "";
|
||||
if (token) {
|
||||
search.set(OPENWORK_INVITE_PARAM_TOKEN, token);
|
||||
}
|
||||
search.set(OPENWORK_INVITE_PARAM_STARTUP, input.startup ?? "server");
|
||||
if (input.autoConnect) {
|
||||
search.set(OPENWORK_INVITE_PARAM_AUTO_CONNECT, "1");
|
||||
}
|
||||
return `${DEFAULT_OPENWORK_CONNECT_APP_URL}?${search.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenworkConnectInviteFromSearch(input: string | URLSearchParams) {
|
||||
const search =
|
||||
typeof input === "string"
|
||||
@@ -690,109 +560,6 @@ export function readOpenworkConnectInviteFromSearch(input: string | URLSearchPar
|
||||
} satisfies OpenworkConnectInvite;
|
||||
}
|
||||
|
||||
export function buildOpenworkBundleInviteUrl(input: {
|
||||
bundleUrl: string;
|
||||
appUrl?: string | null;
|
||||
intent?: OpenworkBundleInviteIntent;
|
||||
source?: string | null;
|
||||
orgId?: string | null;
|
||||
label?: string | null;
|
||||
}) {
|
||||
const rawBundleUrl = input.bundleUrl?.trim() ?? "";
|
||||
if (!rawBundleUrl) return "";
|
||||
|
||||
let bundleUrl: string;
|
||||
try {
|
||||
bundleUrl = new URL(rawBundleUrl).toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
const base = normalizeOpenworkServerUrl(input.appUrl ?? "") ?? DEFAULT_OPENWORK_CONNECT_APP_URL;
|
||||
|
||||
try {
|
||||
const url = new URL(base);
|
||||
const search = new URLSearchParams(url.search);
|
||||
const intent = normalizeOpenworkBundleInviteIntent(input.intent);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE, bundleUrl);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_INTENT, intent);
|
||||
|
||||
const source = input.source?.trim() ?? "";
|
||||
if (source) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE, source);
|
||||
}
|
||||
|
||||
const orgId = input.orgId?.trim() ?? "";
|
||||
if (orgId) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_ORG, orgId);
|
||||
}
|
||||
|
||||
const label = input.label?.trim() ?? "";
|
||||
if (label) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_LABEL, label);
|
||||
}
|
||||
|
||||
url.search = search.toString();
|
||||
return url.toString();
|
||||
} catch {
|
||||
const search = new URLSearchParams();
|
||||
const intent = normalizeOpenworkBundleInviteIntent(input.intent);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE, bundleUrl);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_INTENT, intent);
|
||||
|
||||
const source = input.source?.trim() ?? "";
|
||||
if (source) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE, source);
|
||||
}
|
||||
|
||||
const orgId = input.orgId?.trim() ?? "";
|
||||
if (orgId) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_ORG, orgId);
|
||||
}
|
||||
|
||||
const label = input.label?.trim() ?? "";
|
||||
if (label) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_LABEL, label);
|
||||
}
|
||||
|
||||
return `${DEFAULT_OPENWORK_CONNECT_APP_URL}?${search.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenworkBundleInviteFromSearch(input: string | URLSearchParams) {
|
||||
const search =
|
||||
typeof input === "string"
|
||||
? new URLSearchParams(input.startsWith("?") ? input.slice(1) : input)
|
||||
: input;
|
||||
|
||||
const rawBundleUrl = search.get(OPENWORK_INVITE_PARAM_BUNDLE)?.trim() ?? "";
|
||||
if (!rawBundleUrl) return null;
|
||||
|
||||
let bundleUrl: string;
|
||||
try {
|
||||
const parsed = new URL(rawBundleUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
bundleUrl = parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = normalizeOpenworkBundleInviteIntent(search.get(OPENWORK_INVITE_PARAM_BUNDLE_INTENT));
|
||||
const source = search.get(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE)?.trim() ?? "";
|
||||
const orgId = search.get(OPENWORK_INVITE_PARAM_BUNDLE_ORG)?.trim() ?? "";
|
||||
const label = search.get(OPENWORK_INVITE_PARAM_BUNDLE_LABEL)?.trim() ?? "";
|
||||
|
||||
return {
|
||||
bundleUrl,
|
||||
intent,
|
||||
source: source || undefined,
|
||||
orgId: orgId || undefined,
|
||||
label: label || undefined,
|
||||
} satisfies OpenworkBundleInvite;
|
||||
}
|
||||
|
||||
export function stripOpenworkConnectInviteFromUrl(input: string) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
@@ -806,20 +573,6 @@ export function stripOpenworkConnectInviteFromUrl(input: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function stripOpenworkBundleInviteFromUrl(input: string) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_INTENT);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_ORG);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_LABEL);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenworkServerSettings(): OpenworkServerSettings {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
@@ -1188,6 +941,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
opencodeRouter: 10_000,
|
||||
workspaceExport: 30_000,
|
||||
workspaceImport: 30_000,
|
||||
shareBundle: 20_000,
|
||||
binary: 60_000,
|
||||
};
|
||||
|
||||
@@ -1202,14 +956,12 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
capabilities: () => requestJson<OpenworkServerCapabilities>(baseUrl, "/capabilities", { token, hostToken, timeoutMs: timeouts.capabilities }),
|
||||
opencodeRouterHealth: () =>
|
||||
requestJsonRaw<OpenworkOpenCodeRouterHealthSnapshot>(baseUrl, "/opencode-router/health", { token, hostToken, timeoutMs: timeouts.opencodeRouter }),
|
||||
getOpenCodeRouterHealth: (workspaceId: string, options?: { healthPort?: number | null }) => {
|
||||
const query = typeof options?.healthPort === "number" ? `?healthPort=${encodeURIComponent(String(options.healthPort))}` : "";
|
||||
return requestJsonRaw<OpenworkOpenCodeRouterHealthSnapshot>(
|
||||
getOpenCodeRouterHealth: (workspaceId: string) =>
|
||||
requestJsonRaw<OpenworkOpenCodeRouterHealthSnapshot>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/health${query}`,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/health`,
|
||||
{ token, hostToken, timeoutMs: timeouts.opencodeRouter },
|
||||
);
|
||||
},
|
||||
),
|
||||
opencodeRouterBindings: (filters?: { channel?: string; identityId?: string }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (filters?.channel?.trim()) search.set("channel", filters.channel.trim());
|
||||
@@ -1257,12 +1009,21 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession },
|
||||
),
|
||||
exportWorkspace: (workspaceId: string) =>
|
||||
requestJson<OpenworkWorkspaceExport>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/export`, {
|
||||
exportWorkspace: (
|
||||
workspaceId: string,
|
||||
options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode },
|
||||
) => {
|
||||
const query = new URLSearchParams();
|
||||
if (options?.sensitiveMode) {
|
||||
query.set("sensitive", options.sensitiveMode);
|
||||
}
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
return requestJson<OpenworkWorkspaceExport>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/export${suffix}`, {
|
||||
token,
|
||||
hostToken,
|
||||
timeoutMs: timeouts.workspaceExport,
|
||||
}),
|
||||
});
|
||||
},
|
||||
importWorkspace: (workspaceId: string, payload: Record<string, unknown>) =>
|
||||
requestJson<{ ok: boolean }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/import`, {
|
||||
token,
|
||||
@@ -1271,6 +1032,41 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
body: payload,
|
||||
timeoutMs: timeouts.workspaceImport,
|
||||
}),
|
||||
materializeBlueprintSessions: (workspaceId: string) =>
|
||||
requestJson<OpenworkBlueprintSessionsMaterializeResult>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/blueprint/sessions/materialize`,
|
||||
{
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
timeoutMs: timeouts.workspaceImport,
|
||||
},
|
||||
),
|
||||
publishBundle: (payload: unknown, bundleType: "skill" | "workspace-profile" | "skills-set", options?: { name?: string; timeoutMs?: number }) =>
|
||||
requestJson<{ url: string }>(baseUrl, "/share/bundles/publish", {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: {
|
||||
payload,
|
||||
bundleType,
|
||||
name: options?.name,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
},
|
||||
timeoutMs: options?.timeoutMs ?? timeouts.shareBundle,
|
||||
}),
|
||||
fetchBundle: (bundleUrl: string, options?: { timeoutMs?: number }) =>
|
||||
requestJson<Record<string, unknown>>(baseUrl, "/share/bundles/fetch", {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: {
|
||||
bundleUrl,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
},
|
||||
timeoutMs: options?.timeoutMs ?? timeouts.shareBundle,
|
||||
}),
|
||||
getConfig: (workspaceId: string) =>
|
||||
requestJson<{ opencode: Record<string, unknown>; openwork: Record<string, unknown>; updatedAt?: number | null }>(
|
||||
baseUrl,
|
||||
@@ -1280,7 +1076,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
setOpenCodeRouterTelegramToken: (
|
||||
workspaceId: string,
|
||||
tokenValue: string,
|
||||
healthPort?: number | null,
|
||||
) =>
|
||||
requestJson<OpenworkOpenCodeRouterTelegramResult>(
|
||||
baseUrl,
|
||||
@@ -1289,7 +1084,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { token: tokenValue, healthPort },
|
||||
body: { token: tokenValue },
|
||||
timeoutMs: timeouts.opencodeRouter,
|
||||
},
|
||||
),
|
||||
@@ -1297,7 +1092,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
workspaceId: string,
|
||||
botToken: string,
|
||||
appToken: string,
|
||||
healthPort?: number | null,
|
||||
) =>
|
||||
requestJson<OpenworkOpenCodeRouterSlackResult>(
|
||||
baseUrl,
|
||||
@@ -1306,7 +1100,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { botToken, appToken, healthPort },
|
||||
body: { botToken, appToken },
|
||||
timeoutMs: timeouts.opencodeRouter,
|
||||
},
|
||||
),
|
||||
@@ -1316,18 +1110,15 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/telegram`,
|
||||
{ token, hostToken, timeoutMs: timeouts.opencodeRouter },
|
||||
),
|
||||
getOpenCodeRouterTelegramIdentities: (workspaceId: string, options?: { healthPort?: number | null }) => {
|
||||
const query = typeof options?.healthPort === "number" ? `?healthPort=${encodeURIComponent(String(options.healthPort))}` : "";
|
||||
return requestJson<OpenworkOpenCodeRouterTelegramIdentitiesResult>(
|
||||
getOpenCodeRouterTelegramIdentities: (workspaceId: string) =>
|
||||
requestJson<OpenworkOpenCodeRouterTelegramIdentitiesResult>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/telegram${query}`,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/telegram`,
|
||||
{ token, hostToken, timeoutMs: timeouts.opencodeRouter },
|
||||
);
|
||||
},
|
||||
),
|
||||
upsertOpenCodeRouterTelegramIdentity: (
|
||||
workspaceId: string,
|
||||
input: { id?: string; token: string; enabled?: boolean; access?: "public" | "private"; pairingCode?: string },
|
||||
options?: { healthPort?: number | null },
|
||||
) =>
|
||||
requestJson<OpenworkOpenCodeRouterTelegramIdentityUpsertResult>(
|
||||
baseUrl,
|
||||
@@ -1342,30 +1133,24 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
...(typeof input.enabled === "boolean" ? { enabled: input.enabled } : {}),
|
||||
...(input.access ? { access: input.access } : {}),
|
||||
...(input.pairingCode?.trim() ? { pairingCode: input.pairingCode.trim() } : {}),
|
||||
healthPort: options?.healthPort ?? null,
|
||||
},
|
||||
},
|
||||
),
|
||||
deleteOpenCodeRouterTelegramIdentity: (workspaceId: string, identityId: string, options?: { healthPort?: number | null }) => {
|
||||
const query = typeof options?.healthPort === "number" ? `?healthPort=${encodeURIComponent(String(options.healthPort))}` : "";
|
||||
return requestJson<OpenworkOpenCodeRouterTelegramIdentityDeleteResult>(
|
||||
deleteOpenCodeRouterTelegramIdentity: (workspaceId: string, identityId: string) =>
|
||||
requestJson<OpenworkOpenCodeRouterTelegramIdentityDeleteResult>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/telegram/${encodeURIComponent(identityId)}${query}`,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/telegram/${encodeURIComponent(identityId)}`,
|
||||
{ token, hostToken, method: "DELETE" },
|
||||
);
|
||||
},
|
||||
getOpenCodeRouterSlackIdentities: (workspaceId: string, options?: { healthPort?: number | null }) => {
|
||||
const query = typeof options?.healthPort === "number" ? `?healthPort=${encodeURIComponent(String(options.healthPort))}` : "";
|
||||
return requestJson<OpenworkOpenCodeRouterSlackIdentitiesResult>(
|
||||
),
|
||||
getOpenCodeRouterSlackIdentities: (workspaceId: string) =>
|
||||
requestJson<OpenworkOpenCodeRouterSlackIdentitiesResult>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/slack${query}`,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/slack`,
|
||||
{ token, hostToken },
|
||||
);
|
||||
},
|
||||
),
|
||||
upsertOpenCodeRouterSlackIdentity: (
|
||||
workspaceId: string,
|
||||
input: { id?: string; botToken: string; appToken: string; enabled?: boolean },
|
||||
options?: { healthPort?: number | null },
|
||||
) =>
|
||||
requestJson<OpenworkOpenCodeRouterSlackIdentityUpsertResult>(
|
||||
baseUrl,
|
||||
@@ -1379,26 +1164,22 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
botToken: input.botToken,
|
||||
appToken: input.appToken,
|
||||
...(typeof input.enabled === "boolean" ? { enabled: input.enabled } : {}),
|
||||
healthPort: options?.healthPort ?? null,
|
||||
},
|
||||
},
|
||||
),
|
||||
deleteOpenCodeRouterSlackIdentity: (workspaceId: string, identityId: string, options?: { healthPort?: number | null }) => {
|
||||
const query = typeof options?.healthPort === "number" ? `?healthPort=${encodeURIComponent(String(options.healthPort))}` : "";
|
||||
return requestJson<OpenworkOpenCodeRouterSlackIdentityDeleteResult>(
|
||||
deleteOpenCodeRouterSlackIdentity: (workspaceId: string, identityId: string) =>
|
||||
requestJson<OpenworkOpenCodeRouterSlackIdentityDeleteResult>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/slack/${encodeURIComponent(identityId)}${query}`,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/opencode-router/identities/slack/${encodeURIComponent(identityId)}`,
|
||||
{ token, hostToken, method: "DELETE" },
|
||||
);
|
||||
},
|
||||
),
|
||||
getOpenCodeRouterBindings: (
|
||||
workspaceId: string,
|
||||
filters?: { channel?: string; identityId?: string; healthPort?: number | null },
|
||||
filters?: { channel?: string; identityId?: string },
|
||||
) => {
|
||||
const search = new URLSearchParams();
|
||||
if (filters?.channel?.trim()) search.set("channel", filters.channel.trim());
|
||||
if (filters?.identityId?.trim()) search.set("identityId", filters.identityId.trim());
|
||||
if (typeof filters?.healthPort === "number") search.set("healthPort", String(filters.healthPort));
|
||||
const suffix = search.toString();
|
||||
return requestJson<OpenworkOpenCodeRouterBindingsResult>(
|
||||
baseUrl,
|
||||
@@ -1409,7 +1190,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
setOpenCodeRouterBinding: (
|
||||
workspaceId: string,
|
||||
input: { channel: string; identityId?: string; peerId: string; directory?: string },
|
||||
options?: { healthPort?: number | null },
|
||||
) =>
|
||||
requestJson<OpenworkOpenCodeRouterBindingUpdateResult>(
|
||||
baseUrl,
|
||||
@@ -1423,7 +1203,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
...(input.identityId?.trim() ? { identityId: input.identityId.trim() } : {}),
|
||||
peerId: input.peerId,
|
||||
...(input.directory?.trim() ? { directory: input.directory.trim() } : {}),
|
||||
healthPort: options?.healthPort ?? null,
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -1437,7 +1216,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
peerId?: string;
|
||||
autoBind?: boolean;
|
||||
},
|
||||
options?: { healthPort?: number | null },
|
||||
) => {
|
||||
const payload = {
|
||||
channel: input.channel,
|
||||
@@ -1446,7 +1224,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
...(input.directory?.trim() ? { directory: input.directory.trim() } : {}),
|
||||
...(input.peerId?.trim() ? { peerId: input.peerId.trim() } : {}),
|
||||
...(input.autoBind === true ? { autoBind: true } : {}),
|
||||
healthPort: options?.healthPort ?? null,
|
||||
};
|
||||
|
||||
const primaryPath = `/workspace/${encodeURIComponent(workspaceId)}/opencode-router/send`;
|
||||
@@ -1478,7 +1255,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
setOpenCodeRouterTelegramEnabled: (
|
||||
workspaceId: string,
|
||||
enabled: boolean,
|
||||
options?: { clearToken?: boolean; healthPort?: number | null },
|
||||
options?: { clearToken?: boolean },
|
||||
) =>
|
||||
requestJson<OpenworkOpenCodeRouterTelegramEnabledResult>(
|
||||
baseUrl,
|
||||
@@ -1487,7 +1264,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { enabled, clearToken: options?.clearToken ?? false, healthPort: options?.healthPort ?? null },
|
||||
body: { enabled, clearToken: options?.clearToken ?? false },
|
||||
},
|
||||
),
|
||||
patchConfig: (workspaceId: string, payload: { opencode?: Record<string, unknown>; openwork?: Record<string, unknown> }) =>
|
||||
@@ -1733,103 +1510,6 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
{ token, hostToken, timeoutMs: timeouts.binary },
|
||||
),
|
||||
|
||||
createFileSession: (workspaceId: string, options?: { ttlSeconds?: number; write?: boolean }) =>
|
||||
requestJson<{ session: OpenworkFileSession }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/files/sessions`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: {
|
||||
...(typeof options?.ttlSeconds === "number" ? { ttlSeconds: options.ttlSeconds } : {}),
|
||||
...(typeof options?.write === "boolean" ? { write: options.write } : {}),
|
||||
},
|
||||
}),
|
||||
|
||||
renewFileSession: (sessionId: string, options?: { ttlSeconds?: number }) =>
|
||||
requestJson<{ session: OpenworkFileSession }>(baseUrl, `/files/sessions/${encodeURIComponent(sessionId)}/renew`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: {
|
||||
...(typeof options?.ttlSeconds === "number" ? { ttlSeconds: options.ttlSeconds } : {}),
|
||||
},
|
||||
}),
|
||||
|
||||
closeFileSession: (sessionId: string) =>
|
||||
requestJson<{ ok: boolean }>(baseUrl, `/files/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
getFileCatalogSnapshot: (
|
||||
sessionId: string,
|
||||
options?: { prefix?: string; after?: string; includeDirs?: boolean; limit?: number },
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.prefix?.trim()) params.set("prefix", options.prefix.trim());
|
||||
if (options?.after?.trim()) params.set("after", options.after.trim());
|
||||
if (typeof options?.includeDirs === "boolean") params.set("includeDirs", options.includeDirs ? "true" : "false");
|
||||
if (typeof options?.limit === "number") params.set("limit", String(options.limit));
|
||||
const query = params.toString();
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
generatedAt: number;
|
||||
cursor: number;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
nextAfter?: string;
|
||||
items: OpenworkFileCatalogEntry[];
|
||||
}>(
|
||||
baseUrl,
|
||||
`/files/sessions/${encodeURIComponent(sessionId)}/catalog/snapshot${query ? `?${query}` : ""}`,
|
||||
{ token, hostToken },
|
||||
);
|
||||
},
|
||||
|
||||
listFileSessionEvents: (sessionId: string, options?: { since?: number }) => {
|
||||
const query = typeof options?.since === "number" ? `?since=${encodeURIComponent(String(options.since))}` : "";
|
||||
return requestJson<{ items: OpenworkFileSessionEvent[]; cursor: number }>(
|
||||
baseUrl,
|
||||
`/files/sessions/${encodeURIComponent(sessionId)}/catalog/events${query}`,
|
||||
{ token, hostToken },
|
||||
);
|
||||
},
|
||||
|
||||
readFileBatch: (sessionId: string, paths: string[]) =>
|
||||
requestJson<OpenworkFileReadBatchResult>(baseUrl, `/files/sessions/${encodeURIComponent(sessionId)}/read-batch`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { paths },
|
||||
}),
|
||||
|
||||
writeFileBatch: (
|
||||
sessionId: string,
|
||||
writes: Array<{ path: string; contentBase64: string; ifMatchRevision?: string; force?: boolean }>,
|
||||
) =>
|
||||
requestJson<OpenworkFileWriteBatchResult>(baseUrl, `/files/sessions/${encodeURIComponent(sessionId)}/write-batch`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { writes },
|
||||
}),
|
||||
|
||||
runFileBatchOps: (
|
||||
sessionId: string,
|
||||
operations: Array<
|
||||
| { type: "mkdir"; path: string }
|
||||
| { type: "delete"; path: string; recursive?: boolean }
|
||||
| { type: "rename"; from: string; to: string }
|
||||
>,
|
||||
) =>
|
||||
requestJson<OpenworkFileOpsBatchResult>(baseUrl, `/files/sessions/${encodeURIComponent(sessionId)}/ops`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { operations },
|
||||
}),
|
||||
|
||||
readWorkspaceFile: (workspaceId: string, path: string) =>
|
||||
requestJson<OpenworkWorkspaceFileContent>(
|
||||
baseUrl,
|
||||
|
||||
@@ -7,7 +7,7 @@ export type PublishBundleResult = {
|
||||
const ENV_OPENWORK_PUBLISHER_BASE_URL = String(import.meta.env.VITE_OPENWORK_PUBLISHER_BASE_URL ?? "").trim();
|
||||
|
||||
export const DEFAULT_OPENWORK_PUBLISHER_BASE_URL =
|
||||
ENV_OPENWORK_PUBLISHER_BASE_URL || "https://share.openwork.software";
|
||||
ENV_OPENWORK_PUBLISHER_BASE_URL || "https://share.openworklabs.com";
|
||||
|
||||
function normalizeBaseUrl(input: string): string {
|
||||
const trimmed = String(input ?? "").trim();
|
||||
|
||||
@@ -118,6 +118,8 @@ export type WorkspaceInfo = {
|
||||
displayName?: string | null;
|
||||
openworkHostUrl?: string | null;
|
||||
openworkToken?: string | null;
|
||||
openworkClientToken?: string | null;
|
||||
openworkHostToken?: string | null;
|
||||
openworkWorkspaceId?: string | null;
|
||||
openworkWorkspaceName?: string | null;
|
||||
|
||||
@@ -128,10 +130,21 @@ export type WorkspaceInfo = {
|
||||
};
|
||||
|
||||
export type WorkspaceList = {
|
||||
activeId: string;
|
||||
// UI-selected workspace persisted by the desktop shell.
|
||||
selectedId?: string;
|
||||
// Runtime/watch target currently followed by the desktop host.
|
||||
watchedId?: string | null;
|
||||
// Legacy desktop payloads used activeId for the UI-selected workspace.
|
||||
activeId?: string | null;
|
||||
workspaces: WorkspaceInfo[];
|
||||
};
|
||||
|
||||
export function resolveWorkspaceListSelectedId(
|
||||
list: Pick<WorkspaceList, "selectedId" | "activeId"> | null | undefined,
|
||||
): string {
|
||||
return list?.selectedId?.trim() || list?.activeId?.trim() || "";
|
||||
}
|
||||
|
||||
export type WorkspaceExportSummary = {
|
||||
outputPath: string;
|
||||
included: number;
|
||||
@@ -164,8 +177,12 @@ export async function workspaceBootstrap(): Promise<WorkspaceList> {
|
||||
return invoke<WorkspaceList>("workspace_bootstrap");
|
||||
}
|
||||
|
||||
export async function workspaceSetActive(workspaceId: string): Promise<WorkspaceList> {
|
||||
return invoke<WorkspaceList>("workspace_set_active", { workspaceId });
|
||||
export async function workspaceSetSelected(workspaceId: string): Promise<WorkspaceList> {
|
||||
return invoke<WorkspaceList>("workspace_set_selected", { workspaceId });
|
||||
}
|
||||
|
||||
export async function workspaceSetRuntimeActive(workspaceId: string | null): Promise<WorkspaceList> {
|
||||
return invoke<WorkspaceList>("workspace_set_runtime_active", { workspaceId: workspaceId ?? "" });
|
||||
}
|
||||
|
||||
export async function workspaceCreate(input: {
|
||||
@@ -187,6 +204,8 @@ export async function workspaceCreateRemote(input: {
|
||||
remoteType?: "openwork" | "opencode" | null;
|
||||
openworkHostUrl?: string | null;
|
||||
openworkToken?: string | null;
|
||||
openworkClientToken?: string | null;
|
||||
openworkHostToken?: string | null;
|
||||
openworkWorkspaceId?: string | null;
|
||||
openworkWorkspaceName?: string | null;
|
||||
|
||||
@@ -202,6 +221,8 @@ export async function workspaceCreateRemote(input: {
|
||||
remoteType: input.remoteType ?? null,
|
||||
openworkHostUrl: input.openworkHostUrl ?? null,
|
||||
openworkToken: input.openworkToken ?? null,
|
||||
openworkClientToken: input.openworkClientToken ?? null,
|
||||
openworkHostToken: input.openworkHostToken ?? null,
|
||||
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
|
||||
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
|
||||
sandboxBackend: input.sandboxBackend ?? null,
|
||||
@@ -218,6 +239,8 @@ export async function workspaceUpdateRemote(input: {
|
||||
remoteType?: "openwork" | "opencode" | null;
|
||||
openworkHostUrl?: string | null;
|
||||
openworkToken?: string | null;
|
||||
openworkClientToken?: string | null;
|
||||
openworkHostToken?: string | null;
|
||||
openworkWorkspaceId?: string | null;
|
||||
openworkWorkspaceName?: string | null;
|
||||
|
||||
@@ -234,6 +257,8 @@ export async function workspaceUpdateRemote(input: {
|
||||
remoteType: input.remoteType ?? null,
|
||||
openworkHostUrl: input.openworkHostUrl ?? null,
|
||||
openworkToken: input.openworkToken ?? null,
|
||||
openworkClientToken: input.openworkClientToken ?? null,
|
||||
openworkHostToken: input.openworkHostToken ?? null,
|
||||
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
|
||||
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
|
||||
sandboxBackend: input.sandboxBackend ?? null,
|
||||
@@ -406,8 +431,8 @@ export async function appBuildInfo(): Promise<AppBuildInfo> {
|
||||
return invoke<AppBuildInfo>("app_build_info");
|
||||
}
|
||||
|
||||
export async function nukeOpencodeDevConfigAndExit(): Promise<void> {
|
||||
return invoke<void>("nuke_opencode_dev_config_and_exit");
|
||||
export async function nukeOpenworkAndOpencodeConfigAndExit(): Promise<void> {
|
||||
return invoke<void>("nuke_openwork_and_opencode_config_and_exit");
|
||||
}
|
||||
|
||||
export type OrchestratorDetachedHost = {
|
||||
@@ -737,63 +762,6 @@ export async function resetOpencodeCache(): Promise<CacheResetResult> {
|
||||
return invoke<CacheResetResult>("reset_opencode_cache");
|
||||
}
|
||||
|
||||
export async function obsidianIsAvailable(): Promise<boolean> {
|
||||
return invoke<boolean>("obsidian_is_available");
|
||||
}
|
||||
|
||||
export async function openInObsidian(filePath: string): Promise<void> {
|
||||
const safePath = filePath.trim();
|
||||
if (!safePath) {
|
||||
throw new Error("filePath is required");
|
||||
}
|
||||
return invoke<void>("open_in_obsidian", { filePath: safePath });
|
||||
}
|
||||
|
||||
export async function writeObsidianMirrorFile(
|
||||
workspaceId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const safeWorkspaceId = workspaceId.trim();
|
||||
const safePath = filePath.trim();
|
||||
if (!safeWorkspaceId) {
|
||||
throw new Error("workspaceId is required");
|
||||
}
|
||||
if (!safePath) {
|
||||
throw new Error("filePath is required");
|
||||
}
|
||||
return invoke<string>("write_obsidian_mirror_file", {
|
||||
workspaceId: safeWorkspaceId,
|
||||
filePath: safePath,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
export type ObsidianMirrorFileContent = {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
content: string | null;
|
||||
updatedAtMs: number | null;
|
||||
};
|
||||
|
||||
export async function readObsidianMirrorFile(
|
||||
workspaceId: string,
|
||||
filePath: string,
|
||||
): Promise<ObsidianMirrorFileContent> {
|
||||
const safeWorkspaceId = workspaceId.trim();
|
||||
const safePath = filePath.trim();
|
||||
if (!safeWorkspaceId) {
|
||||
throw new Error("workspaceId is required");
|
||||
}
|
||||
if (!safePath) {
|
||||
throw new Error("filePath is required");
|
||||
}
|
||||
return invoke<ObsidianMirrorFileContent>("read_obsidian_mirror_file", {
|
||||
workspaceId: safeWorkspaceId,
|
||||
filePath: safePath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function schedulerListJobs(scopeRoot?: string): Promise<ScheduledJob[]> {
|
||||
return invoke<ScheduledJob[]>("scheduler_list_jobs", { scopeRoot });
|
||||
}
|
||||
@@ -896,23 +864,6 @@ export async function setOpenCodeRouterGroupsEnabled(enabled: boolean): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function opencodeDbMigrate(input: {
|
||||
projectDir: string;
|
||||
preferSidecar?: boolean;
|
||||
opencodeBinPath?: string | null;
|
||||
}): Promise<ExecResult> {
|
||||
const safeProjectDir = input.projectDir.trim();
|
||||
if (!safeProjectDir) {
|
||||
throw new Error("project_dir is required");
|
||||
}
|
||||
|
||||
return invoke<ExecResult>("opencode_db_migrate", {
|
||||
projectDir: safeProjectDir,
|
||||
preferSidecar: input.preferSidecar ?? false,
|
||||
opencodeBinPath: input.opencodeBinPath ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function opencodeMcpAuth(
|
||||
projectDir: string,
|
||||
serverName: string,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { WorkspaceBlueprint, WorkspaceBlueprintStarter, WorkspaceOpenworkConfig } from "../types";
|
||||
import type {
|
||||
WorkspaceBlueprint,
|
||||
WorkspaceBlueprintMaterializedSession,
|
||||
WorkspaceBlueprintSessionMessage,
|
||||
WorkspaceBlueprintSessionTemplate,
|
||||
WorkspaceBlueprintStarter,
|
||||
WorkspaceOpenworkConfig,
|
||||
} from "../types";
|
||||
import { parseTemplateFrontmatter } from "../utils";
|
||||
|
||||
import browserSetupTemplate from "../data/commands/browser-setup.md?raw";
|
||||
@@ -13,6 +20,108 @@ export const DEFAULT_EMPTY_STATE_COPY = {
|
||||
body: "Pick a starting point or just type below.",
|
||||
};
|
||||
|
||||
const DEFAULT_WELCOME_BLUEPRINT_MESSAGES: WorkspaceBlueprintSessionMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
text:
|
||||
"Hi welcome to OpenWork!\n\nPeople use us to write .csv files on their computer, connect to Chrome and automate repetitive tasks, and sync contacts to Notion.\n\nBut the only limit is your imagination.\n\nWhat would you want to do?",
|
||||
},
|
||||
];
|
||||
|
||||
export function defaultBlueprintSessionsForPreset(_preset: string): WorkspaceBlueprintSessionTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: "welcome-to-openwork",
|
||||
title: "Welcome to OpenWork",
|
||||
messages: DEFAULT_WELCOME_BLUEPRINT_MESSAGES,
|
||||
openOnFirstLoad: true,
|
||||
},
|
||||
{
|
||||
id: "csv-playbook",
|
||||
title: "CSV workflow ideas",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
text: "I can help you generate, clean, merge, and summarize CSV files. What kind of CSV work do you want to automate?",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
text: "I want to combine exports from multiple tools into one clean CSV.",
|
||||
},
|
||||
],
|
||||
openOnFirstLoad: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeSessionMessage(value: unknown): WorkspaceBlueprintSessionMessage | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const text = typeof record.text === "string" ? record.text.trim() : "";
|
||||
if (!text) return null;
|
||||
const role = String(record.role ?? "assistant").trim().toLowerCase() === "user" ? "user" : "assistant";
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
function normalizeSessionTemplate(value: unknown, index: number): WorkspaceBlueprintSessionTemplate | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const title = typeof record.title === "string" ? record.title.trim() : "";
|
||||
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : `template-session-${index + 1}`;
|
||||
const messages = Array.isArray(record.messages)
|
||||
? record.messages.map(normalizeSessionMessage).filter((item): item is WorkspaceBlueprintSessionMessage => Boolean(item))
|
||||
: [];
|
||||
if (!title && messages.length === 0) return null;
|
||||
return {
|
||||
id,
|
||||
title: title || null,
|
||||
messages,
|
||||
openOnFirstLoad: record.openOnFirstLoad === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMaterializedSession(value: unknown): WorkspaceBlueprintMaterializedSession | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : "";
|
||||
const templateId = typeof record.templateId === "string" ? record.templateId.trim() : "";
|
||||
if (!sessionId || !templateId) return null;
|
||||
return { sessionId, templateId };
|
||||
}
|
||||
|
||||
function normalizeBlueprint(value: unknown): WorkspaceBlueprint | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const candidate = value as WorkspaceBlueprint & Record<string, unknown>;
|
||||
const sessions = Array.isArray(candidate.sessions)
|
||||
? candidate.sessions
|
||||
.map((session, index) => normalizeSessionTemplate(session, index))
|
||||
.filter((item): item is WorkspaceBlueprintSessionTemplate => Boolean(item))
|
||||
: null;
|
||||
const materializedSessions = Array.isArray(candidate.materialized?.sessions?.items)
|
||||
? candidate.materialized?.sessions?.items
|
||||
.map(normalizeMaterializedSession)
|
||||
.filter((item): item is WorkspaceBlueprintMaterializedSession => Boolean(item))
|
||||
: null;
|
||||
|
||||
return {
|
||||
emptyState: candidate.emptyState ?? null,
|
||||
sessions,
|
||||
materialized: candidate.materialized
|
||||
? {
|
||||
sessions: candidate.materialized.sessions
|
||||
? {
|
||||
hydratedAt:
|
||||
typeof candidate.materialized.sessions.hydratedAt === "number"
|
||||
? candidate.materialized.sessions.hydratedAt
|
||||
: null,
|
||||
items: materializedSessions,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlueprintStarter[] {
|
||||
switch (preset.trim().toLowerCase()) {
|
||||
case "automation":
|
||||
@@ -46,6 +155,13 @@ export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlue
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{
|
||||
id: "csv-help",
|
||||
kind: "prompt",
|
||||
title: "Work on a CSV",
|
||||
description: "Clean up or generate spreadsheet data.",
|
||||
prompt: "Help me create or edit CSV files on this computer.",
|
||||
},
|
||||
{
|
||||
id: "starter-connect-openai",
|
||||
kind: "action",
|
||||
@@ -54,11 +170,11 @@ export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlue
|
||||
action: "connect-openai",
|
||||
},
|
||||
{
|
||||
id: "starter-browser",
|
||||
id: "browser-automation",
|
||||
kind: "session",
|
||||
title: "Automate your browser",
|
||||
description: "Set up browser actions and run reliable web tasks from OpenWork.",
|
||||
prompt: BROWSER_AUTOMATION_QUICKSTART_PROMPT,
|
||||
title: "Automate Chrome",
|
||||
description: "Start a browser automation conversation right away.",
|
||||
prompt: "Help me connect to Chrome and automate a repetitive task.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -89,9 +205,22 @@ export function buildDefaultWorkspaceBlueprint(preset: string): WorkspaceBluepri
|
||||
body: copy.body,
|
||||
starters: defaultBlueprintStartersForPreset(preset),
|
||||
},
|
||||
sessions: defaultBlueprintSessionsForPreset(preset),
|
||||
};
|
||||
}
|
||||
|
||||
export function blueprintSessions(config: WorkspaceOpenworkConfig | null | undefined): WorkspaceBlueprintSessionTemplate[] {
|
||||
return Array.isArray(config?.blueprint?.sessions)
|
||||
? config!.blueprint!.sessions!.filter((item): item is WorkspaceBlueprintSessionTemplate => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
export function blueprintMaterializedSessions(config: WorkspaceOpenworkConfig | null | undefined): WorkspaceBlueprintMaterializedSession[] {
|
||||
return Array.isArray(config?.blueprint?.materialized?.sessions?.items)
|
||||
? config!.blueprint!.materialized!.sessions!.items!.filter((item): item is WorkspaceBlueprintMaterializedSession => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceOpenworkConfig(
|
||||
value: unknown,
|
||||
preset?: string | null,
|
||||
@@ -116,7 +245,7 @@ export function normalizeWorkspaceOpenworkConfig(
|
||||
authorizedRoots: Array.isArray(candidate.authorizedRoots)
|
||||
? candidate.authorizedRoots.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
blueprint: candidate.blueprint ?? null,
|
||||
blueprint: normalizeBlueprint(candidate.blueprint),
|
||||
reload: candidate.reload ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
1032
apps/app/src/app/pages/automations.tsx
Normal file
1032
apps/app/src/app/pages/automations.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ export type ConfigViewProps = {
|
||||
openworkServerUrl: string;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
openworkServerHostInfo: OpenworkServerInfo | null;
|
||||
openworkServerWorkspaceId: string | null;
|
||||
runtimeWorkspaceId: string | null;
|
||||
|
||||
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
|
||||
resetOpenworkServerSettings: () => void;
|
||||
@@ -112,7 +112,7 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
});
|
||||
|
||||
const resolvedWorkspaceId = createMemo(() => {
|
||||
const explicitId = props.openworkServerWorkspaceId?.trim() ?? "";
|
||||
const explicitId = props.runtimeWorkspaceId?.trim() ?? "";
|
||||
if (explicitId) return explicitId;
|
||||
return parseOpenworkWorkspaceIdFromUrl(openworkUrl()) ?? "";
|
||||
});
|
||||
@@ -153,7 +153,7 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
developerMode: props.developerMode,
|
||||
},
|
||||
workspace: {
|
||||
openworkServerWorkspaceId: props.openworkServerWorkspaceId ?? null,
|
||||
runtimeWorkspaceId: props.runtimeWorkspaceId ?? null,
|
||||
clientConnected: props.clientConnected,
|
||||
anyActiveRuns: props.anyActiveRuns,
|
||||
},
|
||||
@@ -222,11 +222,11 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-2">
|
||||
<div class="text-sm font-medium text-gray-12">Workspace config</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
These settings affect the active workspace (sharing, reload, bots). Global app behavior lives in Settings.
|
||||
These settings affect the selected workspace. Runtime-only actions apply to whichever workspace is currently connected.
|
||||
</div>
|
||||
<Show when={props.openworkServerWorkspaceId}>
|
||||
<Show when={props.runtimeWorkspaceId}>
|
||||
<div class="text-[11px] text-gray-7 font-mono truncate">
|
||||
Workspace: {props.openworkServerWorkspaceId}
|
||||
Workspace: {props.runtimeWorkspaceId}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -491,8 +491,8 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
label="OpenWork server URL"
|
||||
value={openworkUrl()}
|
||||
onInput={(event) => setOpenworkUrl(event.currentTarget.value)}
|
||||
placeholder="http://127.0.0.1:8787"
|
||||
hint="Use the URL shared by your OpenWork server."
|
||||
placeholder="http://127.0.0.1:<port>"
|
||||
hint="Use the URL shared by your OpenWork server. Local desktop workers reuse a persistent high port in the 48000-51000 range."
|
||||
disabled={props.busy}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,19 +3,23 @@ import { Show, createEffect, createMemo, createSignal, on } from "solid-js";
|
||||
import { Box, Cpu } from "lucide-solid";
|
||||
|
||||
import Button from "../components/button";
|
||||
import McpView, { type McpViewProps } from "./mcp";
|
||||
import McpView from "../connections/mcp-view";
|
||||
import { useConnections } from "../connections/provider";
|
||||
import { useExtensions } from "../extensions/provider";
|
||||
import PluginsView, { type PluginsViewProps } from "./plugins";
|
||||
|
||||
export type ExtensionsSection = "all" | "mcp" | "plugins";
|
||||
|
||||
export type ExtensionsViewProps = McpViewProps &
|
||||
PluginsViewProps & {
|
||||
refreshMcpServers: () => void;
|
||||
initialSection?: ExtensionsSection;
|
||||
setDashboardTab?: (tab: "mcp" | "plugins") => void;
|
||||
};
|
||||
export type ExtensionsViewProps = PluginsViewProps & {
|
||||
isRemoteWorkspace: boolean;
|
||||
initialSection?: ExtensionsSection;
|
||||
setSectionRoute?: (tab: "mcp" | "plugins") => void;
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
export default function ExtensionsView(props: ExtensionsViewProps) {
|
||||
const connections = useConnections();
|
||||
const extensions = useExtensions();
|
||||
const [section, setSection] = createSignal<ExtensionsSection>(props.initialSection ?? "all");
|
||||
|
||||
createEffect(
|
||||
@@ -29,24 +33,24 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
|
||||
);
|
||||
|
||||
const connectedAppsCount = createMemo(() =>
|
||||
props.mcpServers.filter((entry) => {
|
||||
connections.mcpServers().filter((entry) => {
|
||||
if (entry.config.enabled === false) return false;
|
||||
const status = props.mcpStatuses[entry.name];
|
||||
const status = connections.mcpStatuses()[entry.name];
|
||||
return status?.status === "connected";
|
||||
}).length,
|
||||
);
|
||||
|
||||
const pluginCount = createMemo(() => props.pluginList.length);
|
||||
const pluginCount = createMemo(() => extensions.pluginList().length);
|
||||
|
||||
const refreshAll = () => {
|
||||
props.refreshMcpServers();
|
||||
props.refreshPlugins();
|
||||
void connections.refreshMcpServers();
|
||||
void extensions.refreshPlugins();
|
||||
};
|
||||
|
||||
const selectSection = (nextSection: ExtensionsSection) => {
|
||||
setSection(nextSection);
|
||||
if (nextSection === "mcp" || nextSection === "plugins") {
|
||||
props.setDashboardTab?.(nextSection);
|
||||
props.setSectionRoute?.(nextSection);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,11 +63,13 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
|
||||
<section class="space-y-6 animate-in fade-in duration-300">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-3xl font-bold text-dls-text">Extensions</h2>
|
||||
<p class="text-sm text-dls-secondary mt-1.5">
|
||||
Apps (MCP) and OpenCode plugins live in one place.
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Show when={props.showHeader !== false}>
|
||||
<h2 class="text-3xl font-bold text-dls-text">Extensions</h2>
|
||||
<p class="text-sm text-dls-secondary mt-1.5">
|
||||
Apps (MCP) and OpenCode plugins live in one place.
|
||||
</p>
|
||||
</Show>
|
||||
<div class={`${props.showHeader === false ? "" : "mt-3"} flex flex-wrap items-center gap-2`}>
|
||||
<Show when={connectedAppsCount() > 0}>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-green-3 px-3 py-1">
|
||||
<div class="w-2 h-2 rounded-full bg-green-9" />
|
||||
@@ -127,23 +133,8 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
|
||||
<McpView
|
||||
showHeader={false}
|
||||
busy={props.busy}
|
||||
activeWorkspaceRoot={props.activeWorkspaceRoot}
|
||||
selectedWorkspaceRoot={props.selectedWorkspaceRoot}
|
||||
isRemoteWorkspace={props.isRemoteWorkspace}
|
||||
mcpServers={props.mcpServers}
|
||||
mcpStatus={props.mcpStatus}
|
||||
mcpLastUpdatedAt={props.mcpLastUpdatedAt}
|
||||
mcpStatuses={props.mcpStatuses}
|
||||
mcpConnectingName={props.mcpConnectingName}
|
||||
selectedMcp={props.selectedMcp}
|
||||
setSelectedMcp={props.setSelectedMcp}
|
||||
quickConnect={props.quickConnect}
|
||||
connectMcp={props.connectMcp}
|
||||
authorizeMcp={props.authorizeMcp}
|
||||
logoutMcpAuth={props.logoutMcpAuth}
|
||||
removeMcp={props.removeMcp}
|
||||
showMcpReloadBanner={props.showMcpReloadBanner}
|
||||
reloadBlocked={props.reloadBlocked}
|
||||
reloadMcpEngine={props.reloadMcpEngine}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -156,24 +147,11 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
|
||||
</div>
|
||||
<PluginsView
|
||||
busy={props.busy}
|
||||
activeWorkspaceRoot={props.activeWorkspaceRoot}
|
||||
selectedWorkspaceRoot={props.selectedWorkspaceRoot}
|
||||
canEditPlugins={props.canEditPlugins}
|
||||
canUseGlobalScope={props.canUseGlobalScope}
|
||||
accessHint={props.accessHint}
|
||||
pluginScope={props.pluginScope}
|
||||
setPluginScope={props.setPluginScope}
|
||||
pluginConfigPath={props.pluginConfigPath}
|
||||
pluginList={props.pluginList}
|
||||
pluginInput={props.pluginInput}
|
||||
setPluginInput={props.setPluginInput}
|
||||
pluginStatus={props.pluginStatus}
|
||||
activePluginGuide={props.activePluginGuide}
|
||||
setActivePluginGuide={props.setActivePluginGuide}
|
||||
isPluginInstalled={props.isPluginInstalled}
|
||||
suggestedPlugins={props.suggestedPlugins}
|
||||
refreshPlugins={props.refreshPlugins}
|
||||
addPlugin={props.addPlugin}
|
||||
removePlugin={props.removePlugin}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -33,9 +33,10 @@ export type IdentitiesViewProps = {
|
||||
openworkReconnectBusy: boolean;
|
||||
reconnectOpenworkServer: () => Promise<boolean>;
|
||||
restartLocalServer: () => Promise<boolean>;
|
||||
openworkServerWorkspaceId: string | null;
|
||||
activeWorkspaceRoot: string;
|
||||
runtimeWorkspaceId: string | null;
|
||||
selectedWorkspaceRoot: string;
|
||||
developerMode: boolean;
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
const OPENCODE_ROUTER_AGENT_FILE_PATH = ".opencode/agents/opencode-router.md";
|
||||
@@ -201,7 +202,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
const [messagingRestartAction, setMessagingRestartAction] = createSignal<"enable" | "disable">("enable");
|
||||
|
||||
const workspaceId = createMemo(() => {
|
||||
const explicitId = props.openworkServerWorkspaceId?.trim() ?? "";
|
||||
const explicitId = props.runtimeWorkspaceId?.trim() ?? "";
|
||||
if (explicitId) return explicitId;
|
||||
return parseOpenworkWorkspaceIdFromUrl(props.openworkServerUrl) ?? "";
|
||||
});
|
||||
@@ -216,7 +217,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
|
||||
const serverReady = createMemo(() => props.openworkServerStatus === "connected" && Boolean(openworkServerClient()));
|
||||
const scopedWorkspaceReady = createMemo(() => Boolean(workspaceId()));
|
||||
const defaultRoutingDirectory = createMemo(() => props.activeWorkspaceRoot.trim() || "Not set");
|
||||
const defaultRoutingDirectory = createMemo(() => props.selectedWorkspaceRoot.trim() || "Not set");
|
||||
|
||||
let lastResetKey = "";
|
||||
|
||||
@@ -853,7 +854,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
{/* ---- Header ---- */}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<h1 class="text-lg font-bold text-gray-12 tracking-tight">Messaging channels</h1>
|
||||
<Show when={props.showHeader !== false}>
|
||||
<h1 class="text-lg font-bold text-gray-12 tracking-tight">Messaging channels</h1>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -875,10 +878,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-9 leading-relaxed">
|
||||
Let people reach your worker through messaging apps. Connect a channel and
|
||||
your worker will automatically read and respond to messages.
|
||||
</p>
|
||||
<Show when={props.showHeader !== false}>
|
||||
<p class="text-sm text-gray-9 leading-relaxed">
|
||||
Let people reach your worker through messaging apps. Connect a channel and
|
||||
your worker will automatically read and respond to messages.
|
||||
</p>
|
||||
</Show>
|
||||
<div class="mt-1.5 text-[11px] text-gray-8 font-mono break-all">
|
||||
Workspace scope: {scopedOpenworkBaseUrl().trim() || props.openworkServerUrl.trim() || "Not set"}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
MonitorSmartphone,
|
||||
Plug2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Settings2,
|
||||
Unplug,
|
||||
@@ -40,7 +39,7 @@ import { currentLocale, t, type Language } from "../../i18n";
|
||||
|
||||
export type McpViewProps = {
|
||||
busy: boolean;
|
||||
activeWorkspaceRoot: string;
|
||||
selectedWorkspaceRoot: string;
|
||||
isRemoteWorkspace: boolean;
|
||||
readConfigFile?: (scope: "project" | "global") => Promise<OpencodeConfigFile | null>;
|
||||
showHeader?: boolean;
|
||||
@@ -56,9 +55,6 @@ export type McpViewProps = {
|
||||
authorizeMcp: (entry: McpServerEntry) => void;
|
||||
logoutMcpAuth: (name: string) => Promise<void> | void;
|
||||
removeMcp: (name: string) => void;
|
||||
showMcpReloadBanner: boolean;
|
||||
reloadBlocked: boolean;
|
||||
reloadMcpEngine: () => void;
|
||||
};
|
||||
|
||||
/* ── Status helpers ─────────────────────────────────── */
|
||||
@@ -166,7 +162,7 @@ export default function McpView(props: McpViewProps) {
|
||||
|
||||
let configRequestId = 0;
|
||||
createEffect(() => {
|
||||
const root = props.activeWorkspaceRoot.trim();
|
||||
const root = props.selectedWorkspaceRoot.trim();
|
||||
const nextId = (configRequestId += 1);
|
||||
const readConfig = props.readConfigFile;
|
||||
|
||||
@@ -207,13 +203,13 @@ export default function McpView(props: McpViewProps) {
|
||||
|
||||
const canRevealConfig = () => {
|
||||
if (!isTauriRuntime() || revealBusy()) return false;
|
||||
if (configScope() === "project" && !props.activeWorkspaceRoot.trim()) return false;
|
||||
if (configScope() === "project" && !props.selectedWorkspaceRoot.trim()) return false;
|
||||
return Boolean(activeConfig()?.exists);
|
||||
};
|
||||
|
||||
const revealConfig = async () => {
|
||||
if (!isTauriRuntime() || revealBusy()) return;
|
||||
const root = props.activeWorkspaceRoot.trim();
|
||||
const root = props.selectedWorkspaceRoot.trim();
|
||||
|
||||
if (configScope() === "project" && !root) {
|
||||
setConfigError(tr("mcp.pick_workspace_error"));
|
||||
@@ -327,29 +323,6 @@ export default function McpView(props: McpViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Reload banner ────────────────────────────── */}
|
||||
<Show when={props.showMcpReloadBanner}>
|
||||
<div class="bg-amber-2 border border-amber-6 rounded-xl px-5 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-amber-11">{tr("mcp.finish_setup")}</div>
|
||||
<div class="text-xs text-amber-11/70 mt-0.5">
|
||||
{props.reloadBlocked
|
||||
? tr("mcp.reload_banner_description_blocked")
|
||||
: tr("mcp.finish_setup_hint")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => props.reloadMcpEngine()}
|
||||
disabled={props.reloadBlocked}
|
||||
title={props.reloadBlocked ? tr("mcp.reload_banner_blocked_hint") : undefined}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{tr("mcp.activate_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Status message ───────────────────────────── */}
|
||||
<Show when={props.mcpStatus}>
|
||||
<div class="rounded-xl border border-dls-border bg-dls-hover px-4 py-3 text-xs text-dls-secondary whitespace-pre-wrap break-words">
|
||||
@@ -357,6 +330,19 @@ export default function McpView(props: McpViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="rounded-2xl border border-blue-6/30 bg-[linear-gradient(180deg,rgba(59,130,246,0.08),rgba(59,130,246,0.03))] px-5 py-5 sm:px-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="text-base font-semibold text-dls-text">{tr("mcp.add_modal_title")}</div>
|
||||
<div class="text-sm text-dls-secondary">{tr("mcp.custom_app_cta_hint")}</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setAddMcpModalOpen(true)}>
|
||||
<Plus size={14} />
|
||||
{tr("mcp.add_modal_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Available apps (Quick Connect) ───────────── */}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -764,12 +750,6 @@ export default function McpView(props: McpViewProps) {
|
||||
<div class="text-xs text-red-11">{configError()}</div>
|
||||
</Show>
|
||||
|
||||
<div class="border-t border-dls-border pt-4">
|
||||
<Button variant="secondary" onClick={() => setAddMcpModalOpen(true)}>
|
||||
<Plus size={14} />
|
||||
{tr("mcp.add_modal_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
import { For, Match, Show, Switch, createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import type { OnboardingStep, StartupPreference } from "../types";
|
||||
import type { WorkspaceInfo } from "../lib/tauri";
|
||||
import { CheckCircle2, ChevronDown, Circle, Globe, HardDrive } from "lucide-solid";
|
||||
|
||||
import Button from "../components/button";
|
||||
import OnboardingWorkspaceSelector from "../components/onboarding-workspace-selector";
|
||||
import OpenWorkLogo from "../components/openwork-logo";
|
||||
import TextInput from "../components/text-input";
|
||||
import { isTauriRuntime, isWindowsPlatform } from "../utils/index";
|
||||
import { currentLocale, t } from "../../i18n";
|
||||
|
||||
export type OnboardingViewProps = {
|
||||
startupPreference: StartupPreference | null;
|
||||
onboardingStep: OnboardingStep;
|
||||
rememberStartupChoice: boolean;
|
||||
busy: boolean;
|
||||
clientDirectory: string;
|
||||
openworkHostUrl: string;
|
||||
openworkToken: string;
|
||||
newAuthorizedDir: string;
|
||||
authorizedDirs: string[];
|
||||
activeWorkspacePath: string;
|
||||
workspaces: WorkspaceInfo[];
|
||||
localHostLabel: string;
|
||||
engineRunning: boolean;
|
||||
engineBaseUrl: string | null;
|
||||
engineDoctorFound: boolean | null;
|
||||
engineDoctorSupportsServe: boolean | null;
|
||||
engineDoctorVersion: string | null;
|
||||
engineDoctorResolvedPath: string | null;
|
||||
engineDoctorNotes: string[];
|
||||
engineDoctorServeHelpStdout: string | null;
|
||||
engineDoctorServeHelpStderr: string | null;
|
||||
engineDoctorCheckedAt: number | null;
|
||||
engineInstallLogs: string | null;
|
||||
error: string | null;
|
||||
canRepairMigration: boolean;
|
||||
migrationRepairUnavailableReason: string | null;
|
||||
migrationRepairBusy: boolean;
|
||||
migrationRepairResult: { ok: boolean; message: string } | null;
|
||||
developerMode: boolean;
|
||||
isWindows: boolean;
|
||||
onClientDirectoryChange: (value: string) => void;
|
||||
onOpenworkHostUrlChange: (value: string) => void;
|
||||
onOpenworkTokenChange: (value: string) => void;
|
||||
onSelectStartup: (mode: StartupPreference) => void;
|
||||
onRememberStartupToggle: () => void;
|
||||
onStartHost: () => void;
|
||||
onRepairMigration: () => void;
|
||||
onCreateWorkspace: (preset: "starter" | "automation" | "minimal", folder: string | null) => void;
|
||||
onPickWorkspaceFolder: () => Promise<string | null>;
|
||||
onImportWorkspaceConfig: () => void;
|
||||
importingWorkspaceConfig: boolean;
|
||||
onAttachHost: () => void;
|
||||
onConnectClient: () => void;
|
||||
onBackToWelcome: () => void;
|
||||
onSetAuthorizedDir: (value: string) => void;
|
||||
onAddAuthorizedDir: () => void;
|
||||
onAddAuthorizedDirFromPicker: () => void;
|
||||
onRemoveAuthorizedDir: (index: number) => void;
|
||||
onRefreshEngineDoctor: () => void;
|
||||
onInstallEngine: () => void;
|
||||
onShowSearchNotes: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenAdvancedSettings: () => void;
|
||||
themeMode: "light" | "dark" | "system";
|
||||
setThemeMode: (value: "light" | "dark" | "system") => void;
|
||||
};
|
||||
|
||||
export default function OnboardingView(props: OnboardingViewProps) {
|
||||
// Translation helper that uses current language from i18n
|
||||
const translate = (key: string) => t(key, currentLocale());
|
||||
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
|
||||
const [connectingFallbackVisible, setConnectingFallbackVisible] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (props.onboardingStep !== "connecting" && props.onboardingStep !== "bootstrap") {
|
||||
setConnectingFallbackVisible(false);
|
||||
return;
|
||||
}
|
||||
setConnectingFallbackVisible(false);
|
||||
const timer = window.setTimeout(() => setConnectingFallbackVisible(true), 4_000);
|
||||
onCleanup(() => window.clearTimeout(timer));
|
||||
});
|
||||
|
||||
const engineDoctorAvailable = () =>
|
||||
props.engineDoctorFound === true && props.engineDoctorSupportsServe === true;
|
||||
|
||||
const engineStatusLabel = () => {
|
||||
if (props.engineDoctorFound == null || props.engineDoctorSupportsServe == null) {
|
||||
return translate("onboarding.checking_cli");
|
||||
}
|
||||
if (!props.engineDoctorFound) return translate("onboarding.cli_not_found");
|
||||
if (!props.engineDoctorSupportsServe) return translate("onboarding.cli_needs_update");
|
||||
if (props.engineDoctorVersion) {
|
||||
return translate("onboarding.cli_version").replace("{version}", props.engineDoctorVersion);
|
||||
}
|
||||
return translate("onboarding.cli_ready");
|
||||
};
|
||||
|
||||
const serveHelpOutput = () => {
|
||||
const parts = [
|
||||
props.engineDoctorServeHelpStdout,
|
||||
props.engineDoctorServeHelpStderr,
|
||||
].filter((value): value is string => Boolean(value && value.trim()));
|
||||
return parts.join("\n\n");
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.onboardingStep === "connecting" || props.onboardingStep === "bootstrap"}>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-gray-2 via-gray-1 to-gray-1 opacity-50" />
|
||||
<div class="z-10 w-full max-w-lg rounded-[28px] border border-dls-border bg-dls-surface/95 p-8 shadow-[var(--dls-shell-shadow)]">
|
||||
<div class="flex flex-col items-center gap-6 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-[22px] border border-dls-border bg-dls-sidebar shadow-[var(--dls-card-shadow)]">
|
||||
<Show when={props.onboardingStep === "bootstrap"} fallback={<OpenWorkLogo size={36} />}>
|
||||
<HardDrive size={24} class="text-gray-11" />
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-dls-secondary">
|
||||
{props.onboardingStep === "bootstrap" ? "First-time setup" : "Connecting"}
|
||||
</div>
|
||||
<h2 class="mt-3 text-2xl font-semibold tracking-tight text-gray-12">
|
||||
{props.onboardingStep === "bootstrap"
|
||||
? "Getting your starter workspace ready"
|
||||
: props.startupPreference === "local"
|
||||
? translate("onboarding.starting_host")
|
||||
: translate("onboarding.searching_host")}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-dls-secondary">
|
||||
{props.onboardingStep === "bootstrap"
|
||||
? "We’re preparing everything so you can start in one click. This only happens the first time."
|
||||
: props.startupPreference === "local"
|
||||
? translate("onboarding.getting_ready")
|
||||
: translate("onboarding.verifying")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full rounded-[22px] border border-dls-border bg-dls-sidebar px-5 py-4 text-left">
|
||||
<div class="flex items-center gap-3 text-sm font-medium text-gray-12">
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-dls-accent animate-pulse" />
|
||||
{props.onboardingStep === "bootstrap" ? "Preparing your workspace" : "Waiting for your host"}
|
||||
</div>
|
||||
<div class="mt-2 text-xs leading-6 text-dls-secondary">
|
||||
{props.onboardingStep === "bootstrap"
|
||||
? "Starting local services, setting up your starter folder, and opening your first session."
|
||||
: "Checking your local OpenWork services and reconnecting to the selected workspace."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.error}>
|
||||
<div class="w-full rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20 text-left">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={connectingFallbackVisible()}>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button variant="secondary" onClick={props.onOpenAdvancedSettings} disabled={props.busy}>
|
||||
Having trouble?
|
||||
</Button>
|
||||
<div class="text-xs text-gray-10">Open Advanced settings to check local host configuration.</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={props.onboardingStep === "local"}>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
|
||||
<div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
|
||||
|
||||
<div class="max-w-lg w-full z-10 space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<div class="">
|
||||
<OpenWorkLogo size={48} />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
{props.workspaces.length <= 1 ? translate("onboarding.create_first_workspace") : translate("onboarding.create_workspace")}
|
||||
</h2>
|
||||
<p class="text-gray-11 text-sm leading-relaxed">
|
||||
{translate("onboarding.workspace_folder_label")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-gray-2/40 border border-gray-6 rounded-2xl p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">{translate("onboarding.theme_label")}</div>
|
||||
<div class="text-sm text-gray-12">{translate("onboarding.theme_current").replace("{mode}", props.themeMode)}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
props.themeMode === "system"
|
||||
? "bg-gray-12/10 text-gray-12 border-gray-6/30"
|
||||
: "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
}`}
|
||||
onClick={() => props.setThemeMode("system")}
|
||||
>
|
||||
{translate("onboarding.theme_system")}
|
||||
</button>
|
||||
<button
|
||||
class={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
props.themeMode === "light"
|
||||
? "bg-gray-12/10 text-gray-12 border-gray-6/30"
|
||||
: "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
}`}
|
||||
onClick={() => props.setThemeMode("light")}
|
||||
>
|
||||
{translate("onboarding.theme_light")}
|
||||
</button>
|
||||
<button
|
||||
class={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
props.themeMode === "dark"
|
||||
? "bg-gray-12/10 text-gray-12 border-gray-6/30"
|
||||
: "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
}`}
|
||||
onClick={() => props.setThemeMode("dark")}
|
||||
>
|
||||
{translate("onboarding.theme_dark")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingWorkspaceSelector
|
||||
defaultPath="~/OpenWork/Worker"
|
||||
onConfirm={props.onCreateWorkspace}
|
||||
onPickFolder={props.onPickWorkspaceFolder}
|
||||
/>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/50 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">Import</div>
|
||||
<div class="mt-1 text-sm text-gray-12">Use an existing workspace config.</div>
|
||||
<div class="text-xs text-gray-10">Imports `.opencode` and `opencode.json` only.</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="text-xs h-8 px-3 shrink-0"
|
||||
onClick={props.onImportWorkspaceConfig}
|
||||
disabled={props.importingWorkspaceConfig || props.busy}
|
||||
>
|
||||
Import config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/50 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">{translate("onboarding.access_label")}</div>
|
||||
<div class="mt-1 text-sm text-gray-12">
|
||||
{translate("onboarding.folders_allowed")
|
||||
.replace("{count}", String(props.authorizedDirs.length))
|
||||
.replace("{plural}", props.authorizedDirs.length === 1 ? "" : "s")}
|
||||
</div>
|
||||
<div class="text-xs text-gray-10">{translate("onboarding.manage_access_hint")}</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-7 font-mono truncate max-w-[9rem]">
|
||||
<Show when={props.developerMode}>{props.authorizedDirs[0] ?? ""}</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={props.onStartHost}
|
||||
disabled={props.busy || !props.activeWorkspacePath.trim()}
|
||||
class="w-full py-3 text-base"
|
||||
>
|
||||
{translate("onboarding.start")}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" onClick={props.onBackToWelcome} disabled={props.busy} class="w-full">
|
||||
{translate("onboarding.back")}
|
||||
</Button>
|
||||
|
||||
<details class="rounded-2xl border border-gray-6 bg-gray-1/60 px-4 py-3">
|
||||
<summary class="flex items-center justify-between cursor-pointer text-xs text-gray-10">
|
||||
{translate("onboarding.advanced_settings")}
|
||||
<ChevronDown size={14} class="text-gray-7" />
|
||||
</summary>
|
||||
<div class="pt-3 space-y-3">
|
||||
<div class="text-xs text-gray-10">{translate("onboarding.manage_access_hint")}</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl border border-gray-6 bg-gray-1/40 px-3 py-2">
|
||||
<div class="text-xs text-gray-10">{translate("onboarding.open_settings_hint")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3 shrink-0"
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
{translate("onboarding.open_settings")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
class="w-full bg-dls-surface border border-dls-border rounded-xl px-3 py-2 text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:border-dls-accent transition-all"
|
||||
placeholder={translate("onboarding.add_folder_path")}
|
||||
value={props.newAuthorizedDir}
|
||||
onInput={(e) => props.onSetAuthorizedDir(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
props.onAddAuthorizedDir();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={isTauriRuntime()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onAddAuthorizedDirFromPicker}
|
||||
disabled={props.busy}
|
||||
>
|
||||
{translate("onboarding.pick")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={props.onAddAuthorizedDir}
|
||||
disabled={!props.newAuthorizedDir.trim()}
|
||||
>
|
||||
{translate("onboarding.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-10">{engineStatusLabel()}</div>
|
||||
|
||||
<Show when={props.authorizedDirs.length}>
|
||||
<div class="space-y-2">
|
||||
<For each={props.authorizedDirs}>
|
||||
{(dir, idx) => (
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl bg-gray-1/20 border border-gray-6 px-3 py-2">
|
||||
<div class="min-w-0 text-xs font-mono text-gray-11 truncate">{dir}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!p-2 rounded-lg text-xs text-gray-11 hover:text-gray-12"
|
||||
onClick={() => props.onRemoveAuthorizedDir(idx())}
|
||||
disabled={props.busy}
|
||||
title={translate("onboarding.remove")}
|
||||
>
|
||||
{translate("onboarding.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isTauriRuntime() && props.developerMode}>
|
||||
<div class="rounded-2xl bg-gray-2/40 border border-gray-6 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-12">{translate("onboarding.cli_label")}</div>
|
||||
<div class="mt-1 text-xs text-gray-10">
|
||||
<Show when={props.engineDoctorFound != null} fallback={<span>{translate("onboarding.cli_checking")}</span>}>
|
||||
<Show when={props.engineDoctorFound} fallback={<span>{translate("onboarding.cli_not_found_hint")}</span>}>
|
||||
<span class="font-mono">{props.engineDoctorVersion ?? translate("onboarding.cli_version_installed")}</span>
|
||||
<Show when={props.engineDoctorResolvedPath}>
|
||||
<span class="text-gray-7"> · </span>
|
||||
<span class="font-mono text-gray-7 truncate">{props.engineDoctorResolvedPath}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={props.onRefreshEngineDoctor} disabled={props.busy}>
|
||||
{translate("onboarding.cli_recheck")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={props.engineDoctorFound === false}>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="text-xs text-gray-10">
|
||||
{isWindowsPlatform()
|
||||
? translate("onboarding.cli_install_commands")
|
||||
: translate("onboarding.install_instruction")}
|
||||
</div>
|
||||
<Show when={isWindowsPlatform()}>
|
||||
<div class="text-xs text-gray-10 space-y-1 font-mono">
|
||||
<div>choco install opencode</div>
|
||||
<div>scoop install extras/opencode</div>
|
||||
<div>npm install -g opencode-ai</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button onClick={props.onInstallEngine} disabled={props.busy}>
|
||||
{translate("onboarding.install")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={props.onShowSearchNotes} disabled={props.busy}>
|
||||
{translate("onboarding.show_search_notes")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.engineInstallLogs}>
|
||||
<pre class="mt-4 max-h-48 overflow-auto rounded-xl bg-gray-1/50 border border-gray-6 p-3 text-xs text-gray-11 whitespace-pre-wrap">
|
||||
{props.engineInstallLogs}
|
||||
</pre>
|
||||
</Show>
|
||||
|
||||
<Show when={props.engineDoctorCheckedAt != null}>
|
||||
<div class="mt-3 text-[11px] text-gray-7">
|
||||
{translate("onboarding.last_checked").replace("{time}", new Date(props.engineDoctorCheckedAt ?? 0).toLocaleTimeString())}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!engineDoctorAvailable()}>
|
||||
<div class="text-xs text-gray-10">
|
||||
{props.isWindows
|
||||
? translate("onboarding.windows_install_instruction")
|
||||
: translate("onboarding.install_instruction")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={engineDoctorAvailable()}>
|
||||
<div class="text-xs text-gray-7">{translate("onboarding.ready_message")}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.engineDoctorResolvedPath ||
|
||||
props.engineDoctorVersion ||
|
||||
props.engineDoctorNotes.length ||
|
||||
serveHelpOutput()
|
||||
}
|
||||
>
|
||||
<div class="rounded-xl bg-gray-1/40 border border-gray-6 p-3 space-y-3 text-xs text-gray-10">
|
||||
<Show when={props.engineDoctorResolvedPath}>
|
||||
<div>
|
||||
<div class="text-[11px] text-gray-8">{translate("onboarding.resolved_path")}</div>
|
||||
<div class="font-mono break-all">{props.engineDoctorResolvedPath}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.engineDoctorVersion}>
|
||||
<div>
|
||||
<div class="text-[11px] text-gray-8">{translate("onboarding.version")}</div>
|
||||
<div class="font-mono">{props.engineDoctorVersion}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.engineDoctorNotes.length}>
|
||||
<div>
|
||||
<div class="text-[11px] text-gray-8">{translate("onboarding.search_notes")}</div>
|
||||
<pre class="whitespace-pre-wrap break-words text-xs text-gray-10">
|
||||
{props.engineDoctorNotes.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={serveHelpOutput()}>
|
||||
<div>
|
||||
<div class="text-[11px] text-gray-8">{translate("onboarding.serve_help")}</div>
|
||||
<pre class="whitespace-pre-wrap break-words text-xs text-gray-10">{serveHelpOutput()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Show when={props.error || props.migrationRepairResult}>
|
||||
<div class="rounded-2xl border border-red-7/20 bg-red-1/40 px-5 py-4 text-sm text-red-12 space-y-3">
|
||||
<Show when={props.error}>
|
||||
<div>{props.error}</div>
|
||||
</Show>
|
||||
<Show when={props.migrationRepairResult}>
|
||||
{(result) => (
|
||||
<div
|
||||
class={`rounded-xl border px-3 py-2 text-xs ${
|
||||
result().ok
|
||||
? "border-green-7/30 bg-green-2/30 text-green-12"
|
||||
: "border-red-7/30 bg-red-2/30 text-red-12"
|
||||
}`}
|
||||
>
|
||||
{result().message}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.canRepairMigration}>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="text-xs h-8 px-3"
|
||||
onClick={props.onRepairMigration}
|
||||
disabled={props.busy || props.migrationRepairBusy}
|
||||
>
|
||||
{props.migrationRepairBusy
|
||||
? translate("onboarding.fixing_migration")
|
||||
: translate("onboarding.fix_migration")}
|
||||
</Button>
|
||||
<span class="text-xs text-red-12/80">{translate("onboarding.fix_migration_hint")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.canRepairMigration && props.migrationRepairUnavailableReason}>
|
||||
<div class="text-xs text-red-12/80">{props.migrationRepairUnavailableReason}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={props.onboardingStep === "server"}>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
|
||||
<div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
|
||||
|
||||
<div class="max-w-md w-full z-10 space-y-8">
|
||||
<div class="text-center space-y-2">
|
||||
<div class="w-12 h-12 bg-gray-2 rounded-2xl mx-auto flex items-center justify-center border border-gray-6 mb-6">
|
||||
<Globe size={20} class="text-gray-11" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{translate("onboarding.remote_workspace_title")}</h2>
|
||||
<p class="text-gray-11 text-sm leading-relaxed">
|
||||
{translate("onboarding.remote_workspace_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/50 px-4 py-3">
|
||||
<div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">
|
||||
{translate("onboarding.remote_workspace_title")}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-12">{translate("onboarding.remote_workspace_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<TextInput
|
||||
label={translate("dashboard.openwork_host_label")}
|
||||
placeholder={translate("dashboard.openwork_host_placeholder")}
|
||||
value={props.openworkHostUrl}
|
||||
onInput={(e) => props.onOpenworkHostUrlChange(e.currentTarget.value)}
|
||||
hint={translate("dashboard.openwork_host_hint")}
|
||||
/>
|
||||
<label class="block">
|
||||
<div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type={openworkTokenVisible() ? "text" : "password"}
|
||||
value={props.openworkToken}
|
||||
onInput={(e) => props.onOpenworkTokenChange(e.currentTarget.value)}
|
||||
placeholder={translate("dashboard.openwork_host_token_placeholder")}
|
||||
disabled={props.busy}
|
||||
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-9 px-3 shrink-0"
|
||||
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
|
||||
disabled={props.busy}
|
||||
>
|
||||
{openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
|
||||
</label>
|
||||
<TextInput
|
||||
label={translate("dashboard.remote_directory_label")}
|
||||
placeholder={translate("dashboard.remote_directory_placeholder")}
|
||||
value={props.clientDirectory}
|
||||
onInput={(e) => props.onClientDirectoryChange(e.currentTarget.value)}
|
||||
hint={translate("dashboard.remote_directory_hint")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={props.onConnectClient}
|
||||
disabled={props.busy || !props.openworkHostUrl.trim()}
|
||||
class="w-full py-3 text-base"
|
||||
>
|
||||
{translate("onboarding.remote_workspace_action")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={props.onOpenSettings}
|
||||
disabled={props.busy}
|
||||
class="w-full"
|
||||
>
|
||||
{translate("onboarding.open_settings")}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" onClick={props.onBackToWelcome} disabled={props.busy} class="w-full">
|
||||
{translate("onboarding.back")}
|
||||
</Button>
|
||||
|
||||
<Show when={props.error}>
|
||||
<div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={true}>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
|
||||
<div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
|
||||
|
||||
<div class="max-w-xl w-full z-10 space-y-12">
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-3 mb-6">
|
||||
<div class="">
|
||||
<OpenWorkLogo size={48} />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-gray-12">OpenWork</h1>
|
||||
</div>
|
||||
<h2 class="text-xl text-gray-11">{translate("onboarding.welcome_title")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
onClick={() => props.onSelectStartup("local")}
|
||||
class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-indigo-6/10 hover:-translate-y-0.5 flex items-start gap-6"
|
||||
>
|
||||
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-7/20 to-purple-7/20 flex items-center justify-center border border-indigo-7/20 group-hover:border-indigo-7/40 transition-colors">
|
||||
<Circle size={18} class="text-indigo-11" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-medium text-gray-12 mb-2">{translate("onboarding.run_local")}</h3>
|
||||
<p class="text-gray-10 text-sm leading-relaxed mb-4">
|
||||
{translate("onboarding.run_local_description")}
|
||||
</p>
|
||||
<Show when={props.developerMode}>
|
||||
<div class="flex items-center gap-2 text-xs font-mono text-indigo-11/80 bg-indigo-2/10 w-fit px-2 py-1 rounded border border-indigo-7/10">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-indigo-8 animate-pulse" />
|
||||
{props.localHostLabel}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={props.engineRunning && props.engineBaseUrl}>
|
||||
<div class="rounded-2xl bg-gray-2/40 border border-gray-6 p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-12 font-medium">{translate("onboarding.engine_running")}</div>
|
||||
<div class="text-xs text-gray-10">{translate("onboarding.attach_description")}</div>
|
||||
<Show when={props.developerMode}>
|
||||
<div class="text-xs text-gray-10 font-mono truncate max-w-[14rem] md:max-w-[22rem]">
|
||||
{props.engineBaseUrl}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={props.onAttachHost} disabled={props.busy}>
|
||||
{translate("onboarding.attach")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={() => props.onSelectStartup("server")}
|
||||
class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-gray-12/10 hover:-translate-y-0.5 flex items-start gap-6"
|
||||
>
|
||||
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6 group-hover:border-gray-7 transition-colors">
|
||||
<Globe size={18} class="text-gray-11" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-medium text-gray-12 mb-2">
|
||||
{translate("onboarding.remote_workspace_card_title")}
|
||||
</h3>
|
||||
<p class="text-gray-10 text-sm leading-relaxed mb-4">
|
||||
{translate("onboarding.remote_workspace_card_description")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 px-2 py-1">
|
||||
<button
|
||||
onClick={props.onRememberStartupToggle}
|
||||
class="flex items-center gap-2 text-xs text-gray-10 hover:text-gray-11 transition-colors group"
|
||||
>
|
||||
<div
|
||||
class={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||
props.rememberStartupChoice
|
||||
? "bg-indigo-7 border-indigo-7 text-gray-12"
|
||||
: "border-gray-7 bg-transparent group-hover:border-gray-7"
|
||||
}`}
|
||||
>
|
||||
<Show when={props.rememberStartupChoice}>
|
||||
<CheckCircle2 size={10} />
|
||||
</Show>
|
||||
</div>
|
||||
{translate("onboarding.remember_choice")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={props.error}>
|
||||
<div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.developerMode}>
|
||||
<div class="text-center text-xs text-gray-8">{props.localHostLabel}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { For, Show } from "solid-js";
|
||||
|
||||
import type { PluginScope } from "../types";
|
||||
import { useExtensions } from "../extensions/provider";
|
||||
|
||||
import Button from "../components/button";
|
||||
import TextInput from "../components/text-input";
|
||||
@@ -8,20 +8,10 @@ import { Cpu } from "lucide-solid";
|
||||
|
||||
export type PluginsViewProps = {
|
||||
busy: boolean;
|
||||
activeWorkspaceRoot: string;
|
||||
selectedWorkspaceRoot: string;
|
||||
canEditPlugins: boolean;
|
||||
canUseGlobalScope: boolean;
|
||||
accessHint?: string | null;
|
||||
pluginScope: PluginScope;
|
||||
setPluginScope: (scope: PluginScope) => void;
|
||||
pluginConfigPath: string | null;
|
||||
pluginList: string[];
|
||||
pluginInput: string;
|
||||
setPluginInput: (value: string) => void;
|
||||
pluginStatus: string | null;
|
||||
activePluginGuide: string | null;
|
||||
setActivePluginGuide: (value: string | null) => void;
|
||||
isPluginInstalled: (name: string, aliases?: string[]) => boolean;
|
||||
suggestedPlugins: Array<{
|
||||
name: string;
|
||||
packageName: string;
|
||||
@@ -38,12 +28,10 @@ export type PluginsViewProps = {
|
||||
note?: string;
|
||||
}>;
|
||||
}>;
|
||||
refreshPlugins: (scopeOverride?: PluginScope) => void;
|
||||
addPlugin: (pluginNameOverride?: string) => void;
|
||||
removePlugin: (pluginName: string) => void;
|
||||
};
|
||||
|
||||
export default function PluginsView(props: PluginsViewProps) {
|
||||
const extensions = useExtensions();
|
||||
return (
|
||||
<section class="space-y-6">
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
|
||||
@@ -55,13 +43,13 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
props.pluginScope === "project"
|
||||
extensions.pluginScope() === "project"
|
||||
? "bg-gray-12/10 text-gray-12 border-gray-6/20"
|
||||
: "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
}`}
|
||||
onClick={() => {
|
||||
props.setPluginScope("project");
|
||||
props.refreshPlugins("project");
|
||||
extensions.setPluginScope("project");
|
||||
void extensions.refreshPlugins("project");
|
||||
}}
|
||||
>
|
||||
Project
|
||||
@@ -69,19 +57,19 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<button
|
||||
disabled={!props.canUseGlobalScope}
|
||||
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
props.pluginScope === "global"
|
||||
extensions.pluginScope() === "global"
|
||||
? "bg-gray-12/10 text-gray-12 border-gray-6/20"
|
||||
: "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
} ${!props.canUseGlobalScope ? "opacity-40 cursor-not-allowed hover:text-gray-10" : ""}`}
|
||||
onClick={() => {
|
||||
if (!props.canUseGlobalScope) return;
|
||||
props.setPluginScope("global");
|
||||
props.refreshPlugins("global");
|
||||
extensions.setPluginScope("global");
|
||||
void extensions.refreshPlugins("global");
|
||||
}}
|
||||
>
|
||||
Global
|
||||
</button>
|
||||
<Button variant="ghost" onClick={() => props.refreshPlugins()}>
|
||||
<Button variant="ghost" onClick={() => void extensions.refreshPlugins()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -89,7 +77,7 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-gray-10">
|
||||
<div>Config</div>
|
||||
<div class="text-gray-7 font-mono truncate">{props.pluginConfigPath ?? "Not loaded yet"}</div>
|
||||
<div class="text-gray-7 font-mono truncate">{extensions.pluginConfigPath() ?? extensions.pluginConfig()?.path ?? "Not loaded yet"}</div>
|
||||
<Show when={props.accessHint}>
|
||||
<div class="text-gray-9">{props.accessHint}</div>
|
||||
</Show>
|
||||
@@ -101,8 +89,8 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<For each={props.suggestedPlugins}>
|
||||
{(plugin) => {
|
||||
const isGuided = () => plugin.installMode === "guided";
|
||||
const isInstalled = () => props.isPluginInstalled(plugin.packageName, plugin.aliases ?? []);
|
||||
const isGuideOpen = () => props.activePluginGuide === plugin.packageName;
|
||||
const isInstalled = () => extensions.isPluginInstalledByName(plugin.packageName, plugin.aliases ?? []);
|
||||
const isGuideOpen = () => extensions.activePluginGuide() === plugin.packageName;
|
||||
|
||||
return (
|
||||
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
|
||||
@@ -118,19 +106,19 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<Show when={isGuided()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => props.setActivePluginGuide(isGuideOpen() ? null : plugin.packageName)}
|
||||
onClick={() => extensions.setActivePluginGuide(isGuideOpen() ? null : plugin.packageName)}
|
||||
>
|
||||
{isGuideOpen() ? "Hide setup" : "Setup"}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={isInstalled() ? "outline" : "secondary"}
|
||||
onClick={() => props.addPlugin(plugin.packageName)}
|
||||
onClick={() => extensions.addPlugin(plugin.packageName)}
|
||||
disabled={
|
||||
props.busy ||
|
||||
isInstalled() ||
|
||||
!props.canEditPlugins ||
|
||||
(props.pluginScope === "project" && !props.activeWorkspaceRoot.trim())
|
||||
(extensions.pluginScope() === "project" && !props.selectedWorkspaceRoot.trim())
|
||||
}
|
||||
>
|
||||
{isInstalled() ? "Added" : "Add"}
|
||||
@@ -186,7 +174,7 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={props.pluginList.length}
|
||||
when={extensions.pluginList().length}
|
||||
fallback={
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-10">
|
||||
No plugins configured yet.
|
||||
@@ -194,7 +182,7 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
}
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<For each={props.pluginList}>
|
||||
<For each={extensions.pluginList()}>
|
||||
{(pluginName) => (
|
||||
<div class="flex items-center justify-between rounded-xl border border-gray-6/60 bg-gray-1/40 px-4 py-2.5">
|
||||
<div class="text-sm text-gray-12 font-mono">{pluginName}</div>
|
||||
@@ -203,7 +191,7 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[11px] text-red-11 hover:text-red-12"
|
||||
onClick={() => props.removePlugin(pluginName)}
|
||||
onClick={() => extensions.removePlugin(pluginName)}
|
||||
disabled={props.busy || !props.canEditPlugins}
|
||||
>
|
||||
Remove
|
||||
@@ -221,22 +209,22 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<TextInput
|
||||
label="Add plugin"
|
||||
placeholder="opencode-wakatime"
|
||||
value={props.pluginInput}
|
||||
onInput={(e) => props.setPluginInput(e.currentTarget.value)}
|
||||
value={extensions.pluginInput()}
|
||||
onInput={(e) => extensions.setPluginInput(e.currentTarget.value)}
|
||||
hint="Add npm package names, e.g. opencode-wakatime"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => props.addPlugin()}
|
||||
disabled={props.busy || !props.pluginInput.trim() || !props.canEditPlugins}
|
||||
onClick={() => extensions.addPlugin()}
|
||||
disabled={props.busy || !extensions.pluginInput().trim() || !props.canEditPlugins}
|
||||
class="md:mt-6"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={props.pluginStatus}>
|
||||
<div class="text-xs text-gray-10">{props.pluginStatus}</div>
|
||||
<Show when={extensions.pluginStatus()}>
|
||||
<div class="text-xs text-gray-10">{extensions.pluginStatus()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
import { For, Match, Show, Switch, createMemo, createSignal } from "solid-js";
|
||||
import {
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
Box,
|
||||
Brain,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
FileCode,
|
||||
FileText,
|
||||
Gamepad2,
|
||||
History,
|
||||
Layout,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
X,
|
||||
Zap,
|
||||
Clock,
|
||||
} from "lucide-solid";
|
||||
|
||||
type TabKey = "new-thread" | "automations" | "skills";
|
||||
|
||||
const navTabs: Array<{ id: TabKey; label: string; icon: any }> = [
|
||||
{ id: "new-thread", label: "New thread", icon: Plus },
|
||||
{ id: "automations", label: "Automations", icon: History },
|
||||
{ id: "skills", label: "Skills", icon: Zap },
|
||||
];
|
||||
|
||||
const threadItems = [
|
||||
{ text: "my packages/web/src/app/(auth...", time: "3mo" },
|
||||
{ text: "could you look at https://github...", time: "3mo" },
|
||||
{ text: "<user_action> <context>User ini...", time: "3mo" },
|
||||
{ text: "'/Users/benjaminshafii/Download...", time: "4mo" },
|
||||
{ text: "could you review the current bra...", time: "4mo" },
|
||||
{ text: "it seems there's an issue with th...", time: "4mo" },
|
||||
];
|
||||
|
||||
const automationTemplates = [
|
||||
{ icon: Calendar, description: "Scan recent commits and flag riskier diffs.", color: "text-red-9" },
|
||||
{ icon: BookOpen, description: "Draft weekly release notes from merged PRs.", color: "text-blue-9" },
|
||||
{ icon: MessageSquare, description: "Summarize yesterday's git activity by repo.", color: "text-purple-9" },
|
||||
{ icon: TrendingUp, description: "Watch CI failures and surface recurring flakes.", color: "text-indigo-9" },
|
||||
{ icon: Trophy, description: "Build a tiny classic game for a team demo.", color: "text-amber-9" },
|
||||
{ icon: Brain, description: "Suggest the next skills to install for this worker.", color: "text-pink-9" },
|
||||
];
|
||||
|
||||
const skillInstalled = [
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Remotion Best Practices",
|
||||
description: "Best practices for Remotion - Video creation in React",
|
||||
badge: "zerofinance",
|
||||
},
|
||||
{
|
||||
icon: Pencil,
|
||||
title: "Skill Creator",
|
||||
description: "Create or update a skill",
|
||||
},
|
||||
{
|
||||
icon: Layout,
|
||||
title: "Skill Installer",
|
||||
description: "Install curated skills from openai/skills or other repos",
|
||||
},
|
||||
];
|
||||
|
||||
const skillRecommended = [
|
||||
{
|
||||
icon: ExternalLink,
|
||||
title: "Atlas",
|
||||
description: "Manage tabs in ChatGPT Atlas and access your...",
|
||||
},
|
||||
{
|
||||
icon: Gamepad2,
|
||||
title: "Develop Web Game",
|
||||
description: "Web game dev + Playwright test loop",
|
||||
},
|
||||
{
|
||||
icon: FileCode,
|
||||
title: "Doc",
|
||||
description: "Edit and review docx files",
|
||||
},
|
||||
];
|
||||
|
||||
const promptSuggestions = [
|
||||
{
|
||||
icon: Gamepad2,
|
||||
description: "Build a classic Snake game in this repo.",
|
||||
color: "text-blue-9",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
description: "Create a one-page $pdf that summarizes this app.",
|
||||
color: "text-purple-9",
|
||||
},
|
||||
{
|
||||
icon: Layout,
|
||||
description: "Summarize last week's PRs by teammate and theme.",
|
||||
color: "text-sky-9",
|
||||
},
|
||||
];
|
||||
|
||||
const SidebarItem = (props: {
|
||||
icon: any;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
class={`flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
|
||||
props.active
|
||||
? "bg-gray-3 text-gray-12"
|
||||
: "text-gray-10 hover:bg-gray-2 hover:text-gray-12"
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} class="text-current" />
|
||||
<span>{props.label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadItem = (props: { text: string; time: string }) => (
|
||||
<div class="group relative flex items-center justify-between rounded-xl px-3 py-2 transition-colors hover:bg-gray-2">
|
||||
<span class="truncate text-sm font-medium text-gray-11 group-hover:text-gray-12">
|
||||
{props.text}
|
||||
</span>
|
||||
<span class="text-xs text-gray-8">
|
||||
{props.time}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProjectFolder = (props: { name: string; children: any }) => {
|
||||
const [expanded, setExpanded] = createSignal(true);
|
||||
const toggleExpanded = () => setExpanded((current) => !current);
|
||||
|
||||
return (
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded()}
|
||||
onClick={toggleExpanded}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
toggleExpanded();
|
||||
}}
|
||||
class="group flex w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold text-gray-11 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronRight
|
||||
size={14}
|
||||
class={`text-gray-8 transition-transform ${expanded() ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<span>{props.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add thread"
|
||||
class="rounded-md p-1 text-gray-9 transition-colors hover:bg-gray-3 hover:text-gray-12"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Folder options"
|
||||
class="rounded-md p-1 text-gray-9 transition-colors hover:bg-gray-3 hover:text-gray-12"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<div class="space-y-1 pl-3">{props.children}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutomationCard = (props: {
|
||||
icon: any;
|
||||
description: string;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
class="group flex h-full min-h-[120px] w-full min-w-[240px] flex-1 flex-col rounded-2xl border border-gray-6 bg-gray-1/80 p-5 text-left transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div
|
||||
class={`mb-4 flex h-9 w-9 items-center justify-center rounded-xl border border-gray-6 bg-gray-1 ${
|
||||
props.color ?? "text-gray-10"
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<p class="text-sm text-gray-10 transition-colors group-hover:text-gray-12">
|
||||
{props.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const SkillCard = (props: {
|
||||
icon: any;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
type?: "installed" | "add";
|
||||
}) => {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<div class="flex items-start justify-between rounded-2xl border border-gray-6 bg-gray-1/70 p-4 transition-colors hover:border-gray-7">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl border border-gray-6 bg-gray-1 shadow-sm">
|
||||
<Icon size={20} class="text-gray-11" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-gray-12">{props.title}</h4>
|
||||
<Show when={props.badge}>
|
||||
<span class="flex items-center gap-1 rounded-full border border-gray-6 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-9">
|
||||
<Box size={10} />
|
||||
{props.badge}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-9 truncate max-w-[220px]">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.type === "add" ? "Install skill" : "Edit skill"}
|
||||
class="rounded-lg p-2 text-gray-8 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
>
|
||||
<Show when={props.type === "add"} fallback={<Pencil size={14} />}>
|
||||
<Plus size={16} />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateAutomationModal = (props: { open: boolean; onClose: () => void }) => (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-12/30 px-4 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-automation-title"
|
||||
class="w-full max-w-2xl rounded-3xl border border-gray-6 bg-gray-1 shadow-xl"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 id="create-automation-title" class="text-xl font-semibold text-gray-12">
|
||||
Create automation
|
||||
</h2>
|
||||
<p class="mt-2 text-xs text-gray-9">
|
||||
Automate recurring tasks in the background. Codex adds findings to the inbox or
|
||||
archives the task when nothing changes.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
onClick={props.onClose}
|
||||
class="rounded-full p-2 text-gray-8 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="automation-name" class="text-[11px] font-bold uppercase tracking-wider text-gray-8">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="automation-name"
|
||||
type="text"
|
||||
value="Daily bug scan"
|
||||
class="mt-2 w-full rounded-2xl border border-gray-6 bg-gray-2/60 px-3 py-2 text-sm text-gray-12 focus:outline-none focus:ring-1 focus:ring-blue-7"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="automation-project" class="text-[11px] font-bold uppercase tracking-wider text-gray-8">
|
||||
Projects
|
||||
</label>
|
||||
<input
|
||||
id="automation-project"
|
||||
type="text"
|
||||
placeholder="Choose a folder"
|
||||
class="mt-2 w-full rounded-2xl border border-gray-6 bg-gray-2/60 px-3 py-2 text-sm text-gray-12 focus:outline-none focus:ring-1 focus:ring-blue-7"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="automation-prompt" class="text-[11px] font-bold uppercase tracking-wider text-gray-8">
|
||||
Prompt
|
||||
</label>
|
||||
<div class="mt-2 rounded-2xl border border-gray-6 bg-gray-2/60 p-3">
|
||||
<textarea
|
||||
id="automation-prompt"
|
||||
class="min-h-[120px] w-full resize-none bg-transparent text-sm text-gray-11 focus:outline-none"
|
||||
value="Scan recent commits for tests that failed before release. Summarize the risk."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-[11px] font-bold uppercase tracking-wider text-gray-8">Schedule</label>
|
||||
<div class="flex rounded-full border border-gray-6 bg-gray-2/60 p-1">
|
||||
<button type="button" class="rounded-full bg-gray-12 px-3 py-1 text-[10px] font-bold text-gray-1">
|
||||
Daily
|
||||
</button>
|
||||
<button type="button" class="rounded-full px-3 py-1 text-[10px] font-bold text-gray-9">
|
||||
Interval
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex min-w-[120px] flex-1 items-center justify-between rounded-2xl border border-gray-6 bg-gray-2/60 px-3 py-2 text-sm text-gray-11">
|
||||
<span>09:00</span>
|
||||
<Clock size={16} class="text-gray-8" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]}>
|
||||
{(day) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`h-8 w-8 rounded-full text-[10px] font-bold ${
|
||||
day === "Sa" || day === "Su"
|
||||
? "bg-gray-2 text-gray-9"
|
||||
: "bg-gray-12 text-gray-1"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 border-t border-gray-6 bg-gray-2/40 px-8 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="px-4 py-2 text-xs font-medium text-gray-9 hover:text-gray-12"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
class="cursor-not-allowed rounded-lg bg-gray-3 px-4 py-2 text-xs font-medium text-gray-8"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
|
||||
export default function ProtoV1UxView() {
|
||||
const [activeTab, setActiveTab] = createSignal<TabKey>("new-thread");
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const [modalOpen, setModalOpen] = createSignal(false);
|
||||
const sendEnabled = createMemo(() => inputValue().trim().length > 0);
|
||||
|
||||
return (
|
||||
<main class="min-h-screen bg-gray-1 text-gray-12">
|
||||
<div class="relative min-h-screen">
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute -top-24 left-1/2 h-72 w-[60rem] -translate-x-1/2 rounded-full bg-gradient-to-br from-gray-2/80 via-gray-1 to-gray-2/80 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-0 h-64 w-64 rounded-full bg-gradient-to-br from-blue-3/40 to-transparent blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-6 px-4 py-6 lg:flex-row lg:gap-4 lg:px-6">
|
||||
<aside class="flex w-full flex-col gap-6 rounded-3xl border border-gray-6 bg-gray-1/80 p-4 lg:w-72">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-gray-6 bg-gray-2">
|
||||
<Terminal size={18} class="text-gray-11" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-12">OpenWork</div>
|
||||
<div class="text-xs text-gray-9">Proto v1 UX</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Worker settings"
|
||||
class="rounded-lg p-2 text-gray-8 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="space-y-1">
|
||||
<For each={navTabs}>
|
||||
{(item) => (
|
||||
<SidebarItem
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
active={activeTab() === item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 text-[11px] font-bold uppercase tracking-wider text-gray-8">
|
||||
<span>Threads</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Layout size={14} class="text-gray-8" />
|
||||
<Plus size={14} class="text-gray-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-3 overflow-y-auto pr-1">
|
||||
<ProjectFolder name="zerofinance">
|
||||
<For each={threadItems}>
|
||||
{(item) => <ThreadItem text={item.text} time={item.time} />}
|
||||
</For>
|
||||
</ProjectFolder>
|
||||
<button type="button" class="px-3 text-xs font-medium text-gray-9 hover:text-gray-12">
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarItem icon={Settings} label="Settings" />
|
||||
</aside>
|
||||
|
||||
<section class="flex min-h-[70vh] flex-1 flex-col overflow-hidden rounded-3xl border border-gray-6 bg-gray-1/90 shadow-sm">
|
||||
<Switch>
|
||||
<Match when={activeTab() === "skills"}>
|
||||
<header class="flex h-16 items-center justify-between border-b border-gray-6 px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="button" class="flex items-center gap-2 text-xs font-medium text-gray-9 hover:text-gray-12">
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
<div class="relative">
|
||||
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-8" />
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search skills"
|
||||
placeholder="Search skills"
|
||||
class="w-44 rounded-lg border border-gray-6 bg-gray-2/60 py-2 pl-9 pr-4 text-xs text-gray-11 transition-all focus:w-56 focus:outline-none focus:ring-1 focus:ring-blue-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="rounded-lg bg-gray-12 px-3 py-2 text-xs font-medium text-gray-1 hover:bg-gray-11">
|
||||
<span class="flex items-center gap-2">
|
||||
<Plus size={14} />
|
||||
New skill
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-12 pt-6">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold text-gray-12">Skills</h2>
|
||||
<p class="mt-1 text-sm text-gray-9">
|
||||
Give Codex superpowers. <span class="text-blue-9">Learn more</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<h3 class="mb-4 text-[11px] font-bold uppercase tracking-widest text-gray-8">Installed</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<For each={skillInstalled}>
|
||||
{(skill) => (
|
||||
<SkillCard
|
||||
icon={skill.icon}
|
||||
title={skill.title}
|
||||
description={skill.description}
|
||||
badge={skill.badge}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-[11px] font-bold uppercase tracking-widest text-gray-8">Recommended</h3>
|
||||
<div class="grid grid-cols-1 gap-4 pb-12 md:grid-cols-2">
|
||||
<For each={skillRecommended}>
|
||||
{(skill) => (
|
||||
<SkillCard
|
||||
icon={skill.icon}
|
||||
title={skill.title}
|
||||
description={skill.description}
|
||||
type="add"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeTab() === "automations"}>
|
||||
<header class="flex h-16 items-center justify-end border-b border-gray-6 px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="button" class="text-xs font-medium text-gray-9 hover:text-gray-12">
|
||||
Learn more
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
class="rounded-lg bg-gray-12 px-3 py-2 text-xs font-medium text-gray-1 hover:bg-gray-11"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Plus size={14} />
|
||||
New automation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 flex-col items-center overflow-y-auto px-6 pb-16 pt-10">
|
||||
<div class="mb-10 text-center">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-gray-6 bg-gray-2 shadow-sm">
|
||||
<Terminal size={28} class="text-gray-9" />
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<h2 class="text-2xl font-semibold text-gray-12">Automations</h2>
|
||||
<span class="rounded-full border border-gray-6 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-gray-9">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-9">Automate work by setting up scheduled tasks.</p>
|
||||
</div>
|
||||
<div class="grid w-full max-w-5xl grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={automationTemplates}>
|
||||
{(card) => (
|
||||
<AutomationCard
|
||||
icon={card.icon}
|
||||
description={card.description}
|
||||
color={card.color}
|
||||
onClick={() => setModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<button type="button" class="mt-10 text-xs text-gray-9 hover:text-gray-12">
|
||||
Explore more
|
||||
</button>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={true}>
|
||||
<header class="flex h-16 items-center justify-between border-b border-gray-6 px-6">
|
||||
<h1 class="text-sm font-semibold text-gray-12">New thread</h1>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-6 px-3 py-2 text-gray-11 transition-colors hover:bg-gray-2"
|
||||
>
|
||||
<ExternalLink size={14} class="text-blue-9" />
|
||||
Open
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<div class="h-4 w-px bg-gray-6" />
|
||||
<div class="flex items-center gap-2 text-[10px] font-bold">
|
||||
<span class="text-emerald-9">+9,674</span>
|
||||
<span class="text-red-9">-6,229</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="relative flex flex-1 flex-col items-center justify-center px-6 pb-24">
|
||||
<div class="mb-12 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border border-gray-6 bg-gray-2 shadow-sm">
|
||||
<Terminal size={32} class="text-gray-9" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-semibold text-gray-12">Let's build</h2>
|
||||
<button type="button" class="group mt-2 flex items-center justify-center gap-2 text-xl font-medium text-gray-9 hover:text-gray-12">
|
||||
zerofinance
|
||||
<ChevronDown size={18} class="transition-transform group-hover:translate-y-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid w-full max-w-5xl grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<For each={promptSuggestions}>
|
||||
{(card) => (
|
||||
<AutomationCard
|
||||
icon={card.icon}
|
||||
description={card.description}
|
||||
color={card.color}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-6 w-full max-w-3xl">
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1 px-3 py-2 shadow-lg">
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder="Ask Codex anything..."
|
||||
value={inputValue()}
|
||||
onInput={(event) => setInputValue(event.currentTarget.value)}
|
||||
class="min-h-[44px] w-full resize-none bg-transparent p-3 text-sm text-gray-12 focus:outline-none"
|
||||
/>
|
||||
<div class="flex items-center justify-between px-2 pb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add attachment"
|
||||
class="rounded-md p-1.5 text-gray-8 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium text-gray-10 transition-colors hover:bg-gray-2 hover:text-gray-12"
|
||||
>
|
||||
GPT-5-Codex
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Send"
|
||||
disabled={!sendEnabled()}
|
||||
class={`rounded-full p-2 transition-colors ${
|
||||
sendEnabled()
|
||||
? "bg-gray-12 text-gray-1"
|
||||
: "bg-gray-3 text-gray-8"
|
||||
}`}
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<CreateAutomationModal open={modalOpen()} onClose={() => setModalOpen(false)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Command,
|
||||
Folder,
|
||||
Play,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
} from "lucide-solid";
|
||||
|
||||
import type { WorkspaceInfo } from "../lib/tauri";
|
||||
import Button from "../components/button";
|
||||
import OpenWorkLogo from "../components/openwork-logo";
|
||||
import WorkspaceChip from "../components/workspace-chip";
|
||||
|
||||
type ProtoView = "onboarding" | "dashboard" | "session";
|
||||
|
||||
type SessionProto = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
workspaceId: string;
|
||||
updated: string;
|
||||
status: "idle" | "running" | "failed";
|
||||
};
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [
|
||||
{
|
||||
id: "ws-01",
|
||||
name: "Finance Ops",
|
||||
path: "/Users/susan/FinanceOps",
|
||||
preset: "starter",
|
||||
workspaceType: "local",
|
||||
},
|
||||
{
|
||||
id: "ws-02",
|
||||
name: "Mobile QA Lab",
|
||||
path: "/Users/susan/MobileQA",
|
||||
preset: "automation",
|
||||
workspaceType: "local",
|
||||
},
|
||||
{
|
||||
id: "ws-03",
|
||||
name: "Shared Host",
|
||||
path: "/Users/bob/Shared",
|
||||
preset: "starter",
|
||||
workspaceType: "remote",
|
||||
baseUrl: "http://10.0.0.8:4096",
|
||||
},
|
||||
];
|
||||
|
||||
const sessions: SessionProto[] = [
|
||||
{
|
||||
id: "s-01",
|
||||
title: "Reconcile vendor overages",
|
||||
slug: "rv",
|
||||
workspaceId: "ws-01",
|
||||
updated: "2m ago",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
id: "s-02",
|
||||
title: "Generate QA report",
|
||||
slug: "qr",
|
||||
workspaceId: "ws-02",
|
||||
updated: "28m ago",
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "s-03",
|
||||
title: "Sync policy checklist",
|
||||
slug: "pc",
|
||||
workspaceId: "ws-01",
|
||||
updated: "2h ago",
|
||||
status: "failed",
|
||||
},
|
||||
];
|
||||
|
||||
const statusStyles: Record<SessionProto["status"], string> = {
|
||||
idle: "text-gray-10",
|
||||
running: "text-emerald-11",
|
||||
failed: "text-red-11",
|
||||
};
|
||||
|
||||
const viewLabels: Record<ProtoView, string> = {
|
||||
onboarding: "Onboarding",
|
||||
dashboard: "Dashboard",
|
||||
session: "Session",
|
||||
};
|
||||
|
||||
const navItems: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
}> = [
|
||||
{ id: "scheduled", label: "Schedule", icon: Command },
|
||||
{ id: "sessions", label: "Sessions", icon: Play },
|
||||
{ id: "skills", label: "Skills", icon: Folder },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
{ id: "mcp", label: "MCPs", icon: Server },
|
||||
];
|
||||
|
||||
export default function ProtoWorkspacesView() {
|
||||
const [view, setView] = createSignal<ProtoView>("dashboard");
|
||||
const activeWorkspace = createMemo(() => workspaces[0]);
|
||||
const activeWorkspaceName = createMemo(() => activeWorkspace().name);
|
||||
|
||||
const workspaceById = (id: string) => workspaces.find((ws) => ws.id === id) ?? workspaces[0];
|
||||
|
||||
return (
|
||||
<main class="min-h-screen bg-gray-1 text-gray-12">
|
||||
<div class="mx-auto max-w-6xl px-6 py-10 space-y-8">
|
||||
<header class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-gray-6/70 bg-gray-2/40 px-3 py-1 text-xs text-gray-10">
|
||||
Prototype: multi-workspace incremental
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-semibold text-gray-12">Multi-workspace flow preview.</h1>
|
||||
<p class="text-sm text-gray-10 max-w-2xl">
|
||||
UI-only mock that maps to the current onboarding, dashboard, and session layouts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex items-center gap-1 rounded-full border border-gray-6/70 bg-gray-2/50 p-1">
|
||||
<For each={("onboarding dashboard session".split(" ") as ProtoView[])}>
|
||||
{(key) => (
|
||||
<button
|
||||
onClick={() => setView(key)}
|
||||
class={`rounded-full px-3 py-1 text-xs transition-colors ${
|
||||
view() === key
|
||||
? "bg-gray-12 text-gray-1"
|
||||
: "text-gray-10 hover:text-gray-12"
|
||||
}`}
|
||||
>
|
||||
{viewLabels[key]}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Show when={view() === "onboarding"}>
|
||||
<div class="min-h-[70vh] flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
|
||||
<div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
|
||||
<div class="max-w-xl w-full z-10 space-y-10">
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-3 mb-6">
|
||||
<OpenWorkLogo size={48} />
|
||||
<h2 class="text-3xl font-bold tracking-tight text-gray-12">OpenWork</h2>
|
||||
</div>
|
||||
<h3 class="text-xl text-gray-11">Choose how to connect</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 rounded-3xl text-left transition-all duration-300 flex items-start gap-6">
|
||||
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6">
|
||||
<Play size={18} class="text-gray-11" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xl font-medium text-gray-12 mb-2">Run on this machine</h4>
|
||||
<p class="text-gray-10 text-sm leading-relaxed">
|
||||
Start OpenCode locally and pick a workspace folder.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 rounded-3xl text-left transition-all duration-300 flex items-start gap-6">
|
||||
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6">
|
||||
<Server size={18} class="text-gray-11" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xl font-medium text-gray-12 mb-2">Connect to a host</h4>
|
||||
<p class="text-gray-10 text-sm leading-relaxed">
|
||||
Pair with an existing host and select a shared workspace.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/60 px-5 py-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">Workspaces</div>
|
||||
<div class="mt-1 text-sm text-gray-12">Add a workspace folder or import one.</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="text-xs px-3 py-1.5">Pick folder</Button>
|
||||
<Button variant="outline" class="text-xs px-3 py-1.5">Import config</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<For each={workspaces.slice(0, 2)}>
|
||||
{(workspace) => (
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl bg-gray-1/40 border border-gray-6 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-12 truncate">{workspace.name}</div>
|
||||
<div class="text-[11px] text-gray-10 font-mono truncate">{workspace.path}</div>
|
||||
</div>
|
||||
<Button variant="ghost" class="text-xs px-2 py-1">Use</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={view() === "dashboard"}>
|
||||
<div class="flex h-[70vh] bg-gray-1 text-gray-12 overflow-hidden rounded-3xl border border-gray-6">
|
||||
<aside class="w-60 border-r border-gray-6 p-6 hidden md:flex flex-col justify-between bg-gray-1">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-10 px-2">
|
||||
<OpenWorkLogo size={28} />
|
||||
<span class="font-bold text-lg tracking-tight">OpenWork</span>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<For each={navItems}>
|
||||
{(item) => (
|
||||
<button
|
||||
class={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
item.id === "scheduled"
|
||||
? "bg-gray-2 text-gray-12"
|
||||
: "text-gray-10 hover:text-gray-12 hover:bg-gray-2/50"
|
||||
}`}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Button variant="secondary" class="w-full">Connect</Button>
|
||||
<Button variant="outline" class="w-full">Settings</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-y-auto relative">
|
||||
<header class="h-16 flex items-center justify-between px-6 md:px-8 border-b border-gray-6 sticky top-0 bg-gray-1/80 backdrop-blur-md z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<WorkspaceChip
|
||||
workspace={activeWorkspace()}
|
||||
onClick={() => undefined}
|
||||
connecting={false}
|
||||
/>
|
||||
<h2 class="text-lg font-medium">Dashboard</h2>
|
||||
<span class="text-xs text-gray-10">Active: {activeWorkspaceName()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" class="text-xs">
|
||||
Share config
|
||||
</Button>
|
||||
<Button disabled={false}>
|
||||
<Play size={16} />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="p-6 md:p-8 max-w-4xl mx-auto space-y-8">
|
||||
<section>
|
||||
<div class="bg-gradient-to-r from-gray-2 to-gray-4 rounded-3xl p-1">
|
||||
<div class="bg-gray-1 rounded-[22px] p-6 md:p-8 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div class="space-y-2 text-center md:text-left">
|
||||
<h3 class="text-2xl font-semibold text-gray-12">What should we do today?</h3>
|
||||
<p class="text-gray-11">Describe an outcome. OpenWork will run it and keep an audit trail.</p>
|
||||
</div>
|
||||
<div class="w-full md:w-[320px]">
|
||||
<div class="flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/50 px-4 py-3">
|
||||
<input
|
||||
placeholder="Draft a task to run..."
|
||||
class="flex-1 bg-transparent border-none p-0 text-sm text-dls-text placeholder:text-dls-secondary focus:ring-0"
|
||||
/>
|
||||
<button class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1">Run</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">Workspaces</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" class="text-xs h-8 px-3">
|
||||
Share config
|
||||
</Button>
|
||||
<Button variant="secondary" class="text-xs h-8 px-3">
|
||||
<Plus size={14} />
|
||||
Add workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<For each={workspaces}>
|
||||
{(workspace) => (
|
||||
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-gray-12">{workspace.name}</div>
|
||||
<div class="text-xs text-gray-10 font-mono truncate">{workspace.path}</div>
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-9">
|
||||
{workspace.workspaceType === "remote" ? "Remote" : "Local"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-9">
|
||||
<span>Last active: 2h ago</span>
|
||||
<Button variant="ghost" class="text-xs px-2 py-1">
|
||||
Switch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">Recent Sessions</h3>
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl overflow-hidden">
|
||||
<For each={sessions}>
|
||||
{(session, idx) => (
|
||||
<button
|
||||
class={`w-full p-4 flex items-center justify-between hover:bg-gray-4/50 transition-colors text-left ${
|
||||
idx() !== sessions.length - 1 ? "border-b border-gray-6/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-4 flex items-center justify-center text-xs text-gray-10 font-mono">
|
||||
#{session.slug}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-sm text-gray-12">{session.title}</div>
|
||||
<div class="text-xs text-gray-10 flex items-center gap-2">
|
||||
<span>{session.updated}</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-10">
|
||||
{workspaceById(session.workspaceId).name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={`text-xs ${statusStyles[session.status]}`}>{session.status}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={view() === "session"}>
|
||||
<div class="h-[70vh] flex flex-col bg-gray-1 text-gray-12 relative rounded-3xl border border-gray-6 overflow-hidden">
|
||||
<header class="h-16 border-b border-gray-6 flex items-center justify-between px-6 bg-gray-1/80 backdrop-blur-md z-10 sticky top-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="ghost" class="!p-2 rounded-full">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</Button>
|
||||
<WorkspaceChip workspace={activeWorkspace()} onClick={() => undefined} />
|
||||
<span class="text-xs text-gray-10">Session: Reconcile vendor overages</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" class="text-xs">Rename</Button>
|
||||
<Button class="text-xs">New Task</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<aside class="hidden lg:flex w-72 border-r border-gray-6 bg-gray-1 flex-col">
|
||||
<div class="p-4 border-b border-gray-6 text-xs text-gray-10 uppercase tracking-wider">
|
||||
Sessions
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<For each={sessions}>
|
||||
{(session) => (
|
||||
<button class="w-full text-left rounded-xl border border-gray-6/60 bg-gray-1/40 px-3 py-2">
|
||||
<div class="text-sm text-gray-12 truncate">{session.title}</div>
|
||||
<div class="text-xs text-gray-10">{workspaceById(session.workspaceId).name}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1 overflow-y-auto pt-6 md:pt-10 px-6 md:px-10">
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="rounded-2xl border border-gray-6/70 bg-gray-2/40 px-4 py-3 text-xs text-gray-11">
|
||||
Thinking · Reading workspace files
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4">
|
||||
<div class="text-xs text-gray-9">User</div>
|
||||
<div class="text-sm text-gray-12">Please reconcile vendor overages for the last month.</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-gray-6/60 bg-gray-2/40 p-4">
|
||||
<div class="text-xs text-gray-9">Assistant</div>
|
||||
<div class="text-sm text-gray-12">
|
||||
I matched the overages to the latest statements and flagged three anomalies.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="hidden lg:flex w-72 border-l border-gray-6 bg-gray-1 flex-col">
|
||||
<div class="p-4 border-b border-gray-6 text-xs text-gray-10 uppercase tracking-wider">
|
||||
Context
|
||||
</div>
|
||||
<div class="p-4 space-y-3 text-sm text-gray-10">
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-3">
|
||||
Working files: 4
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-3">
|
||||
Skills: 2
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-3">
|
||||
Plugins: 1
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-6 bg-gray-1 px-6 py-4">
|
||||
<div class="max-w-2xl mx-auto flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/40 px-4 py-3">
|
||||
<input
|
||||
placeholder="Describe a task..."
|
||||
class="flex-1 bg-transparent text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none"
|
||||
/>
|
||||
<button class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
21
apps/app/src/app/session/actions-provider.tsx
Normal file
21
apps/app/src/app/session/actions-provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import type { SessionActionsStore } from "./actions-store";
|
||||
|
||||
const SessionActionsContext = createContext<SessionActionsStore>();
|
||||
|
||||
export function SessionActionsProvider(props: ParentProps<{ store: SessionActionsStore }>) {
|
||||
return (
|
||||
<SessionActionsContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</SessionActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSessionActions() {
|
||||
const context = useContext(SessionActionsContext);
|
||||
if (!context) {
|
||||
throw new Error("useSessionActions must be used within a SessionActionsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
805
apps/app/src/app/session/actions-store.ts
Normal file
805
apps/app/src/app/session/actions-store.ts
Normal file
@@ -0,0 +1,805 @@
|
||||
import { createMemo, createSignal } from "solid-js";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentPartInput,
|
||||
FilePartInput,
|
||||
Session,
|
||||
SubtaskPartInput,
|
||||
TextPartInput,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { unwrap } from "../lib/opencode";
|
||||
import {
|
||||
abortSession as abortSessionTyped,
|
||||
abortSessionSafe,
|
||||
compactSession as compactSessionTyped,
|
||||
listCommands as listCommandsTyped,
|
||||
revertSession,
|
||||
shellInSession,
|
||||
unrevertSession,
|
||||
} from "../lib/opencode-session";
|
||||
import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log";
|
||||
import { toSessionTransportDirectory } from "../lib/session-scope";
|
||||
import type {
|
||||
Client,
|
||||
ComposerAttachment,
|
||||
ComposerDraft,
|
||||
ComposerPart,
|
||||
MessageWithParts,
|
||||
ModelRef,
|
||||
} from "../types";
|
||||
import { addOpencodeCacheHint, safeStringify } from "../utils";
|
||||
import type { createModelConfigStore } from "../context/model-config";
|
||||
|
||||
export type SessionActionsStore = ReturnType<typeof createSessionActionsStore>;
|
||||
|
||||
const fileToDataUrl = (file: File) =>
|
||||
new Promise<string>((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);
|
||||
});
|
||||
|
||||
export function createSessionActionsStore(options: {
|
||||
client: () => Client | null;
|
||||
baseUrl: () => string;
|
||||
developerMode: () => boolean;
|
||||
prompt: () => string;
|
||||
setPrompt: (value: string) => void;
|
||||
selectedSessionId: () => string | null;
|
||||
selectedSession: () => Session | null;
|
||||
sessions: () => Session[];
|
||||
messages: () => MessageWithParts[];
|
||||
setSessions: (value: Session[]) => void;
|
||||
sessionStatusById: () => Record<string, string>;
|
||||
setSessionStatusById: (value: Record<string, string>) => void;
|
||||
setBusy: (value: boolean) => void;
|
||||
setBusyLabel: (value: string | null) => void;
|
||||
setBusyStartedAt: (value: number | null) => void;
|
||||
setCreatingSession: (value: boolean) => void;
|
||||
setError: (value: string | null) => void;
|
||||
workspaceProjectDir: () => string;
|
||||
selectedWorkspaceId: () => string;
|
||||
selectedWorkspaceRoot: () => string;
|
||||
ensureSelectedWorkspaceRuntime: () => Promise<boolean>;
|
||||
selectSession: (id: string) => Promise<void>;
|
||||
refreshSidebarWorkspaceSessions: (workspaceId: string) => Promise<void>;
|
||||
abortRefreshes: () => void;
|
||||
modelConfig: ReturnType<typeof createModelConfigStore>;
|
||||
selectedSessionModel: () => ModelRef;
|
||||
modelVariant: () => string | null;
|
||||
sanitizeModelVariantForRef: (ref: ModelRef, value: string | null) => string | null;
|
||||
resolveCodexReasoningEffort: (modelId: string, variant: string | null) => string | undefined;
|
||||
messageIdFromInfo: (message: MessageWithParts) => string;
|
||||
restorePromptFromUserMessage: (message: MessageWithParts) => void;
|
||||
upsertLocalSession: (session: Session | null | undefined) => void;
|
||||
readSessionByWorkspace: () => Record<string, string>;
|
||||
writeSessionByWorkspace: (map: Record<string, string>) => void;
|
||||
setSelectedSessionId: (value: string | null) => void;
|
||||
locationPath: () => string;
|
||||
navigate: (path: string, options?: { replace?: boolean }) => void;
|
||||
renameSession: (sessionId: string, title: string) => Promise<void>;
|
||||
appendSessionErrorTurn: (sessionId: string, message: string) => void;
|
||||
}) {
|
||||
const [lastPromptSent, setLastPromptSent] = createSignal("");
|
||||
const [sessionAgentById, setSessionAgentById] = createSignal<Record<string, string>>({});
|
||||
|
||||
type PartInput = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput;
|
||||
|
||||
const attachmentToFilePart = async (attachment: ComposerAttachment): Promise<FilePartInput> => ({
|
||||
type: "file",
|
||||
url: await fileToDataUrl(attachment.file),
|
||||
filename: attachment.name,
|
||||
mime: attachment.mimeType,
|
||||
});
|
||||
|
||||
const buildPromptParts = async (draft: ComposerDraft): Promise<PartInput[]> => {
|
||||
const parts: PartInput[] = [];
|
||||
const text = draft.resolvedText ?? draft.text;
|
||||
parts.push({ type: "text", text } as TextPartInput);
|
||||
|
||||
const root = options.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 === "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);
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(...(await Promise.all(draft.attachments.map(attachmentToFilePart))));
|
||||
return parts;
|
||||
};
|
||||
|
||||
const buildCommandFileParts = async (draft: ComposerDraft): Promise<FilePartInput[]> => {
|
||||
const parts: FilePartInput[] = [];
|
||||
const root = options.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 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<string, unknown>[] = [];
|
||||
const root = error && typeof error === "object" ? (error as Record<string, unknown>) : null;
|
||||
if (root) {
|
||||
records.push(root);
|
||||
if (root.data && typeof root.data === "object") records.push(root.data as Record<string, unknown>);
|
||||
if (root.cause && typeof root.cause === "object") {
|
||||
const cause = root.cause as Record<string, unknown>;
|
||||
records.push(cause);
|
||||
if (cause.data && typeof cause.data === "object") records.push(cause.data as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 selectedSessionAgent = createMemo(() => {
|
||||
const id = options.selectedSessionId();
|
||||
if (!id) return null;
|
||||
return sessionAgentById()[id] ?? null;
|
||||
});
|
||||
|
||||
const sessionRevertMessageId = createMemo(() => options.selectedSession()?.revert?.messageID ?? null);
|
||||
|
||||
async function createSessionAndOpen() {
|
||||
const ready = await options.ensureSelectedWorkspaceRuntime();
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c = options.client();
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
const perfEnabled = options.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<string, unknown>) => {
|
||||
const elapsed = Math.round((perfNow() - startedAt) * 100) / 100;
|
||||
recordPerfLog(perfEnabled, "session.create", event, {
|
||||
runId,
|
||||
elapsedMs: elapsed,
|
||||
...(payload ?? {}),
|
||||
});
|
||||
};
|
||||
|
||||
mark("start", {
|
||||
baseUrl: options.baseUrl(),
|
||||
workspace: options.selectedWorkspaceRoot().trim() || null,
|
||||
});
|
||||
|
||||
options.abortRefreshes();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
options.setBusy(true);
|
||||
options.setBusyLabel("status.creating_task");
|
||||
options.setBusyStartedAt(Date.now());
|
||||
options.setCreatingSession(true);
|
||||
options.setError(null);
|
||||
|
||||
const withTimeout = async <T,>(promise: Promise<T>, ms: number, label: string) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, 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 {
|
||||
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("Connection lost");
|
||||
}
|
||||
|
||||
let rawResult: Awaited<ReturnType<typeof c.session.create>>;
|
||||
try {
|
||||
const directory = toSessionTransportDirectory(options.selectedWorkspaceRoot().trim()) || undefined;
|
||||
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);
|
||||
options.setBusyLabel("status.loading_session");
|
||||
mark("session:select:start", { sessionID: session.id });
|
||||
await options.selectSession(session.id);
|
||||
mark("session:select:ok", { sessionID: session.id });
|
||||
|
||||
options.modelConfig.applyPendingSessionChoice(session.id);
|
||||
|
||||
const currentStoreSessions = options.sessions();
|
||||
if (!currentStoreSessions.some((s) => s.id === session.id)) {
|
||||
options.setSessions([session, ...currentStoreSessions]);
|
||||
}
|
||||
|
||||
const wsId = options.selectedWorkspaceId().trim();
|
||||
if (wsId) {
|
||||
await options.refreshSidebarWorkspaceSessions(wsId).catch(() => undefined);
|
||||
}
|
||||
|
||||
options.navigate(`/session/${session.id}`);
|
||||
|
||||
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 : "Unknown error";
|
||||
options.setError(addOpencodeCacheHint(message));
|
||||
return undefined;
|
||||
} finally {
|
||||
options.setCreatingSession(false);
|
||||
options.setBusy(false);
|
||||
options.setBusyLabel(null);
|
||||
options.setBusyStartedAt(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPrompt(draft?: ComposerDraft) {
|
||||
const hasExplicitDraft = Boolean(draft);
|
||||
const fallbackText = options.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 ready = await options.ensureSelectedWorkspaceRuntime();
|
||||
if (!ready) return;
|
||||
|
||||
const c = options.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 && !options.selectedSessionId()) {
|
||||
options.setError("Select a session with messages before running /compact.");
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionID = options.selectedSessionId();
|
||||
if (!sessionID) {
|
||||
await createSessionAndOpen();
|
||||
sessionID = options.selectedSessionId();
|
||||
}
|
||||
if (!sessionID) return;
|
||||
|
||||
options.setBusy(true);
|
||||
options.setBusyLabel("status.running");
|
||||
options.setBusyStartedAt(Date.now());
|
||||
options.setError(null);
|
||||
|
||||
const perfEnabled = options.developerMode();
|
||||
const startedAt = perfNow();
|
||||
const visible = options.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) {
|
||||
options.setPrompt("");
|
||||
}
|
||||
|
||||
const model = options.selectedSessionModel();
|
||||
const agent = selectedSessionAgent();
|
||||
const parts = await buildPromptParts(resolvedDraft);
|
||||
const selectedVariant = options.sanitizeModelVariantForRef(model, options.modelVariant()) ?? undefined;
|
||||
const reasoningEffort = options.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.");
|
||||
}
|
||||
|
||||
const modelString = `${model.providerID}/${model.modelID}`;
|
||||
const files = await buildCommandFileParts(resolvedDraft);
|
||||
|
||||
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);
|
||||
|
||||
options.modelConfig.setSessionModelById((current) => ({
|
||||
...current,
|
||||
[sessionID]: model,
|
||||
}));
|
||||
|
||||
options.modelConfig.clearSessionModelOverride(sessionID);
|
||||
}
|
||||
|
||||
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);
|
||||
options.appendSessionErrorTurn(sessionID, addOpencodeCacheHint(message));
|
||||
} finally {
|
||||
options.setBusy(false);
|
||||
options.setBusyLabel(null);
|
||||
options.setBusyStartedAt(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortSession(sessionID?: string) {
|
||||
const c = options.client();
|
||||
if (!c) return;
|
||||
const id = (sessionID ?? options.selectedSessionId() ?? "").trim();
|
||||
if (!id) return;
|
||||
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 = options.client();
|
||||
if (!c) {
|
||||
throw new Error("Not connected to a server");
|
||||
}
|
||||
|
||||
const sessionID = (sessionIdOverride ?? options.selectedSessionId() ?? "").trim();
|
||||
if (!sessionID) {
|
||||
throw new Error("Select a session before compacting.");
|
||||
}
|
||||
|
||||
const visible = options.messages();
|
||||
if (!visible.length) {
|
||||
throw new Error("Nothing to compact yet.");
|
||||
}
|
||||
|
||||
const model = options.selectedSessionModel();
|
||||
const startedAt = perfNow();
|
||||
const modelLabel = `${model.providerID}/${model.modelID}`;
|
||||
recordPerfLog(options.developerMode(), "session.compact", "start", {
|
||||
sessionID,
|
||||
messageCount: visible.length,
|
||||
model: modelLabel,
|
||||
variant: options.sanitizeModelVariantForRef(model, options.modelVariant()) ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
await compactSessionTyped(c, sessionID, model, {
|
||||
directory: options.workspaceProjectDir().trim() || undefined,
|
||||
});
|
||||
finishPerf(options.developerMode(), "session.compact", "done", startedAt, {
|
||||
sessionID,
|
||||
messageCount: visible.length,
|
||||
model: modelLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
finishPerf(options.developerMode(), "session.compact", "error", startedAt, {
|
||||
sessionID,
|
||||
messageCount: visible.length,
|
||||
model: modelLabel,
|
||||
error: error instanceof Error ? error.message : safeStringify(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function undoLastUserMessage() {
|
||||
const c = options.client();
|
||||
const sessionID = (options.selectedSessionId() ?? "").trim();
|
||||
if (!c || !sessionID) return;
|
||||
|
||||
await abortSessionSafe(c, sessionID);
|
||||
|
||||
const users = options.messages().filter((message) => {
|
||||
const role = (message.info as { role?: string }).role;
|
||||
return role === "user";
|
||||
});
|
||||
const target = users[users.length - 1];
|
||||
if (!target) return;
|
||||
|
||||
const messageID = options.messageIdFromInfo(target);
|
||||
if (!messageID) return;
|
||||
|
||||
const nextSession = await revertSession(c, sessionID, messageID);
|
||||
options.upsertLocalSession(nextSession);
|
||||
|
||||
if (users.length > 1) {
|
||||
options.restorePromptFromUserMessage(users[users.length - 2]);
|
||||
return;
|
||||
}
|
||||
|
||||
options.setPrompt("");
|
||||
}
|
||||
|
||||
async function redoLastUserMessage() {
|
||||
const c = options.client();
|
||||
const sessionID = (options.selectedSessionId() ?? "").trim();
|
||||
if (!c || !sessionID) return;
|
||||
|
||||
await abortSessionSafe(c, sessionID);
|
||||
|
||||
const revertMessageID = options.selectedSession()?.revert?.messageID ?? null;
|
||||
if (!revertMessageID) return;
|
||||
|
||||
const users = options.messages().filter((message) => {
|
||||
const role = (message.info as { role?: string }).role;
|
||||
return role === "user";
|
||||
});
|
||||
|
||||
const next = users.find((message) => {
|
||||
const id = options.messageIdFromInfo(message);
|
||||
return Boolean(id) && id > revertMessageID;
|
||||
});
|
||||
|
||||
if (!next) {
|
||||
const session = await unrevertSession(c, sessionID);
|
||||
options.upsertLocalSession(session);
|
||||
options.setPrompt("");
|
||||
return;
|
||||
}
|
||||
|
||||
const messageID = options.messageIdFromInfo(next);
|
||||
if (!messageID) return;
|
||||
|
||||
const nextSession = await revertSession(c, sessionID, messageID);
|
||||
options.upsertLocalSession(nextSession);
|
||||
|
||||
let prior: MessageWithParts | null = null;
|
||||
for (let idx = users.length - 1; idx >= 0; idx -= 1) {
|
||||
const candidate = users[idx];
|
||||
const id = options.messageIdFromInfo(candidate);
|
||||
if (id && id < messageID) {
|
||||
prior = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (prior) {
|
||||
options.restorePromptFromUserMessage(prior);
|
||||
return;
|
||||
}
|
||||
|
||||
options.setPrompt("");
|
||||
}
|
||||
|
||||
async function renameSessionTitle(sessionID: string, title: string) {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Session name is required");
|
||||
}
|
||||
|
||||
await options.renameSession(sessionID, trimmed);
|
||||
await options.refreshSidebarWorkspaceSessions(options.selectedWorkspaceId()).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function deleteSessionById(sessionID: string) {
|
||||
const trimmed = sessionID.trim();
|
||||
if (!trimmed) return;
|
||||
const c = options.client();
|
||||
if (!c) {
|
||||
throw new Error("Not connected to a server");
|
||||
}
|
||||
|
||||
const root = options.selectedWorkspaceRoot().trim();
|
||||
const directory = toSessionTransportDirectory(root);
|
||||
const params = directory ? { sessionID: trimmed, directory } : { sessionID: trimmed };
|
||||
unwrap(await c.session.delete(params));
|
||||
|
||||
options.setSessions(options.sessions().filter((s) => s.id !== trimmed));
|
||||
const activeWsId = options.selectedWorkspaceId();
|
||||
await options.refreshSidebarWorkspaceSessions(activeWsId).catch(() => undefined);
|
||||
|
||||
try {
|
||||
const path = options.locationPath().toLowerCase();
|
||||
if (path === `/session/${trimmed.toLowerCase()}`) {
|
||||
options.navigate("/session", { replace: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (options.selectedSessionId() === trimmed) {
|
||||
options.setSelectedSessionId(null);
|
||||
const activeWorkspace = options.selectedWorkspaceId().trim();
|
||||
if (activeWorkspace) {
|
||||
const map = options.readSessionByWorkspace();
|
||||
if (map[activeWorkspace] === trimmed) {
|
||||
const next = { ...map };
|
||||
delete next[activeWorkspace];
|
||||
options.writeSessionByWorkspace(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextStatus = { ...options.sessionStatusById() };
|
||||
if (nextStatus[trimmed]) {
|
||||
delete nextStatus[trimmed];
|
||||
options.setSessionStatusById(nextStatus);
|
||||
}
|
||||
}
|
||||
|
||||
async function listAgents(): Promise<Agent[]> {
|
||||
const c = options.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 = options.client();
|
||||
if (!c) return [];
|
||||
const list = await listCommandsTyped(c, options.selectedWorkspaceRoot().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 searchWorkspaceFiles = async (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
const activeClient = options.client();
|
||||
if (!activeClient) return [];
|
||||
try {
|
||||
const directory = options.workspaceProjectDir().trim();
|
||||
const result = unwrap(
|
||||
await activeClient.find.files({
|
||||
query: trimmed,
|
||||
dirs: "true",
|
||||
limit: 50,
|
||||
directory: directory || undefined,
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lastPromptSent,
|
||||
selectedSessionAgent,
|
||||
sessionRevertMessageId,
|
||||
createSessionAndOpen,
|
||||
sendPrompt,
|
||||
abortSession,
|
||||
retryLastPrompt,
|
||||
compactCurrentSession,
|
||||
undoLastUserMessage,
|
||||
redoLastUserMessage,
|
||||
renameSessionTitle,
|
||||
deleteSessionById,
|
||||
listAgents,
|
||||
listCommands,
|
||||
setSessionAgent,
|
||||
searchWorkspaceFiles,
|
||||
};
|
||||
}
|
||||
614
apps/app/src/app/session/share-workspace.ts
Normal file
614
apps/app/src/app/session/share-workspace.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
} from "solid-js";
|
||||
|
||||
import {
|
||||
publishSkillsSetBundleFromWorkspace,
|
||||
publishWorkspaceProfileBundleFromWorkspace,
|
||||
saveWorkspaceProfileBundleToTeam,
|
||||
} from "../bundles/publish";
|
||||
import { buildDenAuthUrl, readDenSettings } from "../lib/den";
|
||||
import {
|
||||
buildOpenworkWorkspaceBaseUrl,
|
||||
createOpenworkServerClient,
|
||||
OpenworkServerError,
|
||||
parseOpenworkWorkspaceIdFromUrl,
|
||||
type OpenworkWorkspaceExportSensitiveMode,
|
||||
type OpenworkWorkspaceExportWarning,
|
||||
} from "../lib/openwork-server";
|
||||
import type {
|
||||
EngineInfo,
|
||||
OpenworkServerInfo,
|
||||
WorkspaceInfo,
|
||||
} from "../lib/tauri";
|
||||
import type { OpenworkServerSettings } from "../lib/openwork-server";
|
||||
import { isTauriRuntime, normalizeDirectoryPath } from "../utils";
|
||||
|
||||
export type ShareWorkspaceState = ReturnType<typeof createShareWorkspaceState>;
|
||||
|
||||
type ShareWorkspaceStateOptions = {
|
||||
workspaces: Accessor<WorkspaceInfo[]>;
|
||||
openworkServerHostInfo: Accessor<OpenworkServerInfo | null>;
|
||||
openworkServerSettings: Accessor<OpenworkServerSettings>;
|
||||
engineInfo: Accessor<EngineInfo | null>;
|
||||
exportWorkspaceBusy: Accessor<boolean>;
|
||||
openLink: (url: string) => void;
|
||||
workspaceLabel: (workspace: WorkspaceInfo) => string;
|
||||
};
|
||||
|
||||
export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) {
|
||||
type ShareWorkspaceProfileSensitiveMode = Exclude<OpenworkWorkspaceExportSensitiveMode, "auto">;
|
||||
|
||||
const [shareWorkspaceId, setShareWorkspaceId] = createSignal<string | null>(null);
|
||||
const [shareLocalOpenworkWorkspaceId, setShareLocalOpenworkWorkspaceId] =
|
||||
createSignal<string | null>(null);
|
||||
const [shareWorkspaceProfileBusy, setShareWorkspaceProfileBusy] =
|
||||
createSignal(false);
|
||||
const [shareWorkspaceProfileUrl, setShareWorkspaceProfileUrl] =
|
||||
createSignal<string | null>(null);
|
||||
const [shareWorkspaceProfileError, setShareWorkspaceProfileError] =
|
||||
createSignal<string | null>(null);
|
||||
const [shareWorkspaceProfileSensitiveWarnings, setShareWorkspaceProfileSensitiveWarnings] =
|
||||
createSignal<OpenworkWorkspaceExportWarning[] | null>(null);
|
||||
const [shareWorkspaceProfileSensitiveMode, setShareWorkspaceProfileSensitiveMode] =
|
||||
createSignal<ShareWorkspaceProfileSensitiveMode | null>(null);
|
||||
const [shareWorkspaceProfileTeamBusy, setShareWorkspaceProfileTeamBusy] =
|
||||
createSignal(false);
|
||||
const [shareWorkspaceProfileTeamError, setShareWorkspaceProfileTeamError] =
|
||||
createSignal<string | null>(null);
|
||||
const [shareWorkspaceProfileTeamSuccess, setShareWorkspaceProfileTeamSuccess] =
|
||||
createSignal<string | null>(null);
|
||||
const [shareCloudSettingsVersion, setShareCloudSettingsVersion] =
|
||||
createSignal(0);
|
||||
const [shareSkillsSetBusy, setShareSkillsSetBusy] = createSignal(false);
|
||||
const [shareSkillsSetUrl, setShareSkillsSetUrl] = createSignal<string | null>(null);
|
||||
const [shareSkillsSetError, setShareSkillsSetError] =
|
||||
createSignal<string | null>(null);
|
||||
|
||||
const openShareWorkspace = (workspaceId: string) => setShareWorkspaceId(workspaceId);
|
||||
const closeShareWorkspace = () => setShareWorkspaceId(null);
|
||||
|
||||
const shareWorkspace = createMemo(() => {
|
||||
const id = shareWorkspaceId();
|
||||
if (!id) return null;
|
||||
return options.workspaces().find((workspace) => workspace.id === id) ?? null;
|
||||
});
|
||||
|
||||
const shareWorkspaceName = createMemo(() => {
|
||||
const workspace = shareWorkspace();
|
||||
return workspace ? options.workspaceLabel(workspace) : "";
|
||||
});
|
||||
|
||||
const shareWorkspaceDetail = createMemo(() => {
|
||||
const workspace = shareWorkspace();
|
||||
if (!workspace) return "";
|
||||
if (workspace.workspaceType === "remote") {
|
||||
if (workspace.remoteType === "openwork") {
|
||||
const hostUrl =
|
||||
workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || "";
|
||||
const mounted = buildOpenworkWorkspaceBaseUrl(
|
||||
hostUrl,
|
||||
workspace.openworkWorkspaceId,
|
||||
);
|
||||
return mounted || hostUrl;
|
||||
}
|
||||
return workspace.baseUrl?.trim() || "";
|
||||
}
|
||||
return workspace.path?.trim() || "";
|
||||
});
|
||||
|
||||
createEffect(
|
||||
on(shareWorkspaceId, () => {
|
||||
setShareWorkspaceProfileBusy(false);
|
||||
setShareWorkspaceProfileUrl(null);
|
||||
setShareWorkspaceProfileError(null);
|
||||
setShareWorkspaceProfileSensitiveWarnings(null);
|
||||
setShareWorkspaceProfileSensitiveMode(null);
|
||||
setShareWorkspaceProfileTeamBusy(false);
|
||||
setShareWorkspaceProfileTeamError(null);
|
||||
setShareWorkspaceProfileTeamSuccess(null);
|
||||
setShareSkillsSetBusy(false);
|
||||
setShareSkillsSetUrl(null);
|
||||
setShareSkillsSetError(null);
|
||||
}),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const workspace = shareWorkspace();
|
||||
const baseUrl = options.openworkServerHostInfo()?.baseUrl?.trim() ?? "";
|
||||
const token =
|
||||
options.openworkServerHostInfo()?.ownerToken?.trim() ||
|
||||
options.openworkServerHostInfo()?.clientToken?.trim() ||
|
||||
"";
|
||||
const workspacePath =
|
||||
workspace?.workspaceType === "local" ? (workspace.path?.trim() ?? "") : "";
|
||||
|
||||
if (
|
||||
!workspace ||
|
||||
workspace.workspaceType !== "local" ||
|
||||
!workspacePath ||
|
||||
!baseUrl ||
|
||||
!token
|
||||
) {
|
||||
setShareLocalOpenworkWorkspaceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setShareLocalOpenworkWorkspaceId(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const client = createOpenworkServerClient({ baseUrl, token });
|
||||
const response = await client.listWorkspaces();
|
||||
if (cancelled) return;
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const targetPath = normalizeDirectoryPath(workspacePath);
|
||||
const match = items.find(
|
||||
(entry) => normalizeDirectoryPath(entry.path) === targetPath,
|
||||
);
|
||||
setShareLocalOpenworkWorkspaceId(match?.id ?? null);
|
||||
} catch {
|
||||
if (!cancelled) setShareLocalOpenworkWorkspaceId(null);
|
||||
}
|
||||
})();
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
|
||||
const shareFields = createMemo(() => {
|
||||
const workspace = shareWorkspace();
|
||||
if (!workspace) {
|
||||
return [] as Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
secret?: boolean;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
if (workspace.workspaceType !== "remote") {
|
||||
if (options.openworkServerHostInfo()?.remoteAccessEnabled !== true) {
|
||||
return [];
|
||||
}
|
||||
const hostUrl =
|
||||
options.openworkServerHostInfo()?.connectUrl?.trim() ||
|
||||
options.openworkServerHostInfo()?.lanUrl?.trim() ||
|
||||
options.openworkServerHostInfo()?.mdnsUrl?.trim() ||
|
||||
options.openworkServerHostInfo()?.baseUrl?.trim() ||
|
||||
"";
|
||||
const mountedUrl = shareLocalOpenworkWorkspaceId()
|
||||
? buildOpenworkWorkspaceBaseUrl(hostUrl, shareLocalOpenworkWorkspaceId())
|
||||
: null;
|
||||
const url = mountedUrl || hostUrl;
|
||||
const ownerToken = options.openworkServerHostInfo()?.ownerToken?.trim() || "";
|
||||
const collaboratorToken =
|
||||
options.openworkServerHostInfo()?.clientToken?.trim() || "";
|
||||
return [
|
||||
{
|
||||
label: "Worker URL",
|
||||
value: url,
|
||||
placeholder: !isTauriRuntime()
|
||||
? "Desktop app required"
|
||||
: "Starting server...",
|
||||
hint: mountedUrl
|
||||
? "Use on phones or laptops connecting to this worker."
|
||||
: hostUrl
|
||||
? "Worker URL is resolving; host URL shown as fallback."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: "Password",
|
||||
value: ownerToken,
|
||||
secret: true,
|
||||
placeholder: isTauriRuntime() ? "-" : "Desktop app required",
|
||||
hint: mountedUrl
|
||||
? "Use on phones or laptops connecting to this worker."
|
||||
: "Use when the remote client must answer permission prompts.",
|
||||
},
|
||||
{
|
||||
label: "Collaborator token",
|
||||
value: collaboratorToken,
|
||||
secret: true,
|
||||
placeholder: isTauriRuntime() ? "-" : "Desktop app required",
|
||||
hint: mountedUrl
|
||||
? "Routine remote access when you do not need owner-only actions."
|
||||
: "Routine remote access to this host without owner-only actions.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (workspace.remoteType === "openwork") {
|
||||
const hostUrl = workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || "";
|
||||
const url =
|
||||
buildOpenworkWorkspaceBaseUrl(hostUrl, workspace.openworkWorkspaceId) ||
|
||||
hostUrl;
|
||||
const token =
|
||||
workspace.openworkToken?.trim() ||
|
||||
options.openworkServerSettings().token?.trim() ||
|
||||
"";
|
||||
return [
|
||||
{
|
||||
label: "Worker URL",
|
||||
value: url,
|
||||
},
|
||||
{
|
||||
label: "Password",
|
||||
value: token,
|
||||
secret: true,
|
||||
placeholder: token ? undefined : "Set token in workspace settings",
|
||||
hint: "This workspace is currently connected with this password.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const baseUrl = workspace.baseUrl?.trim() || workspace.path?.trim() || "";
|
||||
const directory = workspace.directory?.trim() || "";
|
||||
return [
|
||||
{
|
||||
label: "OpenCode base URL",
|
||||
value: baseUrl,
|
||||
},
|
||||
{
|
||||
label: "Directory",
|
||||
value: directory,
|
||||
placeholder: "(auto)",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const shareNote = createMemo(() => {
|
||||
const workspace = shareWorkspace();
|
||||
if (!workspace) return null;
|
||||
if (
|
||||
workspace.workspaceType === "local" &&
|
||||
options.engineInfo()?.runtime === "direct"
|
||||
) {
|
||||
return "Engine runtime is set to Direct. Switching local workers can restart the host and disconnect clients. The token may change after a restart.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const shareServiceDisabledReason = createMemo(() => {
|
||||
const workspace = shareWorkspace();
|
||||
if (!workspace) return "Select a workspace first.";
|
||||
if (workspace.workspaceType === "remote" && workspace.remoteType !== "openwork") {
|
||||
return "Share service links are available for OpenWork workers.";
|
||||
}
|
||||
if (workspace.workspaceType !== "remote") {
|
||||
const baseUrl = options.openworkServerHostInfo()?.baseUrl?.trim() ?? "";
|
||||
const token =
|
||||
options.openworkServerHostInfo()?.ownerToken?.trim() ||
|
||||
options.openworkServerHostInfo()?.clientToken?.trim() ||
|
||||
"";
|
||||
if (!baseUrl || !token) {
|
||||
return "Local OpenWork host is not ready yet.";
|
||||
}
|
||||
} else {
|
||||
const hostUrl = workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || "";
|
||||
const token =
|
||||
workspace.openworkToken?.trim() ||
|
||||
options.openworkServerSettings().token?.trim() ||
|
||||
"";
|
||||
if (!hostUrl) return "Missing OpenWork host URL.";
|
||||
if (!token) return "Missing OpenWork token.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const shareCloudSettings = createMemo(() => {
|
||||
shareWorkspaceId();
|
||||
shareCloudSettingsVersion();
|
||||
return readDenSettings();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const handleCloudSessionUpdate = () =>
|
||||
setShareCloudSettingsVersion((value) => value + 1);
|
||||
window.addEventListener(
|
||||
"openwork-den-session-updated",
|
||||
handleCloudSessionUpdate,
|
||||
);
|
||||
onCleanup(() =>
|
||||
window.removeEventListener(
|
||||
"openwork-den-session-updated",
|
||||
handleCloudSessionUpdate,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const shareWorkspaceProfileTeamOrgName = createMemo(() => {
|
||||
const orgName = shareCloudSettings().activeOrgName?.trim();
|
||||
if (orgName) return orgName;
|
||||
return "Active Cloud org";
|
||||
});
|
||||
|
||||
const shareWorkspaceProfileToTeamNeedsSignIn = createMemo(
|
||||
() => !shareCloudSettings().authToken?.trim(),
|
||||
);
|
||||
|
||||
const shareWorkspaceProfileTeamDisabledReason = createMemo(() => {
|
||||
const exportReason = shareServiceDisabledReason();
|
||||
if (exportReason) return exportReason;
|
||||
if (shareWorkspaceProfileToTeamNeedsSignIn()) return null;
|
||||
const settings = shareCloudSettings();
|
||||
if (!settings.activeOrgId?.trim() && !settings.activeOrgSlug?.trim()) {
|
||||
return "Choose an organization in Settings -> Cloud before sharing with your team.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const startShareWorkspaceProfileToTeamSignIn = () => {
|
||||
const settings = readDenSettings();
|
||||
options.openLink(buildDenAuthUrl(settings.baseUrl, "sign-in"));
|
||||
};
|
||||
|
||||
const resolveShareExportContext = async (): Promise<{
|
||||
client: ReturnType<typeof createOpenworkServerClient>;
|
||||
workspaceId: string;
|
||||
workspace: WorkspaceInfo;
|
||||
}> => {
|
||||
const workspace = shareWorkspace();
|
||||
if (!workspace) {
|
||||
throw new Error("Select a workspace first.");
|
||||
}
|
||||
|
||||
if (workspace.workspaceType !== "remote") {
|
||||
const baseUrl = options.openworkServerHostInfo()?.baseUrl?.trim() ?? "";
|
||||
const token =
|
||||
options.openworkServerHostInfo()?.ownerToken?.trim() ||
|
||||
options.openworkServerHostInfo()?.clientToken?.trim() ||
|
||||
"";
|
||||
if (!baseUrl || !token) {
|
||||
throw new Error("Local OpenWork host is not ready yet.");
|
||||
}
|
||||
const client = createOpenworkServerClient({ baseUrl, token });
|
||||
|
||||
let workspaceId = shareLocalOpenworkWorkspaceId()?.trim() ?? "";
|
||||
if (!workspaceId) {
|
||||
const response = await client.listWorkspaces();
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const targetPath = normalizeDirectoryPath(workspace.path?.trim() ?? "");
|
||||
const match = items.find(
|
||||
(entry) => normalizeDirectoryPath(entry.path) === targetPath,
|
||||
);
|
||||
workspaceId = (match?.id ?? "").trim();
|
||||
setShareLocalOpenworkWorkspaceId(workspaceId || null);
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new Error(
|
||||
"Could not resolve this workspace on the local OpenWork host.",
|
||||
);
|
||||
}
|
||||
|
||||
return { client, workspaceId, workspace };
|
||||
}
|
||||
|
||||
if (workspace.remoteType !== "openwork") {
|
||||
throw new Error(
|
||||
"Share service links are available for OpenWork workers.",
|
||||
);
|
||||
}
|
||||
|
||||
const hostUrl = workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || "";
|
||||
const token =
|
||||
workspace.openworkToken?.trim() ||
|
||||
options.openworkServerSettings().token?.trim() ||
|
||||
"";
|
||||
if (!hostUrl || !token) {
|
||||
throw new Error("OpenWork host URL and token are required.");
|
||||
}
|
||||
|
||||
const client = createOpenworkServerClient({ baseUrl: hostUrl, token });
|
||||
let workspaceId =
|
||||
workspace.openworkWorkspaceId?.trim() ||
|
||||
parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") ||
|
||||
parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") ||
|
||||
"";
|
||||
|
||||
if (!workspaceId) {
|
||||
const response = await client.listWorkspaces();
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const directoryHint = normalizeDirectoryPath(
|
||||
workspace.directory?.trim() ?? workspace.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]);
|
||||
workspaceId = (match?.id ?? "").trim();
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new Error("Could not resolve this workspace on the OpenWork host.");
|
||||
}
|
||||
|
||||
return { client, workspaceId, workspace };
|
||||
};
|
||||
|
||||
const publishWorkspaceProfileLink = async () => {
|
||||
if (shareWorkspaceProfileBusy()) return;
|
||||
setShareWorkspaceProfileBusy(true);
|
||||
setShareWorkspaceProfileError(null);
|
||||
setShareWorkspaceProfileUrl(null);
|
||||
|
||||
try {
|
||||
const { client, workspaceId, workspace } = await resolveShareExportContext();
|
||||
const result = await publishWorkspaceProfileBundleFromWorkspace({
|
||||
client,
|
||||
workspaceId,
|
||||
workspaceName: options.workspaceLabel(workspace),
|
||||
sensitiveMode: shareWorkspaceProfileSensitiveMode(),
|
||||
});
|
||||
|
||||
setShareWorkspaceProfileUrl(result.url);
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.url);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch (error) {
|
||||
const warnings = readWorkspaceExportWarnings(error);
|
||||
if (warnings) {
|
||||
setShareWorkspaceProfileSensitiveWarnings(warnings);
|
||||
setShareWorkspaceProfileError(null);
|
||||
return;
|
||||
}
|
||||
setShareWorkspaceProfileError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to publish workspace profile",
|
||||
);
|
||||
} finally {
|
||||
setShareWorkspaceProfileBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shareWorkspaceProfileToTeam = async (templateName: string) => {
|
||||
if (shareWorkspaceProfileTeamBusy()) return;
|
||||
setShareWorkspaceProfileTeamBusy(true);
|
||||
setShareWorkspaceProfileTeamError(null);
|
||||
setShareWorkspaceProfileTeamSuccess(null);
|
||||
|
||||
try {
|
||||
const { client, workspaceId, workspace } = await resolveShareExportContext();
|
||||
const { created, orgName } = await saveWorkspaceProfileBundleToTeam({
|
||||
client,
|
||||
workspaceId,
|
||||
workspaceName: options.workspaceLabel(workspace),
|
||||
requestedName: templateName,
|
||||
sensitiveMode: shareWorkspaceProfileSensitiveMode(),
|
||||
});
|
||||
|
||||
setShareWorkspaceProfileTeamSuccess(
|
||||
`Saved ${created.name} to ${orgName || "your team templates"}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const warnings = readWorkspaceExportWarnings(error);
|
||||
if (warnings) {
|
||||
setShareWorkspaceProfileSensitiveWarnings(warnings);
|
||||
setShareWorkspaceProfileTeamError(null);
|
||||
return;
|
||||
}
|
||||
setShareWorkspaceProfileTeamError(
|
||||
error instanceof Error ? error.message : "Failed to save team template",
|
||||
);
|
||||
} finally {
|
||||
setShareWorkspaceProfileTeamBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publishSkillsSetLink = async () => {
|
||||
if (shareSkillsSetBusy()) return;
|
||||
setShareSkillsSetBusy(true);
|
||||
setShareSkillsSetError(null);
|
||||
setShareSkillsSetUrl(null);
|
||||
|
||||
try {
|
||||
const { client, workspaceId, workspace } = await resolveShareExportContext();
|
||||
const result = await publishSkillsSetBundleFromWorkspace({
|
||||
client,
|
||||
workspaceId,
|
||||
workspaceName: options.workspaceLabel(workspace),
|
||||
});
|
||||
|
||||
setShareSkillsSetUrl(result.url);
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.url);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch (error) {
|
||||
setShareSkillsSetError(
|
||||
error instanceof Error ? error.message : "Failed to publish skills set",
|
||||
);
|
||||
} finally {
|
||||
setShareSkillsSetBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportDisabledReason = createMemo(() => {
|
||||
const workspace = shareWorkspace();
|
||||
if (!workspace) return "Export is available for local workers in the desktop app.";
|
||||
if (workspace.workspaceType === "remote") {
|
||||
return "Export is only supported for local workers.";
|
||||
}
|
||||
if (!isTauriRuntime()) return "Export is available in the desktop app.";
|
||||
if (options.exportWorkspaceBusy()) return "Export is already running.";
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
shareWorkspaceId,
|
||||
shareWorkspaceOpen: createMemo(() => Boolean(shareWorkspaceId())),
|
||||
openShareWorkspace,
|
||||
closeShareWorkspace,
|
||||
shareWorkspace,
|
||||
shareWorkspaceName,
|
||||
shareWorkspaceDetail,
|
||||
shareFields,
|
||||
shareNote,
|
||||
shareServiceDisabledReason,
|
||||
shareWorkspaceProfileBusy,
|
||||
shareWorkspaceProfileUrl,
|
||||
shareWorkspaceProfileError,
|
||||
shareWorkspaceProfileSensitiveWarnings,
|
||||
shareWorkspaceProfileSensitiveMode,
|
||||
setShareWorkspaceProfileSensitiveMode,
|
||||
publishWorkspaceProfileLink,
|
||||
shareWorkspaceProfileTeamBusy,
|
||||
shareWorkspaceProfileTeamError,
|
||||
shareWorkspaceProfileTeamSuccess,
|
||||
shareWorkspaceProfileTeamOrgName,
|
||||
shareWorkspaceProfileToTeamNeedsSignIn,
|
||||
shareWorkspaceProfileTeamDisabledReason,
|
||||
shareWorkspaceProfileToTeam,
|
||||
startShareWorkspaceProfileToTeamSignIn,
|
||||
shareSkillsSetBusy,
|
||||
shareSkillsSetUrl,
|
||||
shareSkillsSetError,
|
||||
publishSkillsSetLink,
|
||||
exportDisabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
function readWorkspaceExportWarnings(error: unknown): OpenworkWorkspaceExportWarning[] | null {
|
||||
if (!(error instanceof OpenworkServerError) || error.code !== "workspace_export_requires_decision") {
|
||||
return null;
|
||||
}
|
||||
const warnings = Array.isArray((error.details as { warnings?: unknown } | undefined)?.warnings)
|
||||
? (error.details as { warnings: unknown[] }).warnings
|
||||
: [];
|
||||
const normalized = warnings
|
||||
.map((warning) => {
|
||||
if (!warning || typeof warning !== "object") return null;
|
||||
const record = warning as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.trim() : "";
|
||||
const label = typeof record.label === "string" ? record.label.trim() : "";
|
||||
const detail = typeof record.detail === "string" ? record.detail.trim() : "";
|
||||
if (!id || !label || !detail) return null;
|
||||
return { id, label, detail } satisfies OpenworkWorkspaceExportWarning;
|
||||
})
|
||||
.filter((warning): warning is OpenworkWorkspaceExportWarning => Boolean(warning));
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
25
apps/app/src/app/shell/boot-shell.tsx
Normal file
25
apps/app/src/app/shell/boot-shell.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createWorkspaceShellLayout } from "../lib/workspace-shell-layout";
|
||||
|
||||
export default function BootShell() {
|
||||
const { leftSidebarWidth } = createWorkspaceShellLayout({ expandedRightWidth: 280 });
|
||||
|
||||
return (
|
||||
<div class="h-[100dvh] min-h-screen w-full overflow-hidden bg-[var(--dls-app-bg)] p-3 md:p-4 text-dls-text font-sans">
|
||||
<div class="flex h-full w-full gap-3 md:gap-4">
|
||||
<aside
|
||||
class="relative hidden lg:flex shrink-0 flex-col overflow-hidden rounded-[24px] border border-dls-border bg-dls-sidebar p-2.5"
|
||||
style={{
|
||||
width: `${leftSidebarWidth()}px`,
|
||||
"min-width": `${leftSidebarWidth()}px`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="min-w-0 flex-1 overflow-hidden rounded-[24px] border border-dls-border bg-dls-surface shadow-[var(--dls-shell-shadow)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/app/src/app/shell/deep-links.ts
Normal file
250
apps/app/src/app/shell/deep-links.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js";
|
||||
|
||||
import { createDenClient, writeDenSettings } from "../lib/den";
|
||||
import { stripBundleQuery } from "../bundles";
|
||||
import type { createBundlesStore } from "../bundles/store";
|
||||
import type { SettingsTab, View } from "../types";
|
||||
import type { WorkspaceStore } from "../context/workspace";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
import {
|
||||
parseDebugDeepLinkInput,
|
||||
parseDenAuthDeepLink,
|
||||
parseRemoteConnectDeepLink,
|
||||
stripRemoteConnectQuery,
|
||||
type DenAuthDeepLink,
|
||||
type RemoteWorkspaceDefaults,
|
||||
} from "../lib/openwork-links";
|
||||
|
||||
export type DeepLinksController = ReturnType<typeof createDeepLinksController>;
|
||||
|
||||
export function createDeepLinksController(options: {
|
||||
booting: Accessor<boolean>;
|
||||
setError: (value: string | null) => void;
|
||||
setView: (next: View, sessionId?: string) => void;
|
||||
setSettingsTab: (value: SettingsTab) => void;
|
||||
goToSettings: (value: SettingsTab) => void;
|
||||
workspaceStore: WorkspaceStore;
|
||||
bundlesStore: ReturnType<typeof createBundlesStore>;
|
||||
}) {
|
||||
const [deepLinkRemoteWorkspaceDefaults, setDeepLinkRemoteWorkspaceDefaults] =
|
||||
createSignal<RemoteWorkspaceDefaults | null>(null);
|
||||
const [pendingRemoteConnectDeepLink, setPendingRemoteConnectDeepLink] =
|
||||
createSignal<RemoteWorkspaceDefaults | null>(null);
|
||||
const [pendingDenAuthDeepLink, setPendingDenAuthDeepLink] = createSignal<DenAuthDeepLink | null>(null);
|
||||
const [processingDenAuthDeepLink, setProcessingDenAuthDeepLink] = createSignal(false);
|
||||
const recentClaimedDeepLinks = new Map<string, number>();
|
||||
|
||||
const queueRemoteConnectDefaults = (pending: RemoteWorkspaceDefaults | null) => {
|
||||
setPendingRemoteConnectDeepLink(pending);
|
||||
};
|
||||
|
||||
const clearDeepLinkRemoteWorkspaceDefaults = () => {
|
||||
setDeepLinkRemoteWorkspaceDefaults(null);
|
||||
};
|
||||
|
||||
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);
|
||||
options.workspaceStore.setCreateRemoteWorkspaceOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
options.setError(null);
|
||||
try {
|
||||
const ok = await options.workspaceStore.createRemoteWorkspaceFlow(input);
|
||||
if (ok) {
|
||||
setDeepLinkRemoteWorkspaceDefaults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkRemoteWorkspaceDefaults(input);
|
||||
options.workspaceStore.setCreateRemoteWorkspaceOpen(true);
|
||||
} finally {
|
||||
// no-op overlay placeholder removed; shell has no consumer
|
||||
}
|
||||
};
|
||||
|
||||
const queueDenAuthDeepLink = (rawUrl: string): boolean => {
|
||||
const parsed = parseDenAuthDeepLink(rawUrl);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
setPendingDenAuthDeepLink(parsed);
|
||||
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 = stripBundleQuery(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 && options.bundlesStore.queueBundleLink(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." };
|
||||
}
|
||||
|
||||
options.setError(null);
|
||||
options.setView("settings");
|
||||
if (parsed.kind === "bundle") {
|
||||
return options.bundlesStore.openDebugBundleRequest(parsed.link);
|
||||
}
|
||||
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);
|
||||
options.setSettingsTab("automations");
|
||||
return { ok: true, message: "Queued remote worker link. OpenWork should move into the connect flow." };
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingDenAuthDeepLink();
|
||||
if (!pending || options.booting() || processingDenAuthDeepLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingDenAuthDeepLink(true);
|
||||
setPendingDenAuthDeepLink(null);
|
||||
options.setView("settings");
|
||||
options.setSettingsTab("den");
|
||||
options.goToSettings("den");
|
||||
|
||||
void createDenClient({ baseUrl: pending.denBaseUrl })
|
||||
.exchangeDesktopHandoff(pending.grant)
|
||||
.then((result) => {
|
||||
if (!result.token) {
|
||||
throw new Error("Desktop sign-in completed, but OpenWork Cloud did not return a session token.");
|
||||
}
|
||||
|
||||
writeDenSettings({
|
||||
baseUrl: pending.denBaseUrl,
|
||||
authToken: result.token,
|
||||
activeOrgId: null,
|
||||
activeOrgSlug: null,
|
||||
activeOrgName: 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 Cloud sign-in.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setProcessingDenAuthDeepLink(false);
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingRemoteConnectDeepLink();
|
||||
if (!pending || options.booting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.autoConnect) {
|
||||
options.setView("session");
|
||||
} else {
|
||||
options.setView("settings");
|
||||
options.setSettingsTab("automations");
|
||||
}
|
||||
setPendingRemoteConnectDeepLink(null);
|
||||
void completeRemoteConnectDeepLink(pending);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (options.workspaceStore.createRemoteWorkspaceOpen()) {
|
||||
return;
|
||||
}
|
||||
if (!deepLinkRemoteWorkspaceDefaults()) {
|
||||
return;
|
||||
}
|
||||
setDeepLinkRemoteWorkspaceDefaults(null);
|
||||
});
|
||||
|
||||
return {
|
||||
deepLinkRemoteWorkspaceDefaults,
|
||||
clearDeepLinkRemoteWorkspaceDefaults,
|
||||
queueRemoteConnectDefaults,
|
||||
consumeDeepLinks,
|
||||
openDebugDeepLink,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
127
apps/app/src/app/shell/status-toasts.tsx
Normal file
127
apps/app/src/app/shell/status-toasts.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { For, createContext, createSignal, onCleanup, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import StatusToast from "../components/status-toast";
|
||||
|
||||
export type AppStatusToastTone = "success" | "info" | "warning" | "error";
|
||||
|
||||
export type AppStatusToastInput = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
tone?: AppStatusToastTone;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
dismissLabel?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export type AppStatusToast = AppStatusToastInput & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type StatusToastsStore = ReturnType<typeof createStatusToastsStore>;
|
||||
|
||||
const StatusToastsContext = createContext<StatusToastsStore>();
|
||||
|
||||
const defaultDurationForTone = (tone: AppStatusToastTone) => {
|
||||
if (tone === "warning" || tone === "error") return 4200;
|
||||
return 3200;
|
||||
};
|
||||
|
||||
export function createStatusToastsStore() {
|
||||
const [toasts, setToasts] = createSignal<AppStatusToast[]>([]);
|
||||
const timers = new Map<string, number>();
|
||||
let counter = 0;
|
||||
|
||||
const dismissToast = (id: string) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
const showToast = (input: AppStatusToastInput) => {
|
||||
const id = `status-toast-${Date.now()}-${counter++}`;
|
||||
const tone = input.tone ?? "info";
|
||||
const toast: AppStatusToast = {
|
||||
...input,
|
||||
tone,
|
||||
id,
|
||||
};
|
||||
|
||||
setToasts((current) => [...current, toast].slice(-4));
|
||||
|
||||
const duration = input.durationMs ?? defaultDurationForTone(tone);
|
||||
if (duration > 0) {
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(id);
|
||||
setToasts((current) => current.filter((item) => item.id !== id));
|
||||
}, duration);
|
||||
timers.set(id, timer);
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const clearToasts = () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
timers.clear();
|
||||
setToasts([]);
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
timers.clear();
|
||||
});
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
dismissToast,
|
||||
clearToasts,
|
||||
};
|
||||
}
|
||||
|
||||
export function StatusToastsProvider(props: ParentProps<{ store: StatusToastsStore }>) {
|
||||
return (
|
||||
<StatusToastsContext.Provider value={props.store}>
|
||||
{props.children}
|
||||
</StatusToastsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStatusToasts() {
|
||||
const context = useContext(StatusToastsContext);
|
||||
if (!context) {
|
||||
throw new Error("useStatusToasts must be used within a StatusToastsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function StatusToastsViewport() {
|
||||
const statusToasts = useStatusToasts();
|
||||
|
||||
return (
|
||||
<For each={statusToasts.toasts()}>
|
||||
{(toast) => (
|
||||
<div class="pointer-events-auto">
|
||||
<StatusToast
|
||||
open
|
||||
tone={toast.tone}
|
||||
title={toast.title}
|
||||
description={toast.description ?? null}
|
||||
actionLabel={toast.actionLabel}
|
||||
onAction={toast.onAction}
|
||||
dismissLabel={toast.dismissLabel ?? "Dismiss"}
|
||||
onDismiss={() => statusToasts.dismissToast(toast.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
44
apps/app/src/app/shell/top-right-notifications.tsx
Normal file
44
apps/app/src/app/shell/top-right-notifications.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import ReloadWorkspaceToast from "../components/reload-workspace-toast";
|
||||
import { StatusToastsViewport } from "./status-toasts";
|
||||
|
||||
import type { ReloadTrigger } from "../types";
|
||||
|
||||
export type TopRightNotificationsProps = {
|
||||
reloadOpen: boolean;
|
||||
reloadTitle: string;
|
||||
reloadDescription: string;
|
||||
reloadTrigger?: ReloadTrigger | null;
|
||||
reloadError?: string | null;
|
||||
reloadLabel: string;
|
||||
dismissLabel: string;
|
||||
reloadBusy?: boolean;
|
||||
canReload: boolean;
|
||||
hasActiveRuns: boolean;
|
||||
onReload: () => void;
|
||||
onDismissReload: () => void;
|
||||
};
|
||||
|
||||
export default function TopRightNotifications(props: TopRightNotificationsProps) {
|
||||
return (
|
||||
<div class="pointer-events-none fixed right-4 top-4 z-50 flex w-[min(24rem,calc(100vw-1.5rem))] max-w-full flex-col gap-3 sm:right-6 sm:top-6">
|
||||
<div class="pointer-events-auto">
|
||||
<ReloadWorkspaceToast
|
||||
open={props.reloadOpen}
|
||||
title={props.reloadTitle}
|
||||
description={props.reloadDescription}
|
||||
trigger={props.reloadTrigger}
|
||||
error={props.reloadError}
|
||||
reloadLabel={props.reloadLabel}
|
||||
dismissLabel={props.dismissLabel}
|
||||
busy={props.reloadBusy}
|
||||
canReload={props.canReload}
|
||||
hasActiveRuns={props.hasActiveRuns}
|
||||
onReload={props.onReload}
|
||||
onDismiss={props.onDismissReload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatusToastsViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import type {
|
||||
UpdateHandle,
|
||||
} from "./types";
|
||||
import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils";
|
||||
import { mapConfigProvidersToList } from "./utils/providers";
|
||||
import { createUpdaterState } from "./context/updater";
|
||||
import { filterProviderList, mapConfigProvidersToList } from "./utils/providers";
|
||||
import { createUpdaterState, type UpdateStatus } from "./context/updater";
|
||||
import {
|
||||
resetOpenworkState,
|
||||
resetOpencodeCache,
|
||||
@@ -49,14 +49,20 @@ function throttle<T extends (...args: any[]) => any>(
|
||||
}
|
||||
}
|
||||
|
||||
export type NotionState = {
|
||||
status: Accessor<"disconnected" | "connecting" | "connected" | "error">;
|
||||
setStatus: (value: "disconnected" | "connecting" | "connected" | "error") => void;
|
||||
statusDetail: Accessor<string | null>;
|
||||
setStatusDetail: (value: string | null) => void;
|
||||
skillInstalled: Accessor<boolean>;
|
||||
setTryPromptVisible: (value: boolean) => void;
|
||||
};
|
||||
function forcedDevUpdateStatus(): UpdateStatus | null {
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
const forcedState = String(import.meta.env.VITE_FORCE_UPDATE_STATUS ?? "").trim().toLowerCase();
|
||||
if (forcedState !== "available") return null;
|
||||
|
||||
const version = String(import.meta.env.VITE_FORCE_UPDATE_VERSION ?? "0.11.999").trim() || "0.11.999";
|
||||
return {
|
||||
state: "available",
|
||||
lastCheckedAt: Date.now(),
|
||||
version,
|
||||
notes: "Dev-only forced update state",
|
||||
};
|
||||
}
|
||||
|
||||
export function createSystemState(options: {
|
||||
client: Accessor<Client | null>;
|
||||
@@ -71,9 +77,11 @@ export function createSystemState(options: {
|
||||
setProviderDefaults: (value: Record<string, string>) => void;
|
||||
setProviderConnectedIds: (value: string[]) => void;
|
||||
setError: (value: string | null) => void;
|
||||
notion?: NotionState;
|
||||
}) {
|
||||
const [reloadRequired, setReloadRequired] = createSignal(false);
|
||||
const isActiveSessionStatus = (status: string | null | undefined) =>
|
||||
status === "running" || status === "retry";
|
||||
|
||||
const [reloadPending, setReloadPending] = createSignal(false);
|
||||
const [reloadReasons, setReloadReasons] = createSignal<ReloadReason[]>([]);
|
||||
const [reloadLastTriggeredAt, setReloadLastTriggeredAt] = createSignal<number | null>(null);
|
||||
const [reloadLastFinishedAt, setReloadLastFinishedAt] = createSignal<number | null>(null);
|
||||
@@ -109,7 +117,7 @@ export function createSystemState(options: {
|
||||
|
||||
const anyActiveRuns = createMemo(() => {
|
||||
const statuses = options.sessionStatusById();
|
||||
return options.sessions().some((s) => statuses[s.id] === "running");
|
||||
return options.sessions().some((s) => isActiveSessionStatus(statuses[s.id]));
|
||||
});
|
||||
|
||||
function clearOpenworkLocalStorage(mode: ResetOpenworkMode) {
|
||||
@@ -179,7 +187,7 @@ export function createSystemState(options: {
|
||||
}
|
||||
|
||||
function markReloadRequired(reason: ReloadReason, trigger?: ReloadTrigger) {
|
||||
setReloadRequired(true);
|
||||
setReloadPending(true);
|
||||
setReloadLastTriggeredAt(Date.now());
|
||||
setReloadReasons((current) => (current.includes(reason) ? current : [...current, reason]));
|
||||
if (trigger) {
|
||||
@@ -201,7 +209,7 @@ export function createSystemState(options: {
|
||||
}
|
||||
|
||||
function clearReloadRequired() {
|
||||
setReloadRequired(false);
|
||||
setReloadPending(false);
|
||||
setReloadReasons([]);
|
||||
setReloadError(null);
|
||||
setReloadTrigger(null);
|
||||
@@ -265,7 +273,7 @@ export function createSystemState(options: {
|
||||
});
|
||||
|
||||
const canReloadEngine = createMemo(() => {
|
||||
if (!reloadRequired()) return false;
|
||||
if (!reloadPending()) return false;
|
||||
if (reloadBusy()) return false;
|
||||
const override = options.canReloadWorkspaceEngine?.();
|
||||
if (override === true) return true;
|
||||
@@ -276,7 +284,7 @@ export function createSystemState(options: {
|
||||
|
||||
// Keep this mounted so the reload banner UX remains in the app.
|
||||
createEffect(() => {
|
||||
reloadRequired();
|
||||
reloadPending();
|
||||
});
|
||||
|
||||
async function reloadEngineInstance() {
|
||||
@@ -314,18 +322,37 @@ export function createSystemState(options: {
|
||||
}
|
||||
|
||||
await waitForHealthy(nextClient, { timeoutMs: 12_000 });
|
||||
let disabledProviders: string[] = [];
|
||||
try {
|
||||
const config = unwrap(await nextClient.config.get()) as {
|
||||
disabled_providers?: string[];
|
||||
};
|
||||
disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : [];
|
||||
} catch {
|
||||
// ignore config read failures and continue with provider discovery
|
||||
}
|
||||
|
||||
try {
|
||||
const providerList = unwrap(await nextClient.provider.list());
|
||||
const providerList = filterProviderList(
|
||||
unwrap(await nextClient.provider.list()),
|
||||
disabledProviders,
|
||||
);
|
||||
options.setProviders(providerList.all);
|
||||
options.setProviderDefaults(providerList.default);
|
||||
options.setProviderConnectedIds(providerList.connected);
|
||||
} catch {
|
||||
try {
|
||||
const cfg = unwrap(await nextClient.config.providers());
|
||||
options.setProviders(mapConfigProvidersToList(cfg.providers));
|
||||
options.setProviderDefaults(cfg.default);
|
||||
options.setProviderConnectedIds([]);
|
||||
const cfg = unwrap(await nextClient.config.providers()) as {
|
||||
providers: Parameters<typeof mapConfigProvidersToList>[0];
|
||||
default: Record<string, string>;
|
||||
};
|
||||
const providerList = filterProviderList(
|
||||
{ all: mapConfigProvidersToList(cfg.providers), default: cfg.default, connected: [] },
|
||||
disabledProviders,
|
||||
);
|
||||
options.setProviders(providerList.all);
|
||||
options.setProviderDefaults(providerList.default);
|
||||
options.setProviderConnectedIds(providerList.connected);
|
||||
} catch {
|
||||
options.setProviders([]);
|
||||
options.setProviderDefaults({});
|
||||
@@ -337,40 +364,7 @@ export function createSystemState(options: {
|
||||
await options.refreshSkills({ force: true }).catch(() => undefined);
|
||||
await options.refreshMcpServers?.().catch(() => undefined);
|
||||
|
||||
if (options.notion) {
|
||||
let nextStatus = options.notion.status();
|
||||
if (nextStatus === "connecting") {
|
||||
nextStatus = "connected";
|
||||
options.notion.setStatus(nextStatus);
|
||||
options.notion.setStatusDetail("Worker connected");
|
||||
}
|
||||
|
||||
if (nextStatus === "connected") {
|
||||
const detail = options.notion.statusDetail();
|
||||
if (!detail || detail.toLowerCase().includes("reload")) {
|
||||
options.notion.setStatusDetail("Worker connected");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem("openwork.notionStatus", nextStatus);
|
||||
if (nextStatus === "connected") {
|
||||
const detail = options.notion.statusDetail();
|
||||
if (detail) {
|
||||
window.localStorage.setItem("openwork.notionStatusDetail", detail);
|
||||
} else {
|
||||
window.localStorage.removeItem("openwork.notionStatusDetail");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
clearReloadRequired();
|
||||
if (options.notion && options.notion.status() === "connected" && options.notion.skillInstalled()) {
|
||||
options.notion.setTryPromptVisible(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setReloadError(e instanceof Error ? e.message : safeStringify(e));
|
||||
} finally {
|
||||
@@ -453,6 +447,13 @@ export function createSystemState(options: {
|
||||
async function checkForUpdates(optionsCheck?: { quiet?: boolean }) {
|
||||
if (!isTauriRuntime()) return;
|
||||
|
||||
const forcedStatus = forcedDevUpdateStatus();
|
||||
if (forcedStatus) {
|
||||
setPendingUpdate(null);
|
||||
setUpdateStatus(forcedStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
const env = updateEnv();
|
||||
if (env && !env.supported) {
|
||||
if (!optionsCheck?.quiet) {
|
||||
@@ -593,7 +594,7 @@ export function createSystemState(options: {
|
||||
}
|
||||
|
||||
return {
|
||||
reloadRequired,
|
||||
reloadPending,
|
||||
reloadReasons,
|
||||
reloadLastTriggeredAt,
|
||||
reloadLastFinishedAt,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user