mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
Task/react port cutover react only workspace fixes (#1470)
* scaffold(react): add parallel React entry and app-root shell
Introduces apps/app/src/index.react.tsx and apps/app/src/react-app/shell/app-root.tsx as the base for a component-by-component migration, while keeping the Solid runtime as the shipped default. Updates vite.config so files inside src/react-app/ and the new React entry are routed through the React plugin rather than Solid.
Made-with: Cursor
* feat(react-app/kernel): port platform abstraction to React
Adds react-app/kernel/platform.tsx with a React context equivalent of the Solid PlatformProvider and a createDefaultPlatform() helper that mirrors the Tauri-vs-web bootstrap logic from src/index.tsx. Wires it into the new React entry so downstream hooks can depend on usePlatform() during the migration.
Made-with: Cursor
* feat(react-app/infra): port React Query client singleton
Copies the shared TanStack Query client helper into react-app/infra/query-client.ts and wires QueryClientProvider into the new React entry so future domain modules can share one Query cache.
Made-with: Cursor
* feat(react-app/kernel): add minimal Zustand store for React migration
Introduces react-app/kernel/store.ts with a small OpenworkStore covering bootstrap, server, workspaces, selected session, and an error banner. This gives downstream domain modules a single place to read and mutate React-owned app state as the migration proceeds; it will grow as later phases port more behavior.
Made-with: Cursor
* feat(react-app/kernel): add selectors module
Adds react-app/kernel/selectors.ts with a handful of small selectors (active workspace, server status, server URL, error banner) so domain components can consume store state through named selectors instead of reaching into the store shape directly.
Made-with: Cursor
* feat(react-app/kernel): port ServerProvider to React
Adds react-app/kernel/server-provider.tsx with a React context equivalent of context/server.tsx: persisted server list, active URL, health polling, and add/remove/setActive helpers. Health checks reuse the Tauri or fetch transport based on runtime, and hosted web deployments skip any persisted localhost target.
Made-with: Cursor
* feat(react-app/kernel): port GlobalSDKProvider to React
Adds react-app/kernel/global-sdk-provider.tsx mirroring context/global-sdk.tsx: builds an OpenCode client keyed to the active server URL, subscribes to the SSE event stream when healthy, and fans events into a lightweight channel emitter. Replaces the Solid @solid-primitives/event-bus with a small React-friendly emitter so downstream React domains can listen without pulling in solid-js runtime.
Made-with: Cursor
* feat(react-app/kernel): port GlobalSyncProvider to React
Adds react-app/kernel/global-sync-provider.tsx with a React context equivalent of context/global-sync.tsx. Uses a single useState-backed GlobalState plus a workspaces map for per-directory slices, and re-implements the refresh/refreshDirectory helpers and the directory-scoped event subscriptions on top of the new GlobalEventEmitter from global-sdk-provider.
Made-with: Cursor
* feat(react-app/kernel): port LocalProvider to React
Adds react-app/kernel/local-provider.tsx as a React-side persisted UI state + preferences provider equivalent to context/local.tsx. Uses plain useState with localStorage serialisation and migrates the legacy standalone thinking preference into the unified preferences shape.
Made-with: Cursor
* feat(react-app/shell): compose full provider stack in React entry
Adds react-app/shell/providers.tsx that wraps ServerProvider, GlobalSDKProvider, GlobalSyncProvider, and LocalProvider in the same order as the Solid entry, and mounts the default server URL resolution logic from the original src/index.tsx. Wires the new AppProviders into the React entry so the migration now runs behind the full kernel provider stack.
Made-with: Cursor
* feat(react-app/shell): port startup deep-link bridge
Adds react-app/shell/startup-deep-links.ts that mirrors the Tauri and web deep-link startup behaviour from src/index.tsx, and calls it from the React entry alongside the existing deployment dataset hook.
Made-with: Cursor
* feat(react-app/shell): mount HashRouter in Tauri and BrowserRouter on web
Pulls react-router-dom into apps/app and selects HashRouter for Tauri builds (matching the Solid shell behaviour) and BrowserRouter elsewhere. Wraps AppRoot in the chosen router so downstream pages can use route primitives from day one of the React migration.
Made-with: Cursor
* feat(react-app/kernel): port reload + reset system state to React hook
Adds react-app/kernel/system-state.ts with a useSystemState() hook that covers the reload-pending state (reasons, trigger, mark/clear helpers) and the reset modal state (open/close, confirm flow that clears localStorage and relaunches). Cache repair, Docker cleanup, and the Tauri updater surface stay in the original Solid factory for now and will be ported as the settings shell migrates.
Made-with: Cursor
* feat(react-app/kernel): port core model-config helpers and default model hook
Adds react-app/kernel/model-config.ts with the framework-agnostic parse/serialize helpers for session-level and workspace-level model choices, plus a lightweight useDefaultModel() hook that reads and persists the default model ref to localStorage. The richer workspace-scoped overrides, auto-compact context, and model picker orchestration stay in the Solid factory for now and will be ported when the session + settings surfaces need them.
Made-with: Cursor
* feat(react-app/session): port ModelPickerModal to React
Adds react-app/domains/session/modals/model-picker-modal.tsx as a faithful 1:1 React port of src/app/components/model-picker-modal.tsx. Preserves the recommended / connected / more-providers sections, search, keyboard navigation (arrow keys, enter, escape), behaviour chip row on the active option, and the empty/"no results" state. Uses an inline ProviderIcon placeholder until the full Solid ProviderIcon is ported. Installs lucide-react so the React tree can consume the same icon set.
Made-with: Cursor
* refactor(react-app/session): move session-sync + usechat-adapter into domains/session/sync
Relocates the session sync store and the AI SDK chat transport adapter into react-app/domains/session/sync/, with re-export shims left at the old react/session/ paths for Solid consumers still importing from there. These two files compile as a pair so they are moved together; the shims will disappear once the session route is mounted from react-app.
Made-with: Cursor
* refactor(react-app/session): move transition-controller into domains/session/sync
Pure transition/render-model helper moves from src/react/session/ into react-app/domains/session/sync/. Leaves a re-export shim at the old path so session-surface.react.tsx and debug-panel.react.tsx can keep their imports until they migrate too.
Made-with: Cursor
* refactor(react-app/session): move runtime-sync into domains/session/sync
React effect component that keeps the workspace session sync loop alive now lives under react-app/domains/session/sync/runtime-sync.tsx. Existing callers import through the shim at src/react/session/runtime-sync.react.tsx until Solid mounts move off it.
Made-with: Cursor
* refactor(react-app/session): move markdown block into domains/session/surface
Relocates the markdown/streamdown renderer to react-app/domains/session/surface/markdown.tsx with a re-export shim at the old react/session/ path.
Made-with: Cursor
* refactor(react-app/session): move tool-call view into domains/session/surface
Ports the ToolCallView component file into react-app/domains/session/surface/tool-call.tsx and leaves a shim at src/react/session/tool-call.react.tsx for the Solid transcript to keep importing from.
Made-with: Cursor
* refactor(react-app/session): move message-list into domains/session/surface
Relocates the SessionTranscript renderer to react-app/domains/session/surface/message-list.tsx and retargets its local imports to the already-moved markdown and tool-call modules. Retains a shim at src/react/session/message-list.react.tsx for Solid session-surface.
Made-with: Cursor
* refactor(react-app/session): move debug-panel into domains/session/surface
Session debug overlay moves to react-app/domains/session/surface/debug-panel.tsx with imports retargeted to the already-moved transition-controller and the shared openwork-server types. Leaves a shim at src/react/session/debug-panel.react.tsx for session-surface.
Made-with: Cursor
* refactor(react-app/session): move composer notice into domains/session/surface/composer
Made-with: Cursor
* refactor(react-app/session): move Lexical prompt editor into domains/session/surface/composer
Made-with: Cursor
* refactor(react-app/session): move ReactSessionComposer into domains/session/surface/composer
Made-with: Cursor
* refactor(react-app/session): move SessionSurface into domains/session/surface
Core React session surface (transcript + composer + sync bridge) moves to react-app/domains/session/surface/session-surface.tsx. Imports update to point into the relocated surface, sync, and composer modules, plus the shared infra query client. Shim at src/react/session/session-surface.react.tsx keeps Solid pages/app.tsx compiling against the React island pattern.
Made-with: Cursor
* feat(react-app/workspace): re-export shared modal style tokens
Creates react-app/domains/workspace/modal-styles.ts as a re-export of the framework-agnostic class-name constants in src/app/workspace/modal-styles.ts, so React-side workspace modals consume a domain-scoped import path while the Solid tree keeps using the original file.
Made-with: Cursor
* feat(react-app/workspace): re-export workspace types
Creates react-app/domains/workspace/types.ts as a re-export so React-side modal flows pull workspace types through the domain path.
Made-with: Cursor
* feat(react-app/workspace): port WorkspaceOptionCard to React
Adds react-app/domains/workspace/option-card.tsx as a 1:1 React port of app/workspace/option-card.tsx, using lucide-react's ChevronRight and the shared modal style tokens. Accepts an icon as a React ComponentType so callers can pass any lucide-react icon directly.
Made-with: Cursor
* feat(react-app/workspace): port RemoteWorkspaceFields to React
Ports the reusable remote-worker form (URL, token show/hide, optional directory, display name) into react-app/domains/workspace/remote-workspace-fields.tsx, keeping the Solid "Show" conditional as a plain conditional render and reusing the shared modal style tokens.
Made-with: Cursor
* feat(react-app/workspace): port CreateWorkspaceLocalPanel to React
Faithful 1:1 React port of the Solid local-create panel: folder picker, team template grid, sandbox progress stepper with logs, worker-setup warning banner, and confirm/cancel footer. Uses lucide-react icons and the shared modal style tokens.
Made-with: Cursor
* feat(react-app/workspace): port CreateWorkspaceSharedPanel to React
Ports the signed-in vs signed-out cloud workers panel, org selector, search input, worker cards with status badges, and "open cloud dashboard" footer into react-app/domains/workspace/create-workspace-shared-panel.tsx. Uses lucide-react icons and keeps behaviour parity with the Solid source.
Made-with: Cursor
* feat(react-app/workspace): port CreateRemoteWorkspaceModal to React
Ports the standalone remote-connect modal (host URL, token show/hide, optional directory/display name, cancel/confirm footer, optional inline render) to react-app/domains/workspace/create-remote-workspace-modal.tsx using the already-ported RemoteWorkspaceFields and the shared modal style tokens.
Made-with: Cursor
* feat(react-app/workspace): port CreateWorkspaceModal orchestrator to React
Ports the full create-workspace modal state machine to react-app/domains/workspace/create-workspace-modal.tsx. Covers the chooser/local/remote/shared screens, Den cloud org + worker list with live search + tokens handoff, template cache bootstrap, progress elapsed tracker, and focus/escape lifecycle. Reuses the already-ported local panel, shared panel, remote fields, and option card; depends on the React PlatformProvider for desktop-vs-web deep-link behaviour.
Made-with: Cursor
* feat(react-app/workspace): port ShareWorkspaceAccessPanel to React
Ports the access panel for the share workspace modal into react-app/domains/workspace/share-workspace-access-panel.tsx. Preserves the remote-access toggle with pending/save state, credential fields with reveal + copy, optional collaborator expansion, messaging setup card, warning banner, and note line. Uses lucide-react icons and the shared modal style tokens.
Made-with: Cursor
* feat(react-app/workspace): port ShareWorkspaceTemplatePanel to React
Ports the full template-sharing flow (chooser / public link / team save) into react-app/domains/workspace/share-workspace-template-panel.tsx. Preserves the sensitive-config warning with include/exclude toggles, generated-link copy/regenerate UX, sign-in-required fallback for the team save, and the 'Included in this template' summary card.
Made-with: Cursor
* feat(react-app/workspace): port ShareWorkspaceModal orchestrator to React
Ports the modal chrome + chooser + view switcher for the share workspace flow into react-app/domains/workspace/share-workspace-modal.tsx. Handles the chooser/template/template-public/template-team/access screens, escape-key back navigation, clipboard copy with a 2s confirmation, and syncs remoteAccess toggle state with the parent across opens.
Made-with: Cursor
* feat(react-app/settings): port app-settings state modules to React
Adds React equivalents of the app-settings state surface under react-app/domains/settings/state/:
- model-controls-store.ts: plain ModelControlsStore shape (no Solid accessors)
- model-controls-provider.tsx: React context + useModelControls() hook
- session-display-preferences.ts: show-thinking toggle backed by the kernel LocalProvider
- feature-flags-preferences.ts: microsandbox-create feature flag toggle backed by LocalProvider
Made-with: Cursor
* feat(react-app/shell-feedback): port StatusToast to React
Adds react-app/domains/shell-feedback/status-toast.tsx mirroring the Solid status toast: tone-specific icon tile (success/info/warning/error), title + optional description, optional action + dismiss buttons, and slide-in-from-top entrance animation.
Made-with: Cursor
* feat(react-app/design-system): port ConfirmModal primitive
Adds react-app/design-system/modals/confirm-modal.tsx as a reusable confirm/cancel dialog with warning/danger variants, replacing the Solid Button dependency with inline primary/danger/outline button classes so the React tree can consume this modal without a ported design-system button yet.
Made-with: Cursor
* feat(react-app/workspace): port RenameWorkspaceModal to React
Ports the simple rename-workspace dialog into react-app/domains/workspace/rename-workspace-modal.tsx, focus-on-open behaviour preserved and Enter-to-save keyboard shortcut kept. Uses shared modal-styles pill buttons and input class instead of the Solid Button/TextInput components.
Made-with: Cursor
* feat(react-app/session): port RenameSessionModal to React
Adds react-app/domains/session/modals/rename-session-modal.tsx for the session rename dialog. Parallels the workspace rename modal port: auto-focus on open, enter-to-save, i18n strings, shared modal-styles buttons and input class.
Made-with: Cursor
* feat(react-app/settings): port ResetModal to React
Ports the destructive reset confirmation dialog (typing RESET to enable) into react-app/domains/settings/modals/reset-modal.tsx. Keeps the onboarding-vs-all mode split, active-runs warning, and danger-styled confirm button.
Made-with: Cursor
* feat(react-app/shell-feedback): port ReloadWorkspaceToast to React
Adds react-app/domains/shell-feedback/reload-workspace-toast.tsx with the Solid toast's full copy logic for skill/plugin/mcp/config/agent/command triggers, active-tasks warning, blocked-reason display, and danger-vs-primary reload button depending on active runs.
Made-with: Cursor
* feat(react-app/design-system): port Button and TextInput primitives
Adds react-app/design-system/{button.tsx,text-input.tsx}. Button covers the five variants (primary/secondary/ghost/outline/danger) used across the Solid app. TextInput wraps the standard input with an optional label+hint pair. Both forward refs.
Made-with: Cursor
* feat(react-app/design-system): port FlyoutItem and ProviderIcon primitives
- FlyoutItem: copy-to-toast style animated flyout that flies from origin to target rect.
- ProviderIcon: SVG icons for openai/anthropic/opencode plus initial-letter fallback for other providers.
Made-with: Cursor
* feat(react-app/design-system): port SelectMenu primitive
Adds react-app/design-system/select-menu.tsx as a React port of the Solid select menu. Keeps the trigger + dropdown, chevron rotation, outside-click and Escape closing behaviour, and accessible listbox/options semantics.
Made-with: Cursor
* feat(react-app/design-system): port WebUnavailableSurface
Wraps children with a dismissable web-only banner and an inert overlay when a feature is web-unavailable. Ports the Solid implementation to React without the Solid-only classList attribute.
Made-with: Cursor
* feat(react-app/bundles): re-export framework-agnostic bundle helpers
Adds thin re-export modules under react-app/domains/bundles/ for the Solid-free bundle helpers (types, schema, url-policy, sources, apply, publish, skill-org-publish, index). React consumers will now import bundle behaviour through the react-app domain path while the Solid tree keeps using the original files.
Made-with: Cursor
* feat(react-app/connections): port AddMcpModal to React
Ports the add-MCP dialog with remote/local server type toggle, OAuth opt-in checkbox for remote servers, command split for local servers, validation errors, and remote-workspace disable hint. Reuses the React design-system Button/TextInput primitives.
Made-with: Cursor
* feat(react-app/session): port StatusBar to React
Ports the bottom status bar to react-app/domains/session/chat/status-bar.tsx. The React version receives the MCP-connected count as a prop instead of pulling it from a connections context, since the React connections provider is not ported yet. All other behaviour (ready/limited/disconnected copy, docs/feedback/settings buttons, optional custom status override) matches the Solid source.
Made-with: Cursor
* feat(react-app/session): port QuestionModal to React
Ports the multi-question wizard modal with keyboard navigation (arrow keys + enter), single/multiple/custom answer modes, question counter, and auto-advance on single-option selection. Uses the React design-system Button for the Next/Submit CTA.
Made-with: Cursor
* feat(react-app/connections): port ControlChromeSetupModal to React
Adds react-app/domains/connections/modals/control-chrome-setup-modal.tsx with the three-step setup card, use-existing-profile toggle, contextual hint below the toggle, and connect/save CTA that switches label based on mode and shows a busy spinner.
Made-with: Cursor
* feat(react-app/bundles): port BundleStartModal to React
Adds react-app/domains/bundles/start-modal.tsx for the template start flow: template name + description header, items chip row (with +N more overflow), folder picker card, cancel/create-workspace footer, and Escape-to-close behaviour.
Made-with: Cursor
* feat(react-app/bundles): port BundleImportModal to React
Adds react-app/domains/bundles/import-modal.tsx for the import-bundle flow: header + included items chips, Create-new-worker CTA, expandable existing-worker picker with status badges, disabled-reason hint, current-worker emphasis, and Escape-to-close.
Made-with: Cursor
* feat(react-app/bundles): port SkillDestinationModal to React
Adds react-app/domains/bundles/skill-destination-modal.tsx for the pick-a-workspace-for-this-skill flow. Preserves the skill summary header (with trigger chip), workspace list with colour-coded circles and current/sandbox/remote badges, busy indicator per workspace, optional create-worker / connect-remote CTAs, and sticky footer with the selected-workspace summary + submit button.
Made-with: Cursor
* feat(react-app/shell-feedback): port status-toasts store + TopRightNotifications
Adds StatusToastsProvider + useStatusToasts hook + StatusToastsViewport with auto-dismiss (3.2s info / 4.2s warning+error) and a rolling 4-item cap, plus the top-right notifications column that stacks the reload toast above the status toasts. Mirrors the Solid shell behaviour so downstream React pages can consume the same toast surface.
Made-with: Cursor
* feat(react-app/settings): port PluginsView to React
Ports the plugins settings page into react-app/domains/settings/pages/plugins-view.tsx. Inverts the Solid useExtensions() context dependency: the React PluginsView receives its extensions store as a prop (PluginsExtensionsStore) so the full extensions provider can be ported later without blocking this page. All UI, scope switcher, suggested-plugins grid, guided steps, installed list, and custom add input are preserved.
Made-with: Cursor
* feat(react-app/settings): port ConfigView to React
Ports the advanced-tab config view (OpenWork server URL/token form, test connection, engine reload card, hostInfo tokens card with copy/show for collaborator/owner/host tokens, developer diagnostics bundle JSON) into react-app/domains/settings/pages/config-view.tsx. Source file was already prop-driven so the port is a 1:1 JSX and effects translation.
Made-with: Cursor
* feat(react-app/settings): port ExtensionsView to React
Ports the extensions tab shell (all/mcp/plugins filter pills, counters, mcp/plugins section composition) into react-app/domains/settings/pages/extensions-view.tsx. Inverts the Solid useConnections() dependency: the caller passes mcpConnectedAppsCount and the already-rendered McpView as props, so this page can ship before the connections provider is ported.
Made-with: Cursor
* docs(react-app): add ARCHITECTURE.md describing the domain-based tree
Captures the top-level layout (shell/kernel/infra/design-system/domains),
the domain breakdown (session/workspace/settings/connections/bundles/shell-feedback),
the provider composition flow from index.react.tsx down, where state lives,
the framework-agnostic boundary with app/, the porting pattern (move-and-re-export
+ context-to-props), and the temporary shim layer under src/react/.
Made-with: Cursor
* feat(react-app/session): port draft store to React
* feat(react-app/session): port run state helpers
* feat(react-app/session): port session actions store
* feat(react-app/session): port actions provider
* feat(react-app/session): port transcript scroll controller
* feat(react-app/session): port grouped transcript rendering
* feat(react-app/session): port composer tool affordances
* feat(react-app/session): port workspace session sidebar
* feat(react-app/workspace): port share workspace state
* feat(react-app/settings): port authorized folders panel
* feat(react-app/settings): port settings page chrome
* feat(react-app/settings): port settings shell layout
* feat(react-app/settings): port general settings view
* feat(react-app/settings): port appearance settings view
* feat(react-app/settings): port updates settings view
* feat(react-app/settings): port recovery settings view
* feat(react-app/settings): port den settings panel
* feat(react-app/settings): port den settings view
* feat(react-app/settings): port messaging settings view
* feat(react-app/settings): port MCP settings view
* feat(react-app/connections): port openwork server store
* feat(react-app/connections): port openwork server provider
* feat(react-app/settings): port skills settings view
* feat(react-app/settings): port automations settings view
* feat(react-app/settings): port advanced settings view
* feat(react-app/settings): port debug settings view
* feat(react-app/connections): port connections store
* feat(react-app/connections): port provider auth modal
* feat(react-app/connections): port MCP auth modal
* feat(react-app/connections): port connections modals
* feat(react-app/shell): port font zoom behavior
* feat(react-app/shell): port workspace shell layout
* feat(react-app/session): port session page
* feat(react-app/shell): wire session route in app root
* feat(react-app/shell): add top-level app routes
* feat(react-app/shell): switch app entry to React
* feat(react-app/settings): port extensions store
* feat(react-app/settings): port automations store
* chore(react-app): finalize React-only app cutover
* fix(react-app/shell): hydrate OpenWork settings from env
* fix(react-app/shell): restore full-height app shell
* fix(react-app/routes): restore workspace bootstrap flows
* fix(react-app): streaming, rename/delete session, model picker
* fix(react-app/settings): break infinite loop in model picker + local setUi effects
* docs(evals): capture 9 React session/settings flows for LLM replay
* feat(react-app): restore command palette, workspace options menu, desktop boot, missing i18n
- Add Cmd+K command palette (root + Sessions sub-mode), restoring a flow that was deleted with the Solid tree and never re-ported.
- Add Cmd+N shortcut to create a new session directly (distinct from Cmd+K).
- Wire workspace options menu: Edit name, Reveal in Finder, Share (path to clipboard), Remove workspace.
- Add RenameWorkspaceModal, workspaceUpdateDisplayName, workspaceForget bindings.
- Always-show +/... buttons on the selected workspace row (prev hover-only, broke in Tauri).
- Desktop dev bootstrap hook: starts openwork-server + engineStart + orchestratorWorkspaceActivate on Tauri startup and emits openwork-server-settings-changed so routes re-resolve.
- Restore 17 missing i18n keys leaking as raw identifiers (session.default_model, session.select_or_create_session, settings.default_label, workspace.create_workspace, etc).
- Extend evals/react-session-flows.md with flows 10-12 + desktop runner notes.
* new stuff
* feat(react-app): composer parity with solid shell
- Composer layout matches solid: sticky gradient wrap, 24px panel, menus render
above the panel, attach/tools/send inline with editor, agent+model+behavior
strip below the panel.
- Tools menu now surfaces commands + skills + MCPs with MCP status badges and a
"Configure" settings affordance.
- Attachments gain the 8MB cap, mime allowlist, image compression, better cards,
and localized notices (file_exceeds_limit / unsupported_attachment_type).
- Pasted-text chips expose expand/copy/remove actions; clipboard links now
normalize file://, Windows and UNC paths and surface the unsupported-file
notice.
- Composer now listens for openwork:focusPrompt + openwork:flushPromptDraft and
blocks Enter during IME composition in both the wrapper and the lexical
submit command.
- Inbox upload button and handler wired to openworkClient.uploadInbox with
success/error notices.
- SessionRoute now opens ModelPickerModal in place (instead of navigating to
/settings/general) and lazily loads providers when the picker opens.
- SettingsRoute mounts ConnectionsModals so the MCP OAuth flow actually shows
its auth modal after addMcp.
- Boot flow: fast-path when engine is already running; orphan event
subscriptions disposed immediately so repeated workspace switches stop
leaking event streams. Sidebar collapses non-selected workspaces to keep
rapid switching responsive.
- BootStateProvider + LoadingOverlay + session-memory land under react-app
shell for a consistent startup experience.
* fix(react-app/session): center transcript in 800px column
Wrap SessionTranscript in mx-auto max-w-[800px] so user and assistant
messages share the same centered column as the composer, matching the
reference chat layout.
* fix(react-app/session): let the user break out of autoscroll during streaming
Previously a programmatic scroll-to-bottom fired on every content-height
change during streaming, and the onScroll handler early-returned while
that flag was set. In practice the ResizeObserver re-anchored the user
to the tail of the transcript faster than their wheel gesture window
could win, so scrolling up felt hijacked.
Now:
- onScroll detects a user gesture (wheel/touch/trackpad/scrollbar) or
a meaningful upward delta and aborts the in-flight programmatic
state, switching to manual-browse immediately.
- The ResizeObserver no longer auto-scrolls while the user is actively
gesturing, even if we're still technically in follow-latest, so the
transcript stops fighting the user during streaming.
- Widened the gesture window from 250ms to 600ms and added a 16px
upward threshold to filter anchoring jitter from real intent.
* fix(react-app/session): make workspace rename + remove actually work
Rename and remove were being applied only to the desktop side (Tauri
workspaces.json) while the openwork-server's /workspaces list kept the
old name and a still-present row, and then the next refreshRouteState
overwrote the desktop list with the server one because we prefer the
server-returned workspaces for correct IDs. As a result, clicking Edit
name or Remove workspace in the sidebar menu did nothing visible.
- handleSaveRenameWorkspace now also calls client.updateWorkspaceDisplayName
so the server reflects the new name on the next refresh.
- handleForgetWorkspace now also calls client.deleteWorkspace, confirms
with the user first, clears the sidebar selection + last-session
memory if the removed workspace was active, and navigates back to
/session so the main pane stops referencing a gone workspace.
- refreshRouteState overlays desktop displayName onto the matching
server workspace (by id or normalized path) and filters out any
server row that the desktop no longer knows about, so rename / remove
take effect instantly instead of flickering back.
- Added workspace_list.remove_confirm to the en locale.
* fix(react-app/session): stop draft leak + transient workspace/session mismatch
- SessionSurface now clears the composer draft when sessionId changes. The
existing reset effect was clearing attachments/mentions/pasteParts but
not draft itself, so typed text bled across every session switch.
- SessionRoute refuses to render SessionSurface when the current
selectedSessionId doesn't belong to the new selectedWorkspaceId. That
transient mismatch happens for one tick after clicking a different
workspace, between setSelectedWorkspaceId and the router landing on the
remembered session id. SessionPage.canRenderReactSurface now also
requires a non-null surface prop so spreading null is impossible.
- Added a runtime inspection surface (window.__openwork) used by the
session route and SessionSurface. The route and composer each publish a
live slice (workspaces, selected ids, draft state, attachments, mentions,
pasteParts, busy/error state) and append events on refresh/mount.
External drivers (devtools, chrome-mcp) can call .snapshot() / .slice()
/ .events() to inspect the app without walking the DOM.
* fix: opencode proxy content-encoding + new task flow
- openwork-server's proxyOpencodeRequest/proxyOpenCodeRouterRequest now
returns a sanitized Response that strips content-encoding /
content-length / transfer-encoding. Bun's native fetch already decodes
the upstream body but keeps the upstream headers, so the browser would
see Content-Encoding: gzip on a plain JSON payload and fail with
ERR_CONTENT_DECODING_FAILED. This broke session.create (and anything
else reaching through /w/<id>/opencode/*) from Chrome/web clients.
- SessionRoute.surfaceProps now only refuses to render when the URL
session is known to belong to a *different* workspace. A brand-new
session that hasn't appeared in any workspace's list yet still
renders, so 'New task' feels instant.
- onCreateTaskInWorkspace optimistically inserts the freshly-created
session into sessionsByWorkspaceId, writes it as the remembered
session for the workspace, navigates, and then background-refreshes
the route so the server-assigned title/timestamp catches up.
Remember to run `pnpm --filter openwork-server build:bin` after any
apps/server/src change — the desktop app spawns the built binary, not
the TS source.
* feat(observability): dev log sink + react console/error/hang forwarder
- openwork-server gets a POST /dev/log endpoint that appends JSON lines to
the path in OPENWORK_DEV_LOG_FILE. Unauth on purpose because it runs
only when the env var is set on the dev host.
- React app ships a debug-logger that:
- patches console.log/info/warn/error/debug to forward to /dev/log
- captures window.onerror + unhandledrejection
- wraps fetch to record every request's URL, method, status, duration
and a list of in-flight requests the inspector can read
- runs a 1s heartbeat that logs a 'hang' entry when the main thread
stalls more than 3s, including the in-flight fetch sample
- publishes a 'debug' slice on window.__openwork with pendingFetches +
memory
- uses a captured native fetch for its own flush so it doesn't recurse
- skips recording /dev/log traffic so it can't log itself
- scripts/openwork-debug.sh: snapshot the dev stack, tail all logs
(pnpm dev + /dev/log sink), probe server/opencode/router health, and
clean orphan processes (parent == launchd).
* fix(react-app): auto-recover from webview background throttling
The biggest driver of 'the app becomes inactive after a while' on macOS
is WKWebView's aggressive background throttling: setIntervals pause and
in-flight fetches can sit idle for minutes, so when the user refocuses
the app, cached state is stale and the refresh guard (refreshInFlightRef)
is still stuck true from the interrupted request.
- debug-logger now distinguishes between a real hang (3-10s main-thread
stall) and a post-throttle resume (>10s gap). Only the former gets a
'hang' entry; the latter is logged as a meta event with a clear
'Webview resumed after Xs' message so operators don't chase a
non-bug.
- debug-logger listens for visibilitychange and, on return to visible,
dispatches 'openwork-server-settings-changed' to kick the app into
re-resolving its server connection and re-fetching route data.
- session-route self-heals refreshInFlightRef on both the settings
change event AND on visibility flip so a refresh that was stuck
mid-flight during throttling doesn't block every subsequent refresh.
Together this turns the long silent 'frozen' state after backgrounding
into an automatic recover-and-refresh cycle.
* feat(composer): plain Enter sends; remove 'upload to shared folder' button
- Enter submits the composer by default; Shift+Enter inserts a newline.
Cmd/Ctrl+Enter still works for muscle memory. IME composition guard
preserved so CJK input never triggers a submit mid-character.
- Removed the Upload (shared folder / inbox) button, its hidden file
input, the helper, and the unused lucide Upload import. The
onUploadInboxFiles prop stays available for the remote-paste notice
flow but no longer has a dedicated button in the action row.
* fix(app/http): bypass Tauri HTTP plugin for streaming endpoints
The dev log sink captured repeated 10-minute main-thread stalls on the
desktop webview, every one with pendingFetchCount=1 and the stuck
request being ipc://localhost/plugin%3Ahttp%7Cfetch_read_body. Tauri's
HTTP plugin only resolves the response body when the whole body has
been delivered; pointed at an SSE endpoint (opencode /event or
openwork-server /workspace/<id>/events) the body never closes so the
IPC call hangs forever and the webview's queue backs up until the UI
looks completely frozen.
- apps/app/src/app/lib/opencode.ts: createTauriFetch now detects
streaming requests (URL contains /event or /stream, or the caller
set Accept: text/event-stream) and routes them through the webview's
native window.fetch instead of tauriFetch. Those requests also opt
out of the 10s transport timeout so long-lived SSE subscriptions
aren't aborted — callers abort via AbortSignal when they're done.
- apps/app/src/app/lib/openwork-server.ts: resolveFetch(url) now
switches to native fetch for URLs that match the SSE pattern
(/events or /event-stream or /stream) and keeps tauriFetch for every
other desktop request so CORS-sensitive endpoints still work.
- CORS is already wide open on openwork-server (--cors *) so there's
no regression from using native fetch for streams from the Tauri
webview.
* fix(react-app): publish engine baseUrl to ServerProvider on desktop boot
Move DesktopRuntimeBoot inside ServerProvider so the boot hook can call
useServer().setActive with the real engine baseUrl from both the fast path
(engineInfo probe) and slow path (engineStart). Fixes chat on Windows where
the engine binds to a dynamic port (e.g. 64357) instead of the default 4096
that ServerProvider was stuck on.
* fix(react-app): restore thinking-variant picker + Cmd+K top icons; tighten chat column
Three unintentional regressions from the React port:
1. Thinking / reasoning variant picker was empty because session-route
never threaded modelBehaviorOptions into SessionSurface. Now we
prefetch opencode's provider catalog as soon as the workspace
opencode client is available, and compute
getModelBehaviorSummary(providerID, model, variant) for the current
default model to produce both modelVariantLabel and
modelBehaviorOptions. The composer's Default/Thinking/Minimal etc.
pill works again.
2. Cmd+K command palette lost the top-bar shortcuts. Added entries for
Open documentation, Send feedback, and direct jumps to every
settings tab (Skills, Extensions, Messaging, Appearance, Recovery,
Updates) alongside the existing New session / Sessions / Settings
rows. onOpenSettings now accepts an optional route so the palette
can drop the user straight into the matching tab.
3. Chat transcript column tightened from 800px to 720px so messages
don't sit at the same width as the composer and feel too big. The
composer stays at 800px for action-bar breathing room.
* fix(composer): compact editor + show real default variant instead of 'Provider default'
- Composer used way more vertical space than intended. The editor had
min-h-[180px] on the wrapper + min-h-[140px] on the ContentEditable
so the starting height was ~180px. The panel padding (p-5 md:p-6)
added another ~40px. Reset to a tight single-line look
(min-h-[24px]) and cap the ContentEditable at max-h-[220px] with
overflow-y-auto so long pastes scroll inside the composer instead
of pushing the transcript off screen.
- Drop the generic 'Provider default' row from the reasoning/thinking
variant menu. The list now shows only concrete variants
(Low / Balanced / Deep / …). When no override is saved we resolve
the provider's actual default preset (OpenAI/Google reasoning →
medium, Anthropic extended-thinking → none, etc.) and use its
label for the pill, so the pill is honest about what will run.
Variant menu highlights the row whose label matches the pill, so
'Balanced' appears selected when the provider default is medium.
- Editor placeholder label sizing normalized to 15px/24px line height
to match the new compact composer geometry.
* perf(session): reduce large-transcript render cost
The current freeze investigation pointed at frontend render cost more than
fresh exceptions: a giant session loaded with a huge pasted-email block,
and Chrome DevTools itself timed out when we tried to interact with it.
The transcript was only virtualized after 500 message blocks, which means
sessions with a few enormous messages still rendered a giant DOM eagerly.
This patch makes the transcript cheaper:
- lower virtualization threshold from 500 -> 40 blocks so react-virtual
kicks in much earlier for realistic long sessions
- apply content-visibility:auto sooner (24 blocks) and reduce the
intrinsic placeholder size so distant blocks stay out of layout/paint
- add contain: layout style paint to block wrappers to isolate large
message subtree layout work
- avoid running the text-highlight walk when search is inactive; if no
query is set, only clear highlights when marks actually exist instead
of traversing every large message DOM tree on every render
After this change the previously-problematic giant session
(ses_262bc60dfffefubKIJNQ23S3h2) still loads and, importantly, accepts a
new prompt + assistant reply in Chrome without wedging.
* feat(openwork-debug): proper layered teardown + reset subcommand
When a pulled PR doesn't take effect on the running desktop app, the
cause is almost always a wedged Vite dev server still attached to the
previous module graph, plus the Tauri webview holding its own HMR
client cache, plus the dep pre-bundle cache in node_modules/.vite. The
fix is multi-step and easy to get wrong by hand, so codify it.
scripts/openwork-debug.sh now supports:
start nohup pnpm dev with the /dev/log sink on
stop layered teardown (pnpm dev -> tauri dev -> webview ->
Vite -> openwork-server/orchestrator/opencode/router ->
orphan sweep)
reset stop + wipe Vite caches (apps/app/node_modules/.vite,
root + apps/desktop too) + truncate sink + start +
wait-healthy
restart alias for reset
wait-healthy block until openwork-server /health returns 200
status alias for snapshot
reset-webview destructive: wipe the dev WKWebView WebsiteData when a
plain reset still leaves the UI stuck on a stale URL
override
Safety:
- Tauri webview is matched by full 'target/debug/OpenWork-Dev' path so
the installed /Applications/OpenWork.app is never killed by mistake.
- pnpm dev teardown uses the PID file written at start time, falling
back to a path-aware pkill so we don't nuke pnpm runs in other
repos on the same host.
- reset preserves ~/Library/Application Support/com.differentai.openwork.dev
(tokens, workspaces registry, prefs) and only clears Vite ephemera
plus the log sink file (truncated).
This replaces the previous ad-hoc 'kill pnpm; kill vite; rm -rf .vite'
dance users had to do when HMR went stale after pulling changes.
* fix(composer): paste preserves newlines, no path hijack, no emojis
Two longstanding composer bugs:
1) Paste was fucked. The onPaste handler hijacked any plain-text paste
whose lines looked 'path-ish' (anything starting with '/', file://,
UNC, Windows drive letter) via parseClipboardLinks + preventDefault,
and it also force-replaced any paste >10 lines with a
'pasted text' chip. Together this meant normal email/code/legal
pastes either vanished or collapsed into a pill.
New policy:
- clipboard files -> attach (unchanged)
- clipboard text/uri-list -> treat as external URL drop, insert
links via onUnsupportedFileLinks (drag-from-Finder path only)
- everything else (plain text) -> DO NOTHING, let Lexical handle
the paste natively so newlines render and no content is lost
- remote/sandbox workspace warning is now advisory only; the paste
still goes through
Removed the now-dead countLines helper and the WINDOWS_PATH_RE /
UNC_PATH_RE constants. parseClipboardLinks becomes the
uri-list-only parseClipboardUriList.
2) Emojis in the composer. Mention pills rendered '🤖 ' / '📄 ' and
the pasted-text chip rendered '📋 N lines'. Removed all three. The
pill background/foreground already communicates the kind. The paste
chip now reads 'Pasted · N line(s)'.
* fix(composer): only render pasted-text chip inline, drop duplicate rail
The pasted-text chip was rendered twice: once inline inside the Lexical
editor via ComposerPastedTextNode, and again as a separate rail above
the composer. Deleted the rail so there's a single, inline
representation. The inline chip keeps its label + line-count pill and
users remove it with backspace like any other inline token.
* fix(session): allow appending prompts mid-stream; point feedback button at feedback
Two regressions from the React port:
1) Couldn't append a new prompt while the assistant was still producing
a response. SessionSurface.handleSend short-circuited on
chatStreaming, and the composer swapped the Send button out for a
Stop button whenever busy. Together this made mid-stream follow-ups
impossible.
- handleSend now only short-circuits when the draft is empty. Sending
while streaming is allowed; OpenCode accepts follow-up user turns
mid-run, and any error surfaces via setError.
- Composer action row now keeps Send reachable during streaming:
when busy + draft has text, Stop and Send render side-by-side.
When busy + draft empty, only Stop shows.
2) The bottom-right 'Send feedback' icon in the status bar used to
openLink('https://openworklabs.com/docs'), so clicking feedback
opened the docs. SessionRoute now uses buildFeedbackUrl({
entrypoint: 'status-bar', appVersion: '0.11.207' }) from
apps/app/src/app/lib/feedback.ts — the same helper the Solid app
used — so the button opens the real feedback form with source +
client OS context populated.
* feat(dev-profiler): React Profiler overlay for pnpm dev
Dev-only overlay that shows which React zones re-render and how long
they take, so operators can immediately see which subtree is thrashing
when the UI feels stuck.
Mechanics:
- apps/app/src/react-app/shell/dev-profiler.tsx: exports DevProfiler
(wraps a subtree with React's <Profiler>) and DevProfilerOverlay
(small floating card, bottom-right).
- Aggregates every commit into a Map keyed by zone id: count, total
actual ms, base ms, last commit ts, mount vs update counts. Emits
to subscribers via rAF-throttled dispatch so a stream of commits
can't flood setState.
- Overlay is opt-in. Toggle with Cmd/Ctrl+Shift+P; last state is
persisted in localStorage.openwork.debug.profilerOverlay. Off by
default.
- In prod builds the wrapper is a pure pass-through (no <Profiler>
mounted, no overhead) and the overlay renders null. Gate via
import.meta.env.DEV.
- Exposes the same snapshot at window.__openwork.slice('profiler') so
Chrome MCP / external drivers can read render counts without the
overlay being visible.
Instrumented zones so the table fills up with real work:
- AppRoot, SessionRoute, SettingsRoute (top-level routing)
- SessionSurface (chat pane)
- SessionTranscript (message list)
- SessionComposer (composer tree)
Using it:
1. pnpm dev
2. open the dev app
3. press Cmd+Shift+P
4. watch the table as you interact — hottest zones bubble to the top,
rows flash when they commit so you can visually attribute bursts.
5. hit 'reset' in the overlay header to clear counters between tests.
* fix(model-behavior): use vendor-canonical thinking-mode nomenclature
The composer's variant labels were generic ('Light / Balanced / Deep /
Maximum'), which didn't match the terminology users already know from
the vendors' own products. Switched to vendor-aligned labels per
provider family so the composer pill shows names matching ChatGPT /
Claude / Gemini / Grok conventions.
Mapping (per provider family, by OpenCode variant key):
OpenAI / ChatGPT / Azure / OpenCode
none or minimal -> Instant
low -> Light thinking
medium -> Thinking
high -> Thinking longer
xhigh / max -> Maximum thinking
section title -> 'Reasoning effort'
Anthropic / Claude
none -> No extended thinking
low -> Brief extended thinking
medium -> Extended thinking
high -> Deep extended thinking
xhigh / max -> Maximum extended thinking
section title -> 'Extended thinking'
Google / Gemini
none -> Instant
low -> Brief thinking
medium -> Thinking
high -> Deep thinking
xhigh / max -> Maximum thinking
section title -> 'Thinking budget'
xAI / Grok
none -> Fast
low / medium -> Think
high -> Think harder
xhigh / max -> Think hardest
section title -> 'Think mode'
Each row's description was also rewritten per family to match. The
previous generic labels remain as the fallback for providers outside
these four families.
Also added resolveProviderFamily(providerID) to centralize the family
bucketing so getVariantLabel / getVariantDescription / getBehaviorTitle
stay consistent.
* fix(dev-profiler): stop the profiler from profiling itself into a loop
The React Profiler overlay produced absurd numbers (1.1M+ commits per
zone, ~22min of render work) because it re-rendered itself inside the
AppRoot Profiler zone every time it recorded a commit — which itself
scheduled another commit, forever.
Why:
commit somewhere -> recordCommit -> scheduleEmit (rAF)
emit -> every subscriber's setSnapshot(...)
-> overlay re-renders (inside AppRoot's <Profiler>)
-> another commit -> recordCommit -> emit -> ...
Two changes:
1. DevProfilerOverlay now splits into two components.
- DevProfilerOverlayToggle owns visibility + the Cmd+Shift+P bind
and does NOT subscribe.
- DevProfilerOverlayVisible is only mounted when the overlay is
visible and is the sole subscriber to profiler snapshots.
When the overlay is hidden (the default), nothing is subscribed.
2. recordCommit / scheduleEmit short-circuit when subscribers.size === 0.
Profiler zones are therefore zero-cost when the overlay is off: no
Map writes, no rAF scheduled, no emit, no re-render chain.
When the overlay is on you still get the intended behavior (rows
update per commit, flashing, reset button, totals), but the overlay's
own re-renders no longer propagate endlessly through the tree.
Prod builds: untouched (the wrapper is already a pass-through and the
overlay returns null when !import.meta.env.DEV).
* perf(session): memoize SessionTranscript; lift profiler overlay out of AppRoot zone
The previous profile (1144 AppRoot commits, 55 SessionTranscript at
27ms each) made two things obvious:
1) AppRoot's commit count was inflated by the dev profiler overlay's
own re-renders. The overlay subscribes to commits to refresh its
table, and it was mounted inside <DevProfiler id='AppRoot'>, so
every overlay self-render registered as an AppRoot commit (~1073 of
the 1144). Now the overlay sits OUTSIDE the AppRoot Profiler zone
so AppRoot's count reflects real app-level commits only and its
children's relative numbers stay readable.
2) SessionTranscript was committing 55 times for 59 SessionSurface
commits at ~26 ms per commit (right at the 16ms frame budget). The
counts being almost identical means the transcript was re-rendering
on every parent commit instead of only when its own props changed.
Wrapping with React.memo at the transcript boundary lets it skip
re-renders when SessionSurface ticks for unrelated state (sending,
chatStreaming, attachment changes, etc.). The transcript component
itself remains the same; the public export is a memoized wrapper
over SessionTranscriptInner with displayName preserved.
Composer was already cheap (~0.5ms/commit), so left as-is. Next pass
should reduce SessionSurface state churn during streaming so the
parent commits 5-10x less often.
* fix(dev-profiler): opt-in only; disable by default in dev
Symptom: open an old session, type 'tell me a long story', press
Enter. A few tokens stream in, then the webview wedges. Log contains:
Uncaught DataCloneError: Failed to execute 'measure' on
'Performance': Data cannot be cloned, out of memory.
at logComponentRender (react-dom_client.js)
Root cause: the React <Profiler> zones I added (AppRoot, SessionRoute,
SessionSurface, SessionTranscript, SessionComposer) cause React-DOM's
internal instrumentation (logComponentRender) to emit
performance.measure entries on every commit. Sustained streaming
commits fill the browser's performance timeline faster than it can be
reclaimed; once the allocation cloner runs out of space the commit
throws mid-stream and the main thread stalls for everyone.
Fix: make the profiler fully opt-in. Default is OFF even in dev. Two
opt-ins:
- VITE_OPENWORK_PROFILER=1 at pnpm dev
- window.localStorage.setItem('openwork.debug.profiler', '1')
When off, <DevProfiler> is a pure pass-through (no <Profiler> mounted,
no overhead) and <DevProfilerOverlay> renders null. The Cmd+Shift+P
overlay toggle is unchanged for opted-in dev runs.
* perf(session): correct virtualizer + memoize markdown blocks
Audit of why large sessions still hung 'after 2 words' during streaming
turned up four real virtualizer bugs and one markdown hot spot:
1. shouldVirtualize required props.scrollElement() to already be
non-null. On the first render of a large session the scroll
container ref hadn't attached yet, so the whole transcript rendered
eagerly (every message block) for one tick before switching to
virtualization. That burst alone froze the UI on huge transcripts.
Now shouldVirtualize only depends on block count.
2. A useEffect called virtualizer.measure() on every messageBlocks
change. During streaming messageBlocks gets a new identity on every
token, so we forced a synchronous re-measure (every row's
getBoundingClientRect) on every token. react-virtual already
invalidates rows whose refs or content change. Deleted the effect.
3. The render had a second eager fallback: virtualRows.length > 0
? <virtualized> : <every message eagerly>. That re-introduced the
same freeze on the very first virtualized render before rows were
computed. Removed; if shouldVirtualize is true we always render the
virtualized container, even when getVirtualItems() is empty, so the
tree stays bounded.
4. estimateSize was a flat 220 for every block. Giant messages and
tiny user-user bubbles share the same bad guess, so react-virtual
constantly fought the real sizes. Replaced with a shape-aware
estimate (steps-cluster: 80, user: 96, assistant: 320).
5. VIRTUALIZATION_THRESHOLD lowered from 40 -> 20. Medium sessions are
cheaper to render via the virtualizer than as a flat list of 20+
markdown-heavy blocks.
6. MarkdownBlock was not memoized. Every streamed token re-parsed
markdown for every visible message. Wrapped it in React.memo so
blocks other than the currently-streaming one skip re-render when
their own text prop didn't change.
Also fixed an unrelated TS error in session-sync (missing
deltaFlushBuffer / deltaFlushScheduled fields on the SyncEntry factory
literal) that was blocking typecheck.
* perf(session): structural sharing for messageBlocks during streaming
Port the useStableRows idea from T3Tools' MessagesTimeline. On every
streaming token, props.messages is a fresh array but only the
currently-streaming message has a new UIMessage reference — every
other message in the transcript is still pointer-equal to last tick
(session-sync.ts uses messages.slice() + targeted mutation).
Our messageBlocks useMemo previously reconstructed every block object
from scratch on every token, so downstream React.memo'd components
(MarkdownBlock especially) always saw fresh prop references and
couldn't bail out. Structural sharing fixes that:
- blockIdentityKey(block): derives a stable key per block ('msg:<id>'
for message blocks, 'cluster:<id>' for step clusters).
- blocksAreEquivalent(prev, next): returns true when the two blocks
point at content-equal data. For message blocks the critical check
is prev.message === next.message (UIMessage reference). For step
clusters we compare messageIds + stepGroups identity.
- SessionTranscriptInner keeps a previousBlocksRef<Map<key, block>>.
After the raw messageBlocks array is computed it maps each entry
through useStableBlocks-equivalent logic: if the previous block at
the same key is content-equivalent we reuse the previous reference.
Net effect: during a streaming burst, only the active assistant
message's block gets a new identity per token. All other rows hand
pointer-equal props to their memoized children, which bail out of
rendering entirely. This is the highest-leverage fix available for
the 'big sessions freeze after a few streamed words' symptom.
* fix(session-sync): infer stub role when parts arrive before message.updated
When message.part.updated or message.part.delta arrives for a
messageID we haven't seen a message.updated for yet, we stub the
message so the part has somewhere to live. The stub's role was
hard-coded to 'assistant', which lost the race during a promptAsync
flow: if the server emitted a part event for a user turn before the
user message.updated, the new user message flashed as an assistant-
styled block (left-aligned, markdown, no bubble) until the real role
arrived a tick later. User saw 'sometimes my message renders outside
the user bubble, as if it were assistant'.
Fix: infer the stub role from the conversation. Chat turns alternate
so a new unknown message is almost always the opposite role of the
most recent known message. If the transcript is empty the first
message is always the user's.
- New inferStubRole(messages): returns 'user' when the last message
was 'assistant' (and vice versa), defaulting to 'user' when there
is no prior message.
- message.part.updated / delta-flush stubs now call inferStubRole if
the message isn't already in state. If it is, they preserve the
existing role to avoid flapping.
upsertMessage already re-applies the authoritative role as soon as
the real message.updated event arrives, so this is purely a correct-
on-first-paint improvement and doesn't affect long-term state.
* fix(dev-logger): stop spamming 404s when the /dev/log sink is disabled
The React debug-logger posts console/error/fetch/hang events to the
openwork-server /dev/log sink. When pnpm dev is started without
OPENWORK_DEV_LOG_FILE set, the server returned 404 from both GET and
POST /dev/log. The browser logs every failed POST as
'Failed to load resource: 404' regardless of whether the logger
silently handles the error, so an operator who doesn't know about the
env var sees console noise on every page.
Two changes:
1) Server probe now returns 200 + {ok:false, reason:'dev_log_disabled'}
instead of 404. Clients that want to know whether the sink is active
should read the body flag, not the status. This eliminates the
console warning from the probe itself.
2) Client now probes /dev/log once per base URL with GET. If the
response body says the sink is disabled, it caches that decision
and skips all further POSTs for the remainder of the session. In-
memory events remain available via window.__openwork.events().
Net effect: sink-disabled dev sessions produce zero /dev/log traffic
and zero console 404s. Sink-enabled sessions behave as before.
* feat(app): wire share-workspace modal; add alpha release channel concept
Share workspace (React port):
- session-route wires the full ShareWorkspaceModal + useShareWorkspaceState,
with remote-access toggle (local workspaces), workspace-profile publish,
skills-set publish, team/public template flows, and workspace export.
- Route now tracks OpenworkServerInfo, EngineInfo, and settings version so
the share state has enough context to resolve the mounted local workspace
URL and host tokens.
Alpha release channel:
- New ReleaseChannel type ("stable" | "alpha").
- apps/app/src/app/lib/release-channels.ts exposes STABLE/ALPHA updater
endpoints, resolveUpdaterEndpoint, isAlphaChannelSupported, and a
coerceReleaseChannel helper.
- LocalPreferences carries releaseChannel (default "stable").
- Updates settings shows a macOS-only Stable/Alpha toggle.
- .github/workflows/alpha-macos-aarch64.yml now publishes signed+notarized
macOS arm64 builds to a rolling alpha-macos-latest GitHub release with
a Tauri updater latest.json on every merge to dev.
- ARCHITECTURE.md documents the channel contract.
* feat(react-app/cloud): port Den auth + desktop-config + forced-signin gate
Ports the cloud-domain pieces dev added on Solid so the React shell has
feature parity for:
- DenAuthProvider / useDenAuth — drives auth status + user, calls
ensureDenActiveOrganization() on every refresh so Better-Auth's
active org stays in sync (Solid: apps/app/src/app/cloud/den-auth-provider.tsx)
- DesktopConfigProvider / useDesktopConfig / useOrgRestrictions — fetches
the org-scoped DesktopAppRestrictions (blockZenModel, disallowNonCloudModels,
etc.) introduced in packages/types, caches it per base-url+org in
localStorage so the gate resolves synchronously on next boot, and
re-fetches on denSessionUpdatedEvent / denSettingsChangedEvent and a
1h interval (Solid: apps/app/src/app/cloud/desktop-config-provider.tsx)
- DenSignInSurface — shared presentation surface for both the forced
sign-in page and any embedded 'panel' usage; matches Solid props 1:1
so flows are interchangeable
(Solid: apps/app/src/app/cloud/den-signin-surface.tsx)
- ForcedSigninPage — full-screen sign-in gate that owns local drafts
(base URL, manual auth input) and pipes them into DenSignInSurface.
Handles manual auth input parsing (openwork:// deep links and raw
grant codes) and base-URL overrides via setDenBootstrapConfig
(Solid: apps/app/src/app/cloud/forced-signin-page.tsx)
Shell wiring:
- providers.tsx mounts DenAuthProvider > DesktopConfigProvider inside
GlobalSyncProvider so all routes have access to auth + org restrictions
- app-root.tsx adds a /signin route and a DenSigninGate component that
enforces readDenBootstrapConfig().requireSignin: redirects unsigned
users to /signin and signed-in users away from /signin. Renders null
while the first Den session check is still in flight so no
session/settings chrome flashes behind the gate.
Better-Auth active-org:
- den-settings-panel.tsx now calls ensureDenActiveOrganization({
forceServerSync: true }) both in refreshOrgs and in the org <select>
change handler so subsequent /v1/org/* requests resolve against the
freshly selected org (Solid ac41d58b).
Shared lib:
- den.ts re-exports the DenDesktopConfig type alias so React consumers
can import it from the same module as its helpers. Solid referenced it
only internally.
* feat(react-app/cloud): port dev #1476 + #1505 + #1509 + #1512
Completes the cloud-domain feature parity audit: everything dev shipped
on Solid that hadn't yet been reachable from the React shell now has a
React-native equivalent.
Desktop restriction enforcement (#1505):
- desktop-config-provider exposes a stable checkRestriction closure
matching DesktopAppRestrictionChecker from cloud/desktop-app-restrictions
- New hooks: useCheckDesktopRestriction() and useDesktopRestriction(key)
- RestrictionNoticeProvider owns a single app-wide RestrictionNoticeModal
and exposes { show, dismiss } via useRestrictionNotice()
- session-route now gates "Create workspace" on blockMultipleWorkspaces
and surfaces the admin notice when blocked
- session-route filters the model picker against blockZenModel and
disallowNonCloudModels before the search filter runs, so blocked
providers/models are never offered
- settings-route filters the provider-auth modal's provider list through
the same desktop-provider gate so users can't connect a forbidden one
Cloud provider auto-sync (#1509):
- Ported cloud/sync/constants.ts already came in via the merge
- New useCloudProviderAutoSync(refresh) hook runs refreshCloudOrgProviders
on mount and every CLOUD_SYNC_INTERVAL_MS while Den auth is signed-in;
skips while signed-out and avoids overlapping ticks via an in-flight ref
- Mounted from settings-route (which already owns the provider-auth store)
Cloud-id drift protection (#1510):
- import-state.ts + provider-auth/store.ts track sourceProviderId so we
can detect when the provider's server-side id drifts from what we
imported (enables rename-aware de-dup later)
- ProviderIcon gained the providerName prop so cloud-id-keyed providers
still resolve the right icon via the provider-family detector
Desktop update gating (#1476 + #1512):
- New lib/version-gate.ts exposes compareVersions, parseComparableVersion,
isUpdateAllowedByDesktopConfig (allowedDesktopVersions), and
isUpdateSupportedByDen (Den /v1/app-version round-trip)
- Combined isUpdateAllowed() helper is the single entry point the React
updater flow should call once it's wired (pre-existing TODO)
Not yet wired (explicit follow-ups, none of them user-facing gaps today):
- React checkForUpdates itself is still stubbed; version-gate helpers are
ready to plug in when that land
- Full workspace-config-side restriction reconciliation
(runDesktopAppRestrictionSyncEffects) — our React provider store
doesn't yet expose ensureProjectProviderDisabledState; when it does,
wire the sync-effects call from app-root.
* fix(react-app): stop idle memory growth in the shell
---------
Co-authored-by: Jan Carbonell <jc2897@cornell.edu>
This commit is contained in:
209
.github/workflows/alpha-macos-aarch64.yml
vendored
209
.github/workflows/alpha-macos-aarch64.yml
vendored
@@ -1,4 +1,17 @@
|
||||
name: Alpha Desktop Artifact (macOS arm64)
|
||||
name: Alpha Channel (macOS arm64)
|
||||
|
||||
# Every merge to `dev` publishes a fresh macOS arm64 build to the OpenWork
|
||||
# alpha release channel.
|
||||
#
|
||||
# The alpha channel is macOS-only today. It lives as a rolling GitHub
|
||||
# release under the fixed tag `alpha-macos-latest` so the Tauri updater
|
||||
# manifest URL stays stable while the underlying artifact gets replaced on
|
||||
# every run.
|
||||
#
|
||||
# See:
|
||||
# - apps/app/src/app/lib/release-channels.ts (updater endpoint URLs)
|
||||
# - ARCHITECTURE.md#release-channels
|
||||
# - .github/workflows/release-macos-aarch64.yml (stable channel)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,20 +20,26 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: alpha-macos-aarch64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-alpha-macos-aarch64:
|
||||
name: Build alpha artifact (aarch64-apple-darwin)
|
||||
publish-alpha-macos-aarch64:
|
||||
name: Build + Publish alpha (aarch64-apple-darwin)
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 180
|
||||
|
||||
env:
|
||||
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
|
||||
ALPHA_RELEASE_TAG: alpha-macos-latest
|
||||
ALPHA_RELEASE_NAME: OpenWork Alpha (macOS arm64)
|
||||
# Apple signing + notarization are required so alpha bundles install
|
||||
# and launch without Gatekeeper friction. Alpha builds are served
|
||||
# from GitHub Releases like stable, just from a different tag.
|
||||
MACOS_NOTARIZE: ${{ vars.MACOS_NOTARIZE || 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -72,9 +91,68 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Create CI Tauri config (no updater artifacts)
|
||||
- name: Resolve alpha version
|
||||
id: alpha-version
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_RUN_NUMBER: ${{ github.run_number }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
node -e "const fs=require('fs'); const configPath='apps/desktop/src-tauri/tauri.conf.json'; const ciPath='apps/desktop/src-tauri/tauri.conf.alpha.json'; const config=JSON.parse(fs.readFileSync(configPath,'utf8')); config.bundle={...config.bundle, createUpdaterArtifacts:false}; fs.writeFileSync(ciPath, JSON.stringify(config, null, 2));"
|
||||
set -euo pipefail
|
||||
node <<'NODE' >> "$GITHUB_OUTPUT"
|
||||
const fs = require("node:fs");
|
||||
const path = "apps/desktop/src-tauri/tauri.conf.json";
|
||||
const raw = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const current = String(raw.version || "").trim();
|
||||
const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported version in ${path}: ${current}`);
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
// Alpha builds advertise the *next* patch version so semver
|
||||
// comparison makes the alpha newer than the current stable
|
||||
// (e.g. stable 0.11.207 < alpha 0.11.208-alpha.<run>). Once
|
||||
// stable 0.11.208 ships, its semver beats the alpha prerelease
|
||||
// tag and alpha users cleanly migrate forward.
|
||||
const nextPatch = Number(patch) + 1;
|
||||
const run = process.env.GITHUB_RUN_NUMBER || "0";
|
||||
const sha = (process.env.GITHUB_SHA || "").slice(0, 7) || "local";
|
||||
const alpha = `${major}.${minor}.${nextPatch}-alpha.${run}+${sha}`;
|
||||
console.log(`alpha_version=${alpha}`);
|
||||
console.log(`base_version=${major}.${minor}.${nextPatch}`);
|
||||
NODE
|
||||
|
||||
- name: Write alpha Tauri config override
|
||||
shell: bash
|
||||
env:
|
||||
ALPHA_VERSION: ${{ steps.alpha-version.outputs.alpha_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const base = "apps/desktop/src-tauri/tauri.conf.json";
|
||||
const out = "apps/desktop/src-tauri/tauri.conf.alpha.json";
|
||||
const config = JSON.parse(fs.readFileSync(base, "utf8"));
|
||||
|
||||
config.version = process.env.ALPHA_VERSION;
|
||||
|
||||
// Alpha builds must advertise updater artifacts so the
|
||||
// Tauri updater receives a `.app.tar.gz` + `.sig` pair.
|
||||
config.bundle = { ...(config.bundle || {}), createUpdaterArtifacts: true };
|
||||
|
||||
// Point this build's updater at the alpha channel's rolling
|
||||
// manifest. The stable endpoint stays in the base config for
|
||||
// everyone else.
|
||||
config.plugins = config.plugins || {};
|
||||
config.plugins.updater = {
|
||||
...(config.plugins.updater || {}),
|
||||
endpoints: [
|
||||
"https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json",
|
||||
],
|
||||
};
|
||||
|
||||
fs.writeFileSync(out, `${JSON.stringify(config, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -122,16 +200,113 @@ jobs:
|
||||
cp "$extract_dir/opencode" "apps/desktop/src-tauri/sidecars/opencode-aarch64-apple-darwin"
|
||||
chmod 755 "apps/desktop/src-tauri/sidecars/opencode-aarch64-apple-darwin"
|
||||
|
||||
- name: Build alpha desktop app
|
||||
run: pnpm --filter @openwork/desktop exec tauri build --config src-tauri/tauri.conf.alpha.json --target aarch64-apple-darwin --bundles dmg,app
|
||||
- name: Clear previous alpha release (rolling channel)
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Keep a single rolling release under ALPHA_RELEASE_TAG. Delete
|
||||
# whatever exists so tauri-action can recreate it fresh with
|
||||
# this run's artifacts, and users on the alpha channel always
|
||||
# resolve to the freshest latest.json.
|
||||
gh release delete "$ALPHA_RELEASE_TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--cleanup-tag \
|
||||
--yes || true
|
||||
|
||||
- name: Upload alpha artifact bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
- name: Write notary API key
|
||||
if: env.MACOS_NOTARIZE == 'true'
|
||||
env:
|
||||
APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
NOTARY_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
|
||||
printf '%s' "$APPLE_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$NOTARY_KEY_PATH"
|
||||
chmod 600 "$NOTARY_KEY_PATH"
|
||||
|
||||
echo "NOTARY_KEY_PATH=$NOTARY_KEY_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build + upload alpha (notarized)
|
||||
if: env.MACOS_NOTARIZE == 'true'
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
env:
|
||||
CI: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Tauri updater signing — same minisign keypair as stable so
|
||||
# an installed stable build can update into alpha and back.
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# macOS signing
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }}
|
||||
|
||||
# macOS notarization (App Store Connect API key)
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_NOTARY_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }}
|
||||
APPLE_API_KEY_PATH: ${{ env.NOTARY_KEY_PATH }}
|
||||
with:
|
||||
name: openwork-alpha-macos-aarch64-${{ github.sha }}
|
||||
path: |
|
||||
apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz
|
||||
apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
tagName: ${{ env.ALPHA_RELEASE_TAG }}
|
||||
releaseName: ${{ env.ALPHA_RELEASE_NAME }}
|
||||
releaseBody: |
|
||||
Rolling alpha build for OpenWork (macOS arm64).
|
||||
Every merge to `dev` replaces the artifacts attached to this release.
|
||||
Subscribe from Settings → Updates → Release channel → Alpha.
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
projectPath: apps/desktop
|
||||
tauriScript: pnpm exec tauri -vvv
|
||||
args: --config src-tauri/tauri.conf.alpha.json --target aarch64-apple-darwin --bundles dmg,app
|
||||
retryAttempts: 3
|
||||
uploadUpdaterJson: false
|
||||
releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext]
|
||||
|
||||
- name: Build + upload alpha (unsigned fallback)
|
||||
if: env.MACOS_NOTARIZE != 'true'
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
env:
|
||||
CI: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: ${{ env.ALPHA_RELEASE_TAG }}
|
||||
releaseName: ${{ env.ALPHA_RELEASE_NAME }}
|
||||
releaseBody: |
|
||||
Rolling alpha build for OpenWork (macOS arm64).
|
||||
Unsigned build (MACOS_NOTARIZE disabled). macOS Gatekeeper will
|
||||
require a manual open-on-first-launch. Subscribe from Settings →
|
||||
Updates → Release channel → Alpha.
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
projectPath: apps/desktop
|
||||
tauriScript: pnpm exec tauri -vvv
|
||||
args: --config src-tauri/tauri.conf.alpha.json --target aarch64-apple-darwin --bundles dmg,app
|
||||
retryAttempts: 3
|
||||
uploadUpdaterJson: false
|
||||
releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext]
|
||||
|
||||
- name: Generate alpha latest.json
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release/generate-latest-json.mjs \
|
||||
--tag "$ALPHA_RELEASE_TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--output "$RUNNER_TEMP/alpha-latest.json"
|
||||
|
||||
- name: Upload alpha latest.json
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$ALPHA_RELEASE_TAG" \
|
||||
"$RUNNER_TEMP/alpha-latest.json#latest.json" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
|
||||
@@ -5,5 +5,5 @@ description: Guide user through Chrome browser automation setup
|
||||
|
||||
Help the user set up browser automation.
|
||||
|
||||
Use the `browser-setup-devtools` skill and follow it strictly (Chrome DevTools MCP first, extension only as fallback).
|
||||
Use the `browser-setup-devtools` skill and follow it strictly (Chrome DevTools MCP only).
|
||||
Keep the user prompt minimal and let the skill drive the setup dance.
|
||||
|
||||
115
.opencode/package-lock.json
generated
Normal file
115
.opencode/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz",
|
||||
"integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.3.17",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.96",
|
||||
"@opentui/solid": ">=0.1.96"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz",
|
||||
"integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
name: browser-setup-devtools
|
||||
description: Guide users through browser automation setup using Chrome DevTools MCP as the primary path and the OpenCode browser extension as a fallback. Use when the user asks to set up browser automation, Chrome DevTools MCP, browser MCP, browser extension, or runs the browser-setup command.
|
||||
description: Guide users through browser automation setup using Chrome DevTools MCP only. Use when the user asks to set up browser automation, Chrome DevTools MCP, browser MCP, or runs the browser-setup command.
|
||||
---
|
||||
|
||||
# Browser automation setup (DevTools MCP first)
|
||||
# Browser automation setup (Chrome DevTools MCP)
|
||||
|
||||
## Principles
|
||||
|
||||
- Keep prompts minimal; do as much as possible with tools and commands.
|
||||
- Always attempt Chrome DevTools MCP first; only fall back to the browser extension when DevTools MCP cannot be used.
|
||||
- Use Chrome DevTools MCP only.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -29,16 +29,6 @@ description: Guide users through browser automation setup using Chrome DevTools
|
||||
5. If DevTools MCP is ready:
|
||||
- Offer a first task ("Let's try opening a webpage").
|
||||
- If yes, use `chrome-devtools_navigate_page` or `chrome-devtools_new_page` to open the URL and confirm completion.
|
||||
6. Fallback only if DevTools MCP cannot be used:
|
||||
- Check availability with `browser_version` or `browser_status`.
|
||||
- If missing, run `npx @different-ai/opencode-browser install` yourself.
|
||||
- Open the Extensions page yourself when possible:
|
||||
- macOS: `open -a "Google Chrome" "chrome://extensions"`
|
||||
- Windows: `start chrome://extensions`
|
||||
- Linux: `xdg-open "chrome://extensions"`
|
||||
- Tell the user to enable Developer mode, click "Load unpacked", and select `~/.opencode-browser/extension`, then pin the extension.
|
||||
- Re-check availability with `browser_version`.
|
||||
- Offer a first task and use `browser_open_tab`.
|
||||
|
||||
## Response rules
|
||||
|
||||
|
||||
@@ -85,6 +85,28 @@ 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.
|
||||
|
||||
## Release channels
|
||||
|
||||
OpenWork desktop ships through two release channels:
|
||||
|
||||
- **Stable** (default, all platforms): versioned builds produced by the `Release App` workflow. Each tag `vX.Y.Z` publishes signed, notarized bundles plus a `latest.json` updater manifest at `https://github.com/different-ai/openwork/releases/latest/download/latest.json`.
|
||||
- **Alpha** (macOS arm64 only, rolling): every merge to `dev` publishes a signed, notarized build to the rolling GitHub release tagged `alpha-macos-latest`. The alpha updater manifest lives at a stable URL: `https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json`.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- The alpha channel is an opt-in preference (`LocalPreferences.releaseChannel`). The toggle is rendered only when `isTauriRuntime()` and `isMacPlatform()` both resolve true; other platforms silently fall back to stable even if the stored preference says `"alpha"`.
|
||||
- Alpha builds advertise the next patch version plus an `-alpha.<runNumber>+<sha>` prerelease suffix. That keeps semver ordering `stable < alpha.1 < alpha.2 < next stable` so alpha users migrate forward cleanly when the next stable ships.
|
||||
- Alpha and stable share the same Tauri updater signing keypair so an installed stable can upgrade into alpha and vice versa without re-installing manually.
|
||||
- Apple signing and notarization are required on both channels; the `MACOS_NOTARIZE` repo variable gates the signed path in `alpha-macos-aarch64.yml`.
|
||||
- The alpha workflow is the source of truth for the alpha channel's CI contract. Treat `.github/workflows/alpha-macos-aarch64.yml`, `apps/app/src/app/lib/release-channels.ts`, and this document as one coupled unit.
|
||||
|
||||
Code references:
|
||||
|
||||
- Workflow: `.github/workflows/alpha-macos-aarch64.yml`
|
||||
- Endpoint resolution: `apps/app/src/app/lib/release-channels.ts`
|
||||
- Preference plumbing: `apps/app/src/react-app/kernel/local-provider.tsx`, `apps/app/src/react-app/domains/settings/pages/updates-view.tsx`
|
||||
- Stable workflow (reference): `.github/workflows/release-macos-aarch64.yml`
|
||||
|
||||
## Reload-required flow
|
||||
|
||||
OpenWork uses a single reload-required flow for changes that only take effect when OpenCode restarts.
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
</head>
|
||||
<body class="bg-dls-surface text-dls-text">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script type="module" src="/src/index.react.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,11 +46,8 @@
|
||||
"@openwork/types": "workspace:*",
|
||||
"@openwork/ui": "workspace:*",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@solid-primitives/event-bus": "^1.1.2",
|
||||
"@solid-primitives/storage": "^4.3.3",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@tanstack/solid-virtual": "^3.13.19",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "~2.6.0",
|
||||
@@ -61,27 +58,25 @@
|
||||
"ai": "^6.0.146",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"lucide-solid": "^0.562.0",
|
||||
"lexical": "^0.35.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"solid-js": "^1.9.0",
|
||||
"streamdown": "^2.5.0"
|
||||
"streamdown": "^2.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@solid-devtools/overlay": "^0.33.5",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"solid-devtools": "^0.34.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
"vite": "^6.0.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
|
||||
import { Folder, FolderLock, FolderSearch, X } from "lucide-solid";
|
||||
|
||||
import { 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
|
||||
? preservedCount === 1
|
||||
? t("context_panel.preserving_entry")
|
||||
: t("context_panel.preserving_entries", undefined, { count: preservedCount })
|
||||
: 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 t("context_panel.server_disconnected");
|
||||
if (!openworkServerWorkspaceReady()) return t("context_panel.no_server_workspace");
|
||||
if (!canReadConfig()) {
|
||||
return t("context_panel.config_access_unavailable");
|
||||
}
|
||||
if (!canWriteConfig()) {
|
||||
return t("context_panel.config_read_only");
|
||||
}
|
||||
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(
|
||||
t("context_panel.writable_workspace_required"),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setAuthorizedFoldersSaving(true);
|
||||
setAuthorizedFoldersError(null);
|
||||
setAuthorizedFoldersStatus(t("context_panel.saving_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,
|
||||
t("context_panel.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(t("context_panel.workspace_root_available"));
|
||||
setAuthorizedFoldersError(null);
|
||||
return;
|
||||
}
|
||||
if (authorizedFolders().includes(normalized)) {
|
||||
setAuthorizedFolderDraft("");
|
||||
setAuthorizedFoldersStatus(t("context_panel.folder_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"),
|
||||
});
|
||||
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(t("context_panel.workspace_root_available"));
|
||||
setAuthorizedFoldersError(null);
|
||||
return;
|
||||
}
|
||||
if (authorizedFolders().includes(normalized)) {
|
||||
setAuthorizedFoldersStatus(t("context_panel.folder_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" />
|
||||
{t("context_panel.authorized_folders")}
|
||||
</div>
|
||||
<div class="text-xs text-gray-9 leading-relaxed max-w-[65ch]">
|
||||
{t("context_panel.authorized_folders_desc")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={canReadConfig()}
|
||||
fallback={
|
||||
<div class={`${softPanelClass} px-3 py-3 text-xs text-gray-10`}>
|
||||
{authorizedFoldersHint() ??
|
||||
t("context_panel.authorized_folders_no_access")}
|
||||
</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">{t("context_panel.no_external_folders")}</div>
|
||||
<div class="text-[11px] text-gray-9 mt-1 max-w-[40ch]">
|
||||
{t("context_panel.add_folder_hint")}
|
||||
</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">
|
||||
{t("context_panel.workspace_root_badge")}
|
||||
</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">
|
||||
{t("context_panel.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={t("context_panel.remove_folder", undefined, { name: 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={t("context_panel.input_placeholder")}
|
||||
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" /> {t("context_panel.browse_button")}
|
||||
</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() ? t("context_panel.adding_button") : t("context_panel.add_button")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useLocal } from "../context/local";
|
||||
|
||||
export function useFeatureFlagsPreferences() {
|
||||
const { prefs, setPrefs } = useLocal();
|
||||
|
||||
const microsandboxCreateSandboxEnabled = () =>
|
||||
prefs.featureFlags?.microsandboxCreateSandbox === true;
|
||||
|
||||
const toggleMicrosandboxCreateSandbox = () => {
|
||||
setPrefs("featureFlags", "microsandboxCreateSandbox", (current) => !current);
|
||||
};
|
||||
|
||||
return {
|
||||
microsandboxCreateSandboxEnabled,
|
||||
toggleMicrosandboxCreateSandbox,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
import { Boxes, ChevronDown, ChevronRight, Plus, Sparkles, X } from "lucide-solid";
|
||||
|
||||
import type { BundleWorkerOption } from "./types";
|
||||
|
||||
export default function BundleImportModal(props: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
workers: BundleWorkerOption[];
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
onCreateNewWorker: () => void;
|
||||
onSelectWorker: (workspaceId: string) => void;
|
||||
}) {
|
||||
const [showWorkers, setShowWorkers] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
setShowWorkers(false);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
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 busy = () => Boolean(props.busy);
|
||||
|
||||
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-2xl border border-gray-6 bg-gray-2 shadow-2xl">
|
||||
<div class="border-b border-gray-6 bg-gray-1 px-6 py-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-indigo-9/15 text-indigo-11">
|
||||
<Boxes size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-12">{props.title}</h3>
|
||||
<p class="mt-1 text-sm leading-relaxed text-gray-10">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={busy()}
|
||||
class="rounded-full p-1 text-gray-10 transition hover:bg-gray-4 hover:text-gray-12 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-gray-6 bg-gray-3 px-3 py-1 text-xs font-medium text-gray-11">
|
||||
{item}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={hiddenItemCount() > 0}>
|
||||
<span class="rounded-full border border-gray-6 bg-gray-3 px-3 py-1 text-xs font-medium text-gray-11">
|
||||
+{hiddenItemCount()} more
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
<Show when={props.error?.trim()}>
|
||||
<div class="rounded-xl border border-red-6 bg-red-2 px-4 py-3 text-sm text-red-11">{props.error}</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onCreateNewWorker}
|
||||
disabled={busy()}
|
||||
class="flex w-full items-center justify-between rounded-2xl border border-indigo-7/30 bg-indigo-9/10 px-4 py-4 text-left transition hover:border-indigo-7/50 hover:bg-indigo-9/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-9/20 text-indigo-11">
|
||||
<Plus size={18} />
|
||||
</div>
|
||||
<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>
|
||||
<Sparkles size={18} class="text-indigo-11" />
|
||||
</button>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/70">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWorkers((value) => !value)}
|
||||
disabled={busy()}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-4 text-left transition hover:bg-gray-3/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-expanded={showWorkers()}
|
||||
>
|
||||
<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 this bundle there.</div>
|
||||
</div>
|
||||
<Show when={showWorkers()} fallback={<ChevronRight size={18} class="text-gray-10" />}>
|
||||
<ChevronDown size={18} class="text-gray-10" />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={showWorkers()}>
|
||||
<div class="space-y-3 border-t border-gray-6 px-4 py-4">
|
||||
<Show
|
||||
when={props.workers.length > 0}
|
||||
fallback={<div class="rounded-xl border border-dashed border-gray-6 px-4 py-5 text-sm text-gray-10">No configured workers are available yet. Create a new worker to import this bundle.</div>}
|
||||
>
|
||||
<For each={props.workers}>
|
||||
{(worker) => {
|
||||
const disabledReason = () => worker.disabledReason?.trim() ?? "";
|
||||
const disabled = () => Boolean(disabledReason()) || busy();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelectWorker(worker.id)}
|
||||
disabled={disabled()}
|
||||
class="w-full rounded-xl border border-gray-6 bg-gray-2 px-4 py-3 text-left transition hover:border-gray-7 hover:bg-gray-3 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-gray-12">{worker.label}</span>
|
||||
<span class="rounded-full border border-gray-6 bg-gray-3 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-gray-10">
|
||||
{worker.badge}
|
||||
</span>
|
||||
<Show when={worker.current}>
|
||||
<span class="rounded-full border border-emerald-7/40 bg-emerald-9/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-emerald-11">
|
||||
Current
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-sm text-gray-10">{worker.detail}</div>
|
||||
<Show when={disabledReason()}>
|
||||
<div class="mt-2 text-xs text-amber-11">{disabledReason()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<ChevronRight size={18} class="mt-0.5 shrink-0 text-gray-10" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -2,5 +2,4 @@ export * from "./apply";
|
||||
export * from "./publish";
|
||||
export * from "./schema";
|
||||
export * from "./sources";
|
||||
export * from "./store";
|
||||
export * from "./types";
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { CheckCircle2, Folder, FolderPlus, Globe, Loader2, Sparkles, X } from "lucide-solid";
|
||||
import type { WorkspaceInfo } from "../lib/tauri";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
import { isSandboxWorkspace } from "../utils";
|
||||
|
||||
import Button from "../components/button";
|
||||
|
||||
type SkillSummary = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
trigger?: string | null;
|
||||
};
|
||||
|
||||
export default function SkillDestinationModal(props: {
|
||||
open: boolean;
|
||||
skill: SkillSummary | null;
|
||||
workspaces: WorkspaceInfo[];
|
||||
selectedWorkspaceId?: string | null;
|
||||
busyWorkspaceId?: string | null;
|
||||
onClose: () => void;
|
||||
onSubmitWorkspace: (workspaceId: string) => void | Promise<void>;
|
||||
onCreateWorker?: () => void;
|
||||
onConnectRemote?: () => void;
|
||||
}) {
|
||||
const translate = (key: string) => t(key, currentLocale());
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal<string | null>(null);
|
||||
|
||||
const displayName = (workspace: WorkspaceInfo) =>
|
||||
workspace.displayName?.trim() ||
|
||||
workspace.openworkWorkspaceName?.trim() ||
|
||||
workspace.name?.trim() ||
|
||||
workspace.directory?.trim() ||
|
||||
workspace.path?.trim() ||
|
||||
workspace.baseUrl?.trim() ||
|
||||
"Worker";
|
||||
|
||||
const subtitle = (workspace: WorkspaceInfo) => {
|
||||
if (workspace.workspaceType === "local") {
|
||||
return workspace.path?.trim() || translate("share_skill_destination.local_badge");
|
||||
}
|
||||
return (
|
||||
workspace.directory?.trim() ||
|
||||
workspace.openworkHostUrl?.trim() ||
|
||||
workspace.baseUrl?.trim() ||
|
||||
workspace.path?.trim() ||
|
||||
translate("share_skill_destination.remote_badge")
|
||||
);
|
||||
};
|
||||
|
||||
const workspaceBadge = (workspace: WorkspaceInfo) => {
|
||||
if (isSandboxWorkspace(workspace)) {
|
||||
return translate("share_skill_destination.sandbox_badge");
|
||||
}
|
||||
if (workspace.workspaceType === "remote") {
|
||||
return translate("share_skill_destination.remote_badge");
|
||||
}
|
||||
return translate("share_skill_destination.local_badge");
|
||||
};
|
||||
|
||||
const footerBusy = () => Boolean(props.busyWorkspaceId?.trim());
|
||||
const selectedWorkspace = createMemo(() => props.workspaces.find((workspace) => workspace.id === selectedWorkspaceId()) ?? null);
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
const activeMatch = props.workspaces.find((workspace) => workspace.id === props.selectedWorkspaceId) ?? props.workspaces[0] ?? null;
|
||||
setSelectedWorkspaceId(activeMatch?.id ?? null);
|
||||
});
|
||||
|
||||
const submitSelectedWorkspace = () => {
|
||||
const workspaceId = selectedWorkspaceId()?.trim();
|
||||
if (!workspaceId || footerBusy()) return;
|
||||
void props.onSubmitWorkspace(workspaceId);
|
||||
};
|
||||
|
||||
const workspaceCircleClass = (workspace: WorkspaceInfo, selected: boolean) => {
|
||||
if (selected) {
|
||||
return "bg-indigo-7/15 text-indigo-11 border border-indigo-7/30";
|
||||
}
|
||||
if (isSandboxWorkspace(workspace)) {
|
||||
return "bg-indigo-7/10 text-indigo-11 border border-indigo-7/20";
|
||||
}
|
||||
if (workspace.workspaceType === "remote") {
|
||||
return "bg-sky-7/10 text-sky-11 border border-sky-7/20";
|
||||
}
|
||||
return "bg-amber-7/10 text-amber-11 border border-amber-7/20";
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-12/40 p-4 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-gray-6 bg-gray-1 shadow-2xl">
|
||||
<div class="border-b border-gray-6 bg-gray-1 px-6 py-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 space-y-3">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-gray-6 bg-gray-2 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-10">
|
||||
<Sparkles size={12} />
|
||||
{translate("share_skill_destination.skill_label")}
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-6 bg-gray-2/40 px-4 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-indigo-7/20 bg-indigo-7/10 text-indigo-11">
|
||||
<Sparkles size={17} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-9">
|
||||
{translate("share_skill_destination.skill_label")}
|
||||
</div>
|
||||
<h3 class="mt-1 text-lg font-semibold text-gray-12 break-words">
|
||||
{props.skill?.name ?? translate("share_skill_destination.fallback_skill_name")}
|
||||
</h3>
|
||||
<Show when={props.skill?.description?.trim()}>
|
||||
<p class="mt-1 text-sm leading-relaxed text-gray-10 break-words">{props.skill?.description?.trim()}</p>
|
||||
</Show>
|
||||
<Show when={props.skill?.trigger?.trim()}>
|
||||
<div class="mt-3 inline-flex items-center gap-2 rounded-full border border-gray-6 bg-gray-1 px-3 py-1 text-[11px] text-gray-10">
|
||||
<span class="font-semibold text-gray-12">{translate("share_skill_destination.trigger_label")}</span>
|
||||
<span class="font-mono">{props.skill?.trigger?.trim()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-12">{translate("share_skill_destination.title")}</h4>
|
||||
<p class="mt-1 text-sm leading-relaxed text-gray-10">{translate("share_skill_destination.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
disabled={footerBusy()}
|
||||
class={`rounded-full p-2 text-gray-9 transition hover:bg-gray-2 hover:text-gray-12 ${footerBusy() ? "cursor-not-allowed opacity-50" : ""}`.trim()}
|
||||
aria-label={translate("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-gray-12">{translate("share_skill_destination.existing_workers")}</div>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<span class="text-[11px] uppercase tracking-[0.18em] text-gray-9">{props.workspaces.length}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<div class="rounded-xl border border-dashed border-gray-6 bg-gray-2/20 px-4 py-5 text-sm leading-relaxed text-gray-10">
|
||||
{translate("share_skill_destination.no_workers")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => {
|
||||
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} />);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedWorkspaceId(workspace.id)}
|
||||
disabled={footerBusy()}
|
||||
aria-pressed={isSelected()}
|
||||
class={`w-full rounded-xl border text-left transition-colors ${
|
||||
isSelected()
|
||||
? "border-indigo-7/40 bg-indigo-2/20"
|
||||
: "border-gray-6/40 bg-transparent hover:border-gray-7/50 hover:bg-gray-2"
|
||||
} ${footerBusy() ? "cursor-wait opacity-70" : ""}`.trim()}
|
||||
>
|
||||
<div class="flex items-start gap-3 px-4 py-3">
|
||||
<div class={`mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${workspaceCircleClass(workspace, isSelected())}`.trim()}>
|
||||
<WorkspaceIcon />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-semibold text-gray-12 break-words">{displayName(workspace)}</div>
|
||||
<Show when={isActive()}>
|
||||
<span class="rounded-full bg-gray-3 px-2 py-0.5 text-[10px] uppercase tracking-[0.18em] text-gray-11">
|
||||
{translate("share_skill_destination.current_badge")}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="rounded-full bg-gray-3 px-2 py-0.5 text-[10px] uppercase tracking-[0.18em] text-gray-11">
|
||||
{workspaceBadge(workspace)}
|
||||
</span>
|
||||
<Show when={isSelected()}>
|
||||
<span class="rounded-full bg-indigo-3/60 px-2 py-0.5 text-[10px] uppercase tracking-[0.18em] text-indigo-11">
|
||||
{translate("share_skill_destination.selected_badge")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-[11px] font-mono break-all text-gray-8/80">{subtitle(workspace)}</div>
|
||||
<Show when={isSelected()}>
|
||||
<div class="mt-2 text-xs font-medium text-gray-11">{translate("share_skill_destination.selected_hint")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 pt-0.5 text-gray-9">
|
||||
<Show when={isBusy()} fallback={<CheckCircle2 size={16} class={isSelected() ? "text-indigo-11" : "text-gray-7"} />}>
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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) => (
|
||||
<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 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>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,885 +0,0 @@
|
||||
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 { t } from "../../i18n";
|
||||
import { isSandboxWorkspace, 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() || t("app.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: t("app.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() ||
|
||||
t("app.worker_fallback");
|
||||
const badge =
|
||||
workspace.workspaceType === "remote"
|
||||
? isSandboxWorkspace(workspace)
|
||||
? t("workspace.sandbox_badge")
|
||||
: t("workspace.remote_badge")
|
||||
: t("workspace.local_badge");
|
||||
const detail =
|
||||
workspace.workspaceType === "local"
|
||||
? workspace.path?.trim() || t("app.local_worker_detail")
|
||||
: workspace.directory?.trim() || workspace.baseUrl?.trim() || workspace.openworkHostUrl?.trim() || t("app.remote_worker_detail");
|
||||
|
||||
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: t("app.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: t("app.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,
|
||||
};
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { createContext, createMemo, createSignal, onCleanup, onMount, useContext, type Accessor, type ParentProps } from "solid-js";
|
||||
import { clearDenSession, createDenClient, DenApiError, ensureDenActiveOrganization, readDenSettings, type DenUser } from "../lib/den";
|
||||
import { denSessionUpdatedEvent } from "../lib/den-session-events";
|
||||
import { recordDevLog } from "../lib/dev-log";
|
||||
|
||||
type DenAuthStatus = "checking" | "signed_in" | "signed_out";
|
||||
|
||||
type DenAuthStore = {
|
||||
status: Accessor<DenAuthStatus>;
|
||||
user: Accessor<DenUser | null>;
|
||||
error: Accessor<string | null>;
|
||||
isSignedIn: Accessor<boolean>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DenAuthContext = createContext<DenAuthStore>();
|
||||
|
||||
function logDenAuth(label: string, payload?: unknown) {
|
||||
try {
|
||||
recordDevLog(true, { level: "debug", source: "den-auth", label, payload });
|
||||
if (payload === undefined) {
|
||||
console.log(`[DEN-AUTH] ${label}`);
|
||||
} else {
|
||||
console.log(`[DEN-AUTH] ${label}`, payload);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function DenAuthProvider(props: ParentProps) {
|
||||
const [status, setStatus] = createSignal<DenAuthStatus>("checking");
|
||||
const [user, setUser] = createSignal<DenUser | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
let refreshToken = 0;
|
||||
|
||||
const refresh = async () => {
|
||||
const currentRun = ++refreshToken;
|
||||
const settings = readDenSettings();
|
||||
const token = settings.authToken?.trim() ?? "";
|
||||
|
||||
logDenAuth("refresh-start", {
|
||||
currentRun,
|
||||
hasToken: Boolean(token),
|
||||
activeOrgId: settings.activeOrgId ?? null,
|
||||
activeOrgSlug: settings.activeOrgSlug ?? null,
|
||||
baseUrl: settings.baseUrl,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setError(null);
|
||||
setStatus("signed_out");
|
||||
logDenAuth("refresh-signed-out-no-token", { currentRun });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("checking");
|
||||
logDenAuth("refresh-status-checking", { currentRun });
|
||||
|
||||
try {
|
||||
const nextUser = await createDenClient({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
token,
|
||||
}).getSession();
|
||||
|
||||
if (currentRun !== refreshToken) {
|
||||
logDenAuth("refresh-stale-after-session", { currentRun, refreshToken });
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureDenActiveOrganization({
|
||||
forceServerSync:
|
||||
!settings.activeOrgId?.trim() ||
|
||||
!settings.activeOrgSlug?.trim(),
|
||||
}).catch(() => null);
|
||||
|
||||
if (currentRun !== refreshToken) {
|
||||
logDenAuth("refresh-stale-after-org-sync", { currentRun, refreshToken });
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextUser);
|
||||
setError(null);
|
||||
setStatus("signed_in");
|
||||
logDenAuth("refresh-signed-in", {
|
||||
currentRun,
|
||||
userId: nextUser.id,
|
||||
activeOrgId: readDenSettings().activeOrgId ?? null,
|
||||
activeOrgSlug: readDenSettings().activeOrgSlug ?? null,
|
||||
});
|
||||
} catch (nextError) {
|
||||
if (currentRun !== refreshToken) {
|
||||
logDenAuth("refresh-stale-after-error", { currentRun, refreshToken });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextError instanceof DenApiError && nextError.status === 401) {
|
||||
clearDenSession();
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setError(nextError instanceof Error ? nextError.message : "Failed to restore OpenWork Cloud session.");
|
||||
setStatus("signed_out");
|
||||
logDenAuth("refresh-error", {
|
||||
currentRun,
|
||||
error: nextError instanceof Error ? nextError.message : String(nextError),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void refresh();
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSessionUpdated = () => {
|
||||
logDenAuth("session-updated-event");
|
||||
void refresh();
|
||||
};
|
||||
|
||||
window.addEventListener(denSessionUpdatedEvent, handleSessionUpdated);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener(denSessionUpdatedEvent, handleSessionUpdated);
|
||||
});
|
||||
});
|
||||
|
||||
const store: DenAuthStore = {
|
||||
status,
|
||||
user,
|
||||
error,
|
||||
isSignedIn: createMemo(() => status() === "signed_in"),
|
||||
refresh,
|
||||
};
|
||||
|
||||
return (
|
||||
<DenAuthContext.Provider value={store}>
|
||||
{props.children}
|
||||
</DenAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDenAuth() {
|
||||
const context = useContext(DenAuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useDenAuth must be used within a DenAuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { ArrowUpRight, Cloud } from "lucide-solid";
|
||||
import { Show } from "solid-js";
|
||||
import { currentLocale, t } from "../../i18n";
|
||||
import { DEFAULT_DEN_BASE_URL } from "../lib/den";
|
||||
import Button from "../components/button";
|
||||
import TextInput from "../components/text-input";
|
||||
|
||||
type DenSignInSurfaceProps = {
|
||||
variant?: "panel" | "fullscreen";
|
||||
developerMode: boolean;
|
||||
baseUrl: string;
|
||||
baseUrlDraft: string;
|
||||
baseUrlError: string | null;
|
||||
statusMessage: string | null;
|
||||
authError: string | null;
|
||||
authBusy: boolean;
|
||||
baseUrlBusy: boolean;
|
||||
sessionBusy: boolean;
|
||||
manualAuthOpen: boolean;
|
||||
manualAuthInput: string;
|
||||
onBaseUrlDraftInput: (value: string) => void;
|
||||
onResetBaseUrl: () => void;
|
||||
onApplyBaseUrl: () => void;
|
||||
onOpenControlPlane: () => void;
|
||||
onOpenBrowserAuth: (mode: "sign-in" | "sign-up") => void;
|
||||
onToggleManualAuth: () => void;
|
||||
onManualAuthInput: (value: string) => void;
|
||||
onSubmitManualAuth: () => void;
|
||||
};
|
||||
|
||||
export default function DenSignInSurface(props: DenSignInSurfaceProps) {
|
||||
const tr = (key: string) => t(key, currentLocale());
|
||||
const variant = () => props.variant ?? "panel";
|
||||
const settingsPanelClass = "ow-soft-card rounded-[28px] p-5 md:p-6";
|
||||
const settingsPanelSoftClass = "ow-soft-card-quiet rounded-2xl p-4";
|
||||
const headerBadgeClass =
|
||||
"inline-flex min-h-8 items-center gap-2 rounded-xl border border-dls-border bg-dls-hover px-3 text-[13px] font-medium text-dls-text shadow-sm";
|
||||
const softNoticeClass =
|
||||
"rounded-xl border border-dls-border bg-dls-hover px-3 py-2 text-xs text-dls-secondary";
|
||||
|
||||
const content = (
|
||||
<div class={`${settingsPanelClass} space-y-4`}>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class={headerBadgeClass}>
|
||||
<Cloud size={13} class="text-dls-secondary" />
|
||||
{tr("den.cloud_section_title")}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-dls-text">
|
||||
{tr("den.signin_title")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.developerMode}>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<TextInput
|
||||
label={tr("den.cloud_control_plane_url_label")}
|
||||
value={props.baseUrlDraft}
|
||||
onInput={(event) =>
|
||||
props.onBaseUrlDraftInput(event.currentTarget.value)
|
||||
}
|
||||
placeholder={DEFAULT_DEN_BASE_URL}
|
||||
hint={tr("den.cloud_control_plane_url_hint")}
|
||||
disabled={props.authBusy || props.baseUrlBusy || props.sessionBusy}
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 px-3 text-xs"
|
||||
onClick={props.onResetBaseUrl}
|
||||
disabled={
|
||||
props.authBusy || props.baseUrlBusy || props.sessionBusy
|
||||
}
|
||||
>
|
||||
{tr("den.cloud_control_plane_reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-9 px-3 text-xs"
|
||||
onClick={props.onApplyBaseUrl}
|
||||
disabled={
|
||||
props.authBusy || props.baseUrlBusy || props.sessionBusy
|
||||
}
|
||||
>
|
||||
{tr("den.cloud_control_plane_save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 px-3 text-xs"
|
||||
onClick={props.onOpenControlPlane}
|
||||
>
|
||||
{tr("den.cloud_control_plane_open")}
|
||||
<ArrowUpRight size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.baseUrlError}>
|
||||
{(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={props.statusMessage && !props.authError}>
|
||||
{(value) => <div class={softNoticeClass}>{value()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="max-w-[54ch] text-sm text-dls-secondary">
|
||||
{tr("den.auto_reconnect_hint")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => props.onOpenBrowserAuth("sign-in")}
|
||||
>
|
||||
{tr("den.signin_button")}
|
||||
<ArrowUpRight size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 px-3 text-xs"
|
||||
onClick={() => props.onOpenBrowserAuth("sign-up")}
|
||||
>
|
||||
{tr("den.create_account")}
|
||||
<ArrowUpRight size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 px-3 text-xs"
|
||||
onClick={props.onToggleManualAuth}
|
||||
disabled={props.authBusy || props.sessionBusy}
|
||||
>
|
||||
{props.manualAuthOpen
|
||||
? tr("den.hide_signin_code")
|
||||
: tr("den.paste_signin_code")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={props.manualAuthOpen}>
|
||||
<div class={`${settingsPanelSoftClass} space-y-3`}>
|
||||
<TextInput
|
||||
label={tr("den.signin_link_label")}
|
||||
value={props.manualAuthInput}
|
||||
onInput={(event) =>
|
||||
props.onManualAuthInput(event.currentTarget.value)
|
||||
}
|
||||
placeholder={tr("den.signin_link_placeholder")}
|
||||
disabled={props.authBusy || props.sessionBusy}
|
||||
hint={tr("den.signin_link_hint")}
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-9 px-3 text-xs"
|
||||
onClick={props.onSubmitManualAuth}
|
||||
disabled={
|
||||
props.authBusy ||
|
||||
props.sessionBusy ||
|
||||
!props.manualAuthInput.trim()
|
||||
}
|
||||
>
|
||||
{props.authBusy ? tr("den.finishing") : tr("den.finish_signin")}
|
||||
</Button>
|
||||
<div class="text-[11px] text-dls-secondary">
|
||||
{tr("den.signin_code_note")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.authError}>
|
||||
{(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>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant() === "fullscreen") {
|
||||
return (
|
||||
<div class="min-h-screen bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,1),rgba(241,245,249,0.92))] px-6 py-10 text-dls-text">
|
||||
<div class="mx-auto flex min-h-[calc(100vh-5rem)] max-w-3xl items-center justify-center">
|
||||
<div class="w-full space-y-4">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { createContext, createEffect, createSignal, onCleanup, onMount, useContext, type Accessor, type ParentProps } from "solid-js";
|
||||
import { createDenClient, DenApiError, ensureDenActiveOrganization, type DenDesktopConfig, normalizeDenDesktopConfig, readDenSettings } from "../lib/den";
|
||||
import { denSessionUpdatedEvent, denSettingsChangedEvent } from "../lib/den-session-events";
|
||||
import { useDenAuth } from "./den-auth-provider";
|
||||
import { checkDesktopAppRestriction, type DesktopAppRestrictionChecker } from "./desktop-app-restrictions";
|
||||
|
||||
type DesktopConfigStore = {
|
||||
config: Accessor<DenDesktopConfig>;
|
||||
loading: Accessor<boolean>;
|
||||
refresh: () => Promise<void>;
|
||||
checkRestriction: DesktopAppRestrictionChecker;
|
||||
};
|
||||
|
||||
const DesktopConfigContext = createContext<DesktopConfigStore>();
|
||||
|
||||
const DEFAULT_DESKTOP_CONFIG: DenDesktopConfig = {};
|
||||
const DESKTOP_CONFIG_REFRESH_MS = 60 * 60 * 1000;
|
||||
const DESKTOP_CONFIG_CACHE_PREFIX = "openwork.den.desktopConfig:";
|
||||
|
||||
function getDesktopConfigCacheKey() {
|
||||
const settings = readDenSettings();
|
||||
const baseUrl = settings.baseUrl.trim();
|
||||
const activeOrgId = settings.activeOrgId?.trim() ?? "";
|
||||
if (!baseUrl) return "";
|
||||
return `${DESKTOP_CONFIG_CACHE_PREFIX}${baseUrl}::${activeOrgId}`;
|
||||
}
|
||||
|
||||
function readCachedDesktopConfig(key: string): DenDesktopConfig | null {
|
||||
if (typeof window === "undefined" || !key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return normalizeDenDesktopConfig(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedDesktopConfig(key: string, config: DenDesktopConfig) {
|
||||
if (typeof window === "undefined" || !key) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(normalizeDenDesktopConfig(config)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function DesktopConfigProvider(props: ParentProps) {
|
||||
const denAuth = useDenAuth();
|
||||
const [config, setConfig] = createSignal<DenDesktopConfig>(DEFAULT_DESKTOP_CONFIG);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [settingsVersion, setSettingsVersion] = createSignal(0);
|
||||
let refreshRunId = 0;
|
||||
|
||||
const refresh = async () => {
|
||||
const currentRun = ++refreshRunId;
|
||||
const settings = readDenSettings();
|
||||
const token = settings.authToken?.trim() ?? "";
|
||||
const cacheKey = getDesktopConfigCacheKey();
|
||||
|
||||
if (!denAuth.isSignedIn() || !token || !settings.activeOrgId?.trim()) {
|
||||
setConfig(DEFAULT_DESKTOP_CONFIG);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readCachedDesktopConfig(cacheKey);
|
||||
if (!cached) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const nextConfig = await createDenClient({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
token,
|
||||
}).getDesktopConfig();
|
||||
|
||||
if (currentRun !== refreshRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeCachedDesktopConfig(cacheKey, nextConfig);
|
||||
setConfig(nextConfig);
|
||||
} catch (error) {
|
||||
if (currentRun !== refreshRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof DenApiError &&
|
||||
error.status === 404 &&
|
||||
error.code === "organization_not_found"
|
||||
) {
|
||||
await ensureDenActiveOrganization({ forceServerSync: true }).catch(() => null);
|
||||
}
|
||||
|
||||
setConfig(cached ?? DEFAULT_DESKTOP_CONFIG);
|
||||
} finally {
|
||||
if (currentRun === refreshRunId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
settingsVersion();
|
||||
|
||||
if (!denAuth.isSignedIn()) {
|
||||
setConfig(DEFAULT_DESKTOP_CONFIG);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = getDesktopConfigCacheKey();
|
||||
const cached = readCachedDesktopConfig(cacheKey);
|
||||
setConfig(cached ?? DEFAULT_DESKTOP_CONFIG);
|
||||
setLoading(!cached);
|
||||
void refresh();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSettingsChanged = () => {
|
||||
setSettingsVersion((value) => value + 1);
|
||||
};
|
||||
|
||||
window.addEventListener(denSessionUpdatedEvent, handleSettingsChanged);
|
||||
window.addEventListener(denSettingsChangedEvent, handleSettingsChanged);
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
if (!denAuth.isSignedIn()) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, DESKTOP_CONFIG_REFRESH_MS);
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener(denSessionUpdatedEvent, handleSettingsChanged);
|
||||
window.removeEventListener(denSettingsChangedEvent, handleSettingsChanged);
|
||||
window.clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
const store: DesktopConfigStore = {
|
||||
config,
|
||||
loading,
|
||||
refresh,
|
||||
checkRestriction(input) {
|
||||
return checkDesktopAppRestriction({
|
||||
config: config(),
|
||||
restriction: input.restriction,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<DesktopConfigContext.Provider value={store}>
|
||||
{props.children}
|
||||
</DesktopConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDesktopConfig() {
|
||||
const context = useContext(DesktopConfigContext);
|
||||
if (!context) {
|
||||
throw new Error("useDesktopConfig must be used within a DesktopConfigProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||
import DenSignInSurface from "./den-signin-surface";
|
||||
import { useDenAuth } from "./den-auth-provider";
|
||||
import { useDesktopConfig } from "./desktop-config-provider";
|
||||
import { usePlatform } from "../context/platform";
|
||||
import { currentLocale, t } from "../../i18n";
|
||||
import {
|
||||
buildDenAuthUrl,
|
||||
clearDenSession,
|
||||
createDenClient,
|
||||
DEFAULT_DEN_BASE_URL,
|
||||
normalizeDenBaseUrl,
|
||||
readDenBootstrapConfig,
|
||||
readDenSettings,
|
||||
resolveDenBaseUrls,
|
||||
setDenBootstrapConfig,
|
||||
writeDenSettings,
|
||||
} from "../lib/den";
|
||||
import { denSessionUpdatedEvent, dispatchDenSessionUpdated, type DenSessionUpdatedDetail } from "../lib/den-session-events";
|
||||
|
||||
type ForcedSigninPageProps = {
|
||||
developerMode: boolean;
|
||||
};
|
||||
|
||||
export default function ForcedSigninPage(props: ForcedSigninPageProps) {
|
||||
const platform = usePlatform();
|
||||
const denAuth = useDenAuth();
|
||||
const desktopConfig = useDesktopConfig();
|
||||
const initial = readDenSettings();
|
||||
const initialBaseUrl = initial.baseUrl || DEFAULT_DEN_BASE_URL;
|
||||
|
||||
const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl);
|
||||
const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl);
|
||||
const [baseUrlError, setBaseUrlError] = createSignal<string | null>(null);
|
||||
const [authBusy, setAuthBusy] = createSignal(false);
|
||||
const [baseUrlBusy, setBaseUrlBusy] = createSignal(false);
|
||||
const [manualAuthOpen, setManualAuthOpen] = createSignal(false);
|
||||
const [manualAuthInput, setManualAuthInput] = createSignal("");
|
||||
const [authError, setAuthError] = createSignal<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = createSignal<string | null>(null);
|
||||
|
||||
const openControlPlane = () => {
|
||||
platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl);
|
||||
};
|
||||
|
||||
const openBrowserAuth = (mode: "sign-in" | "sign-up") => {
|
||||
platform.openLink(buildDenAuthUrl(baseUrl(), mode));
|
||||
setStatusMessage(
|
||||
mode === "sign-up"
|
||||
? t("den.status_browser_signup", currentLocale())
|
||||
: t("den.status_browser_signin", currentLocale()),
|
||||
);
|
||||
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(t("den.error_paste_valid_code", currentLocale()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBaseUrl = parsed.baseUrl ?? baseUrl();
|
||||
|
||||
setAuthBusy(true);
|
||||
setAuthError(null);
|
||||
setStatusMessage(t("den.signing_in", currentLocale()));
|
||||
|
||||
try {
|
||||
const result = await createDenClient({ baseUrl: nextBaseUrl }).exchangeDesktopHandoff(parsed.grant);
|
||||
if (!result.token) {
|
||||
throw new Error(t("den.error_no_token", currentLocale()));
|
||||
}
|
||||
|
||||
if (props.developerMode) {
|
||||
setBaseUrl(nextBaseUrl);
|
||||
setBaseUrlDraft(nextBaseUrl);
|
||||
}
|
||||
|
||||
writeDenSettings({
|
||||
baseUrl: nextBaseUrl,
|
||||
authToken: result.token,
|
||||
activeOrgId: null,
|
||||
activeOrgSlug: null,
|
||||
activeOrgName: null,
|
||||
});
|
||||
|
||||
setManualAuthInput("");
|
||||
setManualAuthOpen(false);
|
||||
dispatchDenSessionUpdated({
|
||||
status: "success",
|
||||
baseUrl: nextBaseUrl,
|
||||
token: result.token,
|
||||
user: result.user,
|
||||
email: result.user?.email ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchDenSessionUpdated({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : t("den.error_signin_failed", currentLocale()),
|
||||
});
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyBaseUrl = async () => {
|
||||
const normalized = normalizeDenBaseUrl(baseUrlDraft());
|
||||
if (!normalized) {
|
||||
setBaseUrlError(t("den.error_base_url", currentLocale()));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveDenBaseUrls(normalized);
|
||||
setBaseUrlBusy(true);
|
||||
|
||||
try {
|
||||
await setDenBootstrapConfig({
|
||||
baseUrl: resolved.baseUrl,
|
||||
apiBaseUrl: resolved.apiBaseUrl,
|
||||
requireSignin: readDenBootstrapConfig().requireSignin,
|
||||
});
|
||||
setBaseUrlError(null);
|
||||
setBaseUrl(resolved.baseUrl);
|
||||
setBaseUrlDraft(resolved.baseUrl);
|
||||
clearDenSession({ includeBaseUrls: !props.developerMode });
|
||||
writeDenSettings({
|
||||
baseUrl: resolved.baseUrl,
|
||||
apiBaseUrl: resolved.apiBaseUrl,
|
||||
authToken: null,
|
||||
activeOrgId: null,
|
||||
activeOrgSlug: null,
|
||||
activeOrgName: null,
|
||||
}, { persistBootstrap: false });
|
||||
setAuthError(null);
|
||||
setStatusMessage(t("den.status_base_url_updated", currentLocale()));
|
||||
void desktopConfig.refresh();
|
||||
void denAuth.refresh();
|
||||
} catch (error) {
|
||||
setBaseUrlError(
|
||||
error instanceof Error ? error.message : t("den.error_base_url", currentLocale()),
|
||||
);
|
||||
} finally {
|
||||
setBaseUrlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<DenSessionUpdatedDetail>;
|
||||
const nextSettings = readDenSettings();
|
||||
const nextBaseUrl =
|
||||
customEvent.detail?.baseUrl?.trim() ||
|
||||
nextSettings.baseUrl ||
|
||||
DEFAULT_DEN_BASE_URL;
|
||||
setBaseUrl(nextBaseUrl);
|
||||
setBaseUrlDraft(nextBaseUrl);
|
||||
if (customEvent.detail?.status === "success") {
|
||||
setAuthError(null);
|
||||
setStatusMessage(
|
||||
customEvent.detail.email?.trim()
|
||||
? t("den.status_cloud_signed_in_as", currentLocale(), { email: customEvent.detail.email.trim() })
|
||||
: t("den.status_cloud_signin_done", currentLocale()),
|
||||
);
|
||||
} else if (customEvent.detail?.status === "error") {
|
||||
setAuthError(
|
||||
customEvent.detail.message?.trim() ||
|
||||
t("den.error_signin_failed", currentLocale()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(denSessionUpdatedEvent, handler as EventListener);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener(denSessionUpdatedEvent, handler as EventListener);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<DenSignInSurface
|
||||
variant="fullscreen"
|
||||
developerMode={props.developerMode}
|
||||
baseUrl={baseUrl()}
|
||||
baseUrlDraft={baseUrlDraft()}
|
||||
baseUrlError={baseUrlError()}
|
||||
statusMessage={statusMessage()}
|
||||
authError={authError() ?? denAuth.error()}
|
||||
authBusy={authBusy()}
|
||||
baseUrlBusy={baseUrlBusy()}
|
||||
sessionBusy={denAuth.status() === "checking"}
|
||||
manualAuthOpen={manualAuthOpen()}
|
||||
manualAuthInput={manualAuthInput()}
|
||||
onBaseUrlDraftInput={setBaseUrlDraft}
|
||||
onResetBaseUrl={() => setBaseUrlDraft(baseUrl())}
|
||||
onApplyBaseUrl={() => {
|
||||
void applyBaseUrl();
|
||||
}}
|
||||
onOpenControlPlane={openControlPlane}
|
||||
onOpenBrowserAuth={openBrowserAuth}
|
||||
onToggleManualAuth={() => {
|
||||
setManualAuthOpen((value) => !value);
|
||||
setAuthError(null);
|
||||
}}
|
||||
onManualAuthInput={setManualAuthInput}
|
||||
onSubmitManualAuth={() => {
|
||||
void submitManualAuth();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { Show, createSignal } from "solid-js";
|
||||
import { Loader2, Plus, X } from "lucide-solid";
|
||||
import Button from "./button";
|
||||
import TextInput from "./text-input";
|
||||
import type { McpDirectoryInfo } from "../constants";
|
||||
import { t, type Language } from "../../i18n";
|
||||
|
||||
export type AddMcpModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (entry: McpDirectoryInfo) => void;
|
||||
busy: boolean;
|
||||
isRemoteWorkspace: boolean;
|
||||
language: Language;
|
||||
};
|
||||
|
||||
export default function AddMcpModal(props: AddMcpModalProps) {
|
||||
const tr = (key: string) => t(key, props.language);
|
||||
|
||||
const [name, setName] = createSignal("");
|
||||
const [serverType, setServerType] = createSignal<"remote" | "local">("remote");
|
||||
const [url, setUrl] = createSignal("");
|
||||
const [command, setCommand] = createSignal("");
|
||||
const [oauthRequired, setOauthRequired] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
|
||||
const reset = () => {
|
||||
setName("");
|
||||
setServerType("remote");
|
||||
setUrl("");
|
||||
setCommand("");
|
||||
setOauthRequired(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (submitting()) return;
|
||||
reset();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting()) return;
|
||||
setError(null);
|
||||
|
||||
const trimmedName = name().trim();
|
||||
if (!trimmedName) {
|
||||
setError(tr("mcp.name_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
if (serverType() === "remote") {
|
||||
const trimmedUrl = url().trim();
|
||||
if (!trimmedUrl) {
|
||||
setError(tr("mcp.url_or_command_required"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.resolve(props.onAdd({
|
||||
name: trimmedName,
|
||||
description: "",
|
||||
type: "local",
|
||||
command: trimmedCommand.split(/\s+/),
|
||||
oauth: false,
|
||||
}));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-gray-1/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<h2 class="text-lg font-semibold text-gray-12">
|
||||
{tr("mcp.add_modal_title")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-11">{tr("mcp.add_modal_subtitle")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-gray-11 hover:text-gray-12 hover:bg-gray-4 rounded-lg transition-colors"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<TextInput
|
||||
label={tr("mcp.server_name")}
|
||||
placeholder={tr("mcp.server_name_placeholder")}
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-dls-secondary">{tr("mcp.server_type")}</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
serverType() === "remote"
|
||||
? "bg-dls-active text-dls-text"
|
||||
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
|
||||
}`}
|
||||
onClick={() => setServerType("remote")}
|
||||
>
|
||||
{tr("mcp.type_remote")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.isRemoteWorkspace}
|
||||
class={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
serverType() === "local"
|
||||
? "bg-dls-active text-dls-text"
|
||||
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
|
||||
} ${props.isRemoteWorkspace ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
onClick={() => {
|
||||
if (props.isRemoteWorkspace) return;
|
||||
setServerType("local");
|
||||
}}
|
||||
>
|
||||
{tr("mcp.type_local_cmd")}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.isRemoteWorkspace}>
|
||||
<div class="mt-2 text-[11px] text-dls-secondary">{tr("mcp.remote_workspace_url_hint")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={serverType() === "remote"}>
|
||||
<div class="space-y-3">
|
||||
<TextInput
|
||||
label={tr("mcp.server_url")}
|
||||
placeholder={tr("mcp.server_url_placeholder")}
|
||||
value={url()}
|
||||
onInput={(e) => setUrl(e.currentTarget.value)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Show when={serverType() === "local"}>
|
||||
<TextInput
|
||||
label={tr("mcp.server_command")}
|
||||
placeholder={tr("mcp.server_command_placeholder")}
|
||||
hint={tr("mcp.server_command_hint")}
|
||||
value={command()}
|
||||
onInput={(e) => setCommand(e.currentTarget.value)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="rounded-lg bg-red-2 border border-red-6 px-3 py-2 text-xs text-red-11">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 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} disabled={submitting()}>
|
||||
{tr("mcp.auth.cancel")}
|
||||
</Button>
|
||||
<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")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { splitProps } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "outline" | "danger";
|
||||
};
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
const [local, rest] = splitProps(props, ["variant", "class", "disabled", "title", "type"]);
|
||||
const variant = () => local.variant ?? "primary";
|
||||
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-150 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary: "bg-dls-accent text-white hover:bg-[var(--dls-accent-hover)] border border-transparent shadow-[0_1px_2px_rgba(17,24,39,0.12)]",
|
||||
secondary: "bg-gray-12 text-gray-1 hover:bg-gray-11 border border-transparent font-semibold",
|
||||
ghost: "bg-transparent text-dls-secondary hover:text-dls-text hover:bg-dls-hover",
|
||||
outline: "border border-dls-border text-dls-text hover:bg-dls-hover bg-transparent",
|
||||
danger: "bg-red-3 text-red-11 hover:bg-red-4 border border-red-6",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={local.type ?? "button"}
|
||||
disabled={local.disabled}
|
||||
aria-disabled={local.disabled}
|
||||
title={local.title}
|
||||
class={`${base} ${variants[variant()]} ${local.class ?? ""}`.trim()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Show, type JSX } from "solid-js";
|
||||
|
||||
import { AlertTriangle } from "lucide-solid";
|
||||
|
||||
import Button from "./button";
|
||||
|
||||
export type ConfirmModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string | JSX.Element;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
variant?: "danger" | "warning";
|
||||
confirmButtonVariant?: "primary" | "secondary" | "ghost" | "outline" | "danger";
|
||||
cancelButtonVariant?: "primary" | "secondary" | "ghost" | "outline" | "danger";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function ConfirmModal(props: ConfirmModalProps) {
|
||||
const variant = () => props.variant ?? "warning";
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-[60] bg-gray-1/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
|
||||
classList={{
|
||||
"bg-amber-3/50 text-amber-11": variant() === "warning",
|
||||
"bg-red-3/50 text-red-11": variant() === "danger",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-gray-12">{props.title}</h3>
|
||||
<p class="mt-2 text-sm text-gray-11">{props.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<Button variant={props.cancelButtonVariant ?? "outline"} onClick={props.onCancel}>
|
||||
{props.cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.confirmButtonVariant ?? (variant() === "danger" ? "danger" : "primary")}
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Show, createEffect, createSignal } from "solid-js";
|
||||
import { Check, ExternalLink, Loader2, MonitorSmartphone, Settings2, X } from "lucide-solid";
|
||||
import Button from "./button";
|
||||
import { t, type Language } from "../../i18n";
|
||||
|
||||
export type ControlChromeSetupModalProps = {
|
||||
open: boolean;
|
||||
busy: boolean;
|
||||
language: Language;
|
||||
mode: "connect" | "edit";
|
||||
initialUseExistingProfile: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (useExistingProfile: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ControlChromeSetupModal(props: ControlChromeSetupModalProps) {
|
||||
const tr = (key: string) => t(key, props.language);
|
||||
const [useExistingProfile, setUseExistingProfile] = createSignal(props.initialUseExistingProfile);
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
setUseExistingProfile(props.initialUseExistingProfile);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-gray-1/70 backdrop-blur-sm" onClick={props.onClose} />
|
||||
|
||||
<div class="relative w-full max-w-2xl overflow-hidden rounded-2xl border border-gray-6/70 bg-gray-2 shadow-2xl">
|
||||
<div class="border-b border-gray-6 px-6 py-5 sm:px-7">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-gray-6 bg-gray-3 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-11">
|
||||
<MonitorSmartphone size={12} />
|
||||
Chrome DevTools MCP
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-12 sm:text-2xl">
|
||||
{tr("mcp.control_chrome_setup_title")}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-xl text-sm leading-6 text-gray-11">
|
||||
{tr("mcp.control_chrome_setup_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl p-2 text-gray-11 transition-colors hover:bg-gray-4 hover:text-gray-12"
|
||||
onClick={props.onClose}
|
||||
aria-label={tr("common.cancel")}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 px-6 py-6 sm:px-7">
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/40 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-3 text-blue-11">
|
||||
<Check size={18} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-gray-12">
|
||||
{tr("mcp.control_chrome_browser_title")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-11">
|
||||
{tr("mcp.control_chrome_browser_hint")}
|
||||
</p>
|
||||
<ol class="mt-3 space-y-2 text-sm leading-6 text-gray-12">
|
||||
<li>1. {tr("mcp.control_chrome_browser_step_one")}</li>
|
||||
<li>2. {tr("mcp.control_chrome_browser_step_two")}</li>
|
||||
<li>3. {tr("mcp.control_chrome_browser_step_three")}</li>
|
||||
</ol>
|
||||
<a
|
||||
href="https://github.com/ChromeDevTools/chrome-devtools-mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-3 inline-flex items-center gap-1 text-xs font-medium text-blue-11 transition-colors hover:text-blue-12"
|
||||
>
|
||||
{tr("mcp.control_chrome_docs")}
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-1/40 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-3 text-gray-11">
|
||||
<Settings2 size={18} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-gray-12">
|
||||
{tr("mcp.control_chrome_profile_title")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-11">
|
||||
{tr("mcp.control_chrome_profile_hint")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={useExistingProfile()}
|
||||
onClick={() => setUseExistingProfile((current) => !current)}
|
||||
class="mt-4 flex w-full items-center justify-between gap-4 rounded-2xl border border-gray-6 bg-gray-2 px-4 py-4 text-left transition-colors hover:bg-gray-3"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-gray-12">
|
||||
{tr("mcp.control_chrome_toggle_label")}
|
||||
</div>
|
||||
<div class="text-xs leading-5 text-gray-11">
|
||||
{tr("mcp.control_chrome_toggle_hint")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={`relative h-7 w-12 shrink-0 rounded-full transition-colors ${useExistingProfile() ? "bg-blue-9" : "bg-gray-6"}`}>
|
||||
<div class={`absolute top-1 h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${useExistingProfile() ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3 rounded-2xl border border-dashed border-gray-6 bg-gray-2/70 px-4 py-3 text-xs leading-5 text-gray-11">
|
||||
{useExistingProfile()
|
||||
? tr("mcp.control_chrome_toggle_on")
|
||||
: tr("mcp.control_chrome_toggle_off")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-3 border-t border-gray-6 bg-gray-2/80 px-6 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-7">
|
||||
<Button variant="ghost" onClick={props.onClose}>
|
||||
{tr("mcp.auth.cancel")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => props.onSave(useExistingProfile())} disabled={props.busy}>
|
||||
<Show when={props.busy} fallback={props.mode === "edit" ? tr("mcp.control_chrome_save") : tr("mcp.control_chrome_connect")}>
|
||||
<>
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
{props.mode === "edit" ? tr("mcp.control_chrome_save") : tr("mcp.control_chrome_connect")}
|
||||
</>
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
import { Show, createSignal, onMount } from "solid-js";
|
||||
import { Check, FileText, Folder } from "lucide-solid";
|
||||
|
||||
export type FlyoutProps = {
|
||||
item: {
|
||||
id: string;
|
||||
rect: { top: number; left: number; width: number; height: number };
|
||||
targetRect: { top: number; left: number; width: number; height: number };
|
||||
label: string;
|
||||
icon: "file" | "check" | "folder";
|
||||
};
|
||||
};
|
||||
|
||||
export default function FlyoutItem(props: FlyoutProps) {
|
||||
const [active, setActive] = createSignal(false);
|
||||
onMount(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setActive(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="fixed z-[100] pointer-events-none transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] flex items-center gap-2 px-3 py-2 rounded-xl bg-gray-12 text-gray-1 shadow-xl border border-gray-11/20"
|
||||
style={{
|
||||
top: `${props.item.rect.top}px`,
|
||||
left: `${props.item.rect.left}px`,
|
||||
transform: active()
|
||||
? `translate(${props.item.targetRect.left - props.item.rect.left}px, ${
|
||||
props.item.targetRect.top - props.item.rect.top
|
||||
}px) scale(0.3)`
|
||||
: "translate(0, 0) scale(1)",
|
||||
opacity: active() ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<Show when={props.item.icon === "check"}>
|
||||
<Check size={14} />
|
||||
</Show>
|
||||
<Show when={props.item.icon === "file"}>
|
||||
<FileText size={14} />
|
||||
</Show>
|
||||
<Show when={props.item.icon === "folder"}>
|
||||
<Folder size={14} />
|
||||
</Show>
|
||||
<span class="text-xs font-medium truncate max-w-[120px]">{props.item.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
import { For, Show, createEffect, createSignal, on, onCleanup } from "solid-js";
|
||||
import { CheckCircle2, Loader2, RefreshCcw, X } from "lucide-solid";
|
||||
import Button from "./button";
|
||||
import TextInput from "./text-input";
|
||||
import type { Client } from "../types";
|
||||
import type { McpDirectoryInfo } from "../constants";
|
||||
import { unwrap } from "../lib/opencode";
|
||||
import { opencodeMcpAuth } from "../lib/tauri";
|
||||
import { validateMcpServerName } from "../mcp";
|
||||
import { t, type Language } from "../../i18n";
|
||||
import { isTauriRuntime, normalizeDirectoryPath } from "../utils";
|
||||
|
||||
const MCP_AUTH_POLL_INTERVAL_MS = 2_000;
|
||||
const MCP_AUTH_TIMEOUT_MS = 90_000;
|
||||
const MCP_AUTH_DISCOVERY_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type McpAuthModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void | Promise<void>;
|
||||
onReloadEngine?: () => void | Promise<void>;
|
||||
reloadRequired?: boolean;
|
||||
reloadBlocked?: boolean;
|
||||
activeSessions?: Array<{ id: string; title: string }>;
|
||||
isRemoteWorkspace?: boolean;
|
||||
client: Client | null;
|
||||
entry: McpDirectoryInfo | null;
|
||||
projectDir: string;
|
||||
language: Language;
|
||||
onForceStopSession?: (sessionID: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function McpAuthModal(props: McpAuthModalProps) {
|
||||
const translate = (key: string, replacements?: Record<string, string>) => {
|
||||
let result = t(key, props.language);
|
||||
if (replacements) {
|
||||
Object.entries(replacements).forEach(([placeholder, value]) => {
|
||||
result = result.replace(`{${placeholder}}`, value);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [needsReload, setNeedsReload] = createSignal(false);
|
||||
const [alreadyConnected, setAlreadyConnected] = createSignal(false);
|
||||
const [authInProgress, setAuthInProgress] = createSignal(false);
|
||||
const [statusChecking, setStatusChecking] = createSignal(false);
|
||||
const [reloadNotice, setReloadNotice] = createSignal<string | null>(null);
|
||||
const [authorizationUrl, setAuthorizationUrl] = createSignal<string | null>(null);
|
||||
const [callbackInput, setCallbackInput] = createSignal("");
|
||||
const [manualAuthBusy, setManualAuthBusy] = createSignal(false);
|
||||
const [cliAuthBusy, setCliAuthBusy] = createSignal(false);
|
||||
const [cliAuthResult, setCliAuthResult] = createSignal<string | null>(null);
|
||||
const [authUrlCopied, setAuthUrlCopied] = createSignal(false);
|
||||
const [resolvedDir, setResolvedDir] = createSignal("");
|
||||
const [awaitingReload, setAwaitingReload] = createSignal(false);
|
||||
const [reloadStarting, setReloadStarting] = createSignal(false);
|
||||
const [reloadSatisfied, setReloadSatisfied] = createSignal(false);
|
||||
const [forceStopBusySessionID, setForceStopBusySessionID] = createSignal<string | null>(null);
|
||||
|
||||
let statusPoll: number | null = null;
|
||||
let authCopyTimeout: number | null = null;
|
||||
|
||||
const stopStatusPolling = () => {
|
||||
if (statusPoll !== null) {
|
||||
window.clearInterval(statusPoll);
|
||||
statusPoll = null;
|
||||
}
|
||||
};
|
||||
|
||||
onCleanup(() => stopStatusPolling());
|
||||
|
||||
createEffect(() => {
|
||||
const normalized = normalizeDirectoryPath(props.projectDir ?? "");
|
||||
const collapsed = normalized.replace(/^\/private\/tmp(?=\/|$)/, "/tmp");
|
||||
setResolvedDir(collapsed);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (authCopyTimeout !== null) {
|
||||
window.clearTimeout(authCopyTimeout);
|
||||
authCopyTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
const openAuthorizationUrl = async (url: string) => {
|
||||
if (isTauriRuntime()) {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||
await openUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyAuthorizationUrl = async () => {
|
||||
const url = authorizationUrl();
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setAuthUrlCopied(true);
|
||||
if (authCopyTimeout !== null) {
|
||||
window.clearTimeout(authCopyTimeout);
|
||||
}
|
||||
authCopyTimeout = window.setTimeout(() => {
|
||||
setAuthUrlCopied(false);
|
||||
authCopyTimeout = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMcpStatus = async (slug: string) => {
|
||||
const entry = props.entry;
|
||||
const client = props.client;
|
||||
if (!entry || !client) return null;
|
||||
|
||||
try {
|
||||
const directory = resolvedDir().trim();
|
||||
if (!directory) return null;
|
||||
const result = await client.mcp.status({ directory });
|
||||
const status = result.data?.[slug] as { status?: string; error?: string } | undefined;
|
||||
return status ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDirectory = async () => {
|
||||
const current = resolvedDir().trim();
|
||||
if (current) return current;
|
||||
const client = props.client;
|
||||
if (!client) return "";
|
||||
try {
|
||||
const info = unwrap(await client.path.get());
|
||||
const next = normalizeDirectoryPath(info.directory ?? "");
|
||||
const collapsed = next.replace(/^\/private\/tmp(?=\/|$)/, "/tmp");
|
||||
if (collapsed) {
|
||||
setResolvedDir(collapsed);
|
||||
}
|
||||
return collapsed;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSlug = (name: string) => validateMcpServerName(name).toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
|
||||
const waitForMcpAvailability = async (slug: string) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < MCP_AUTH_DISCOVERY_TIMEOUT_MS) {
|
||||
const status = await fetchMcpStatus(slug);
|
||||
if (status) return status;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const startStatusPolling = (slug: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
stopStatusPolling();
|
||||
const startedAt = Date.now();
|
||||
statusPoll = window.setInterval(async () => {
|
||||
if (Date.now() - startedAt >= MCP_AUTH_TIMEOUT_MS) {
|
||||
stopStatusPolling();
|
||||
setError(translate("mcp.auth.request_timed_out"));
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await fetchMcpStatus(slug);
|
||||
if (status?.status === "connected") {
|
||||
setAlreadyConnected(true);
|
||||
setError(null);
|
||||
stopStatusPolling();
|
||||
}
|
||||
}, MCP_AUTH_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const startAuth = async (forceRetry = false, allowAutoReload = true) => {
|
||||
const entry = props.entry;
|
||||
const client = props.client;
|
||||
|
||||
if (!entry || !client) return;
|
||||
|
||||
const isRemoteWorkspace = !!props.isRemoteWorkspace;
|
||||
|
||||
let slug = "";
|
||||
try {
|
||||
slug = resolveSlug(entry.name);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
setAuthInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceRetry && authInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setNeedsReload(false);
|
||||
setAlreadyConnected(false);
|
||||
stopStatusPolling();
|
||||
setAuthorizationUrl(null);
|
||||
setCallbackInput("");
|
||||
setReloadNotice(null);
|
||||
setLoading(true);
|
||||
setAuthInProgress(true);
|
||||
|
||||
try {
|
||||
const directory = await resolveDirectory();
|
||||
if (!directory) {
|
||||
setError(translate("mcp.pick_workspace_first"));
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEntry = await fetchMcpStatus(slug);
|
||||
if (props.reloadRequired && !reloadSatisfied() && !statusEntry) {
|
||||
setNeedsReload(true);
|
||||
setReloadNotice(
|
||||
props.reloadBlocked
|
||||
? translate("mcp.auth.reload_blocked")
|
||||
: translate("mcp.auth.reload_notice")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusEntry?.status === "connected") {
|
||||
setAlreadyConnected(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRemoteWorkspace) {
|
||||
const result = await client.mcp.auth.authenticate({
|
||||
name: slug,
|
||||
directory,
|
||||
});
|
||||
const status = unwrap(result) as { status?: string; error?: string };
|
||||
|
||||
if (status.status === "connected") {
|
||||
setAlreadyConnected(true);
|
||||
await props.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "needs_client_registration") {
|
||||
setError(status.error ?? translate("mcp.auth.client_registration_required"));
|
||||
} else if (status.status === "disabled") {
|
||||
setError(translate("mcp.auth.server_disabled"));
|
||||
} else if (status.status === "failed") {
|
||||
setError(status.error ?? translate("mcp.auth.oauth_failed"));
|
||||
} else {
|
||||
setError(translate("mcp.auth.authorization_still_required"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await client.mcp.auth.start({
|
||||
name: slug,
|
||||
directory,
|
||||
});
|
||||
const auth = unwrap(authResult) as { authorizationUrl?: string };
|
||||
|
||||
if (!auth.authorizationUrl) {
|
||||
setAlreadyConnected(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthorizationUrl(auth.authorizationUrl);
|
||||
await openAuthorizationUrl(auth.authorizationUrl);
|
||||
startStatusPolling(slug);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
||||
|
||||
if (message.toLowerCase().includes("does not support oauth")) {
|
||||
const serverSlug = props.entry?.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") ?? "server";
|
||||
const canAutoReload =
|
||||
allowAutoReload && !props.isRemoteWorkspace && !props.reloadBlocked && Boolean(props.onReloadEngine);
|
||||
|
||||
if (canAutoReload && props.onReloadEngine) {
|
||||
await props.onReloadEngine();
|
||||
await startAuth(true, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.reloadRequired && !reloadSatisfied()) {
|
||||
setReloadNotice(
|
||||
props.reloadBlocked
|
||||
? translate("mcp.auth.reload_blocked")
|
||||
: translate("mcp.auth.reload_notice")
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`${message}\n\n` + translate("mcp.auth.oauth_not_supported_hint", { server: serverSlug })
|
||||
);
|
||||
}
|
||||
setNeedsReload(true);
|
||||
} else if (message.toLowerCase().includes("not found") || message.toLowerCase().includes("unknown")) {
|
||||
setNeedsReload(true);
|
||||
setError(translate("mcp.auth.try_reload_engine", { message }));
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setAuthInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isInvalidRefreshToken = () => {
|
||||
const message = error();
|
||||
if (!message) return false;
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes("invalidgranterror") ||
|
||||
normalized.includes("invalid refresh token") ||
|
||||
normalized.includes("invalid_refresh_token")
|
||||
);
|
||||
};
|
||||
|
||||
const handleCliReauth = async () => {
|
||||
const entry = props.entry;
|
||||
if (!entry || cliAuthBusy()) return;
|
||||
if (props.isRemoteWorkspace) return;
|
||||
if (!isTauriRuntime()) return;
|
||||
|
||||
setCliAuthBusy(true);
|
||||
setCliAuthResult(null);
|
||||
|
||||
try {
|
||||
const result = await opencodeMcpAuth(props.projectDir, entry.name);
|
||||
if (result.ok) {
|
||||
setError(null);
|
||||
setNeedsReload(true);
|
||||
setReloadNotice(translate("mcp.auth.oauth_completed_reload"));
|
||||
} else {
|
||||
setCliAuthResult(result.stderr || result.stdout || translate("mcp.auth.reauth_failed"));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.reauth_failed");
|
||||
setCliAuthResult(message);
|
||||
} finally {
|
||||
setCliAuthBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the OAuth flow when modal opens with an entry
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.open, props.entry, props.client, props.reloadRequired] as const,
|
||||
([isOpen, entry, client, reloadRequired], previous) => {
|
||||
if (!isOpen || !entry || !client) {
|
||||
return;
|
||||
}
|
||||
const previousEntry = previous?.[1];
|
||||
if (!previous || previousEntry?.name !== entry.name || !previous?.[0]) {
|
||||
setReloadSatisfied(false);
|
||||
}
|
||||
if (reloadRequired && !reloadSatisfied()) {
|
||||
setAwaitingReload(true);
|
||||
return;
|
||||
}
|
||||
// Only start auth on initial open, not on every prop change
|
||||
startAuth(false);
|
||||
},
|
||||
{ defer: true } // Defer to avoid double-firing on mount
|
||||
)
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open || !awaitingReload()) return;
|
||||
if (props.reloadBlocked) return;
|
||||
const reloadEngine = props.onReloadEngine;
|
||||
const entry = props.entry;
|
||||
if (!reloadEngine || !entry || reloadStarting()) return;
|
||||
|
||||
void (async () => {
|
||||
setReloadStarting(true);
|
||||
setError(null);
|
||||
setNeedsReload(false);
|
||||
setReloadNotice(null);
|
||||
try {
|
||||
await reloadEngine();
|
||||
if (!props.open) return;
|
||||
const slug = resolveSlug(entry.name);
|
||||
const status = await waitForMcpAvailability(slug);
|
||||
if (!status) {
|
||||
setAwaitingReload(false);
|
||||
setNeedsReload(true);
|
||||
setReloadNotice(
|
||||
props.reloadBlocked
|
||||
? translate("mcp.auth.reload_blocked")
|
||||
: translate("mcp.auth.reload_notice")
|
||||
);
|
||||
return;
|
||||
}
|
||||
setReloadSatisfied(true);
|
||||
setAwaitingReload(false);
|
||||
startAuth(false, false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.reload_failed");
|
||||
setAwaitingReload(false);
|
||||
setNeedsReload(true);
|
||||
setError(message);
|
||||
} finally {
|
||||
setReloadStarting(false);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const handleRetry = () => {
|
||||
startAuth(true);
|
||||
};
|
||||
|
||||
const handleReopenBrowser = () => {
|
||||
handleRetry();
|
||||
};
|
||||
|
||||
const handleReloadAndRetry = async () => {
|
||||
if (!props.onReloadEngine) return;
|
||||
if (props.isRemoteWorkspace && typeof window !== "undefined") {
|
||||
const proceed = window.confirm(translate("mcp.auth.reload_remote_confirm"));
|
||||
if (!proceed) return;
|
||||
}
|
||||
await props.onReloadEngine();
|
||||
startAuth(true);
|
||||
};
|
||||
|
||||
const handleForceStopSession = async (sessionID: string) => {
|
||||
if (!props.onForceStopSession || forceStopBusySessionID()) return;
|
||||
setForceStopBusySessionID(sessionID);
|
||||
try {
|
||||
await props.onForceStopSession(sessionID);
|
||||
} finally {
|
||||
setForceStopBusySessionID(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setAlreadyConnected(false);
|
||||
setNeedsReload(false);
|
||||
setAuthInProgress(false);
|
||||
setStatusChecking(false);
|
||||
setAuthorizationUrl(null);
|
||||
setCallbackInput("");
|
||||
setManualAuthBusy(false);
|
||||
setReloadNotice(null);
|
||||
setCliAuthBusy(false);
|
||||
setCliAuthResult(null);
|
||||
setAwaitingReload(false);
|
||||
setReloadStarting(false);
|
||||
setReloadSatisfied(false);
|
||||
setForceStopBusySessionID(null);
|
||||
stopStatusPolling();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const isBusy = () => loading() || statusChecking() || manualAuthBusy();
|
||||
const isPreparingReload = () => awaitingReload() || reloadStarting();
|
||||
|
||||
const parseAuthCode = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/);
|
||||
if (match) {
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("localhost") || trimmed.includes("127.0.0.1")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const handleManualComplete = async () => {
|
||||
const entry = props.entry;
|
||||
const client = props.client;
|
||||
if (!entry || !client) return;
|
||||
|
||||
let slug = "";
|
||||
try {
|
||||
const safeName = validateMcpServerName(entry.name);
|
||||
slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = parseAuthCode(callbackInput());
|
||||
if (!code) {
|
||||
setError(translate("mcp.auth.callback_invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
setManualAuthBusy(true);
|
||||
setError(null);
|
||||
stopStatusPolling();
|
||||
|
||||
try {
|
||||
const directory = await resolveDirectory();
|
||||
if (!directory) {
|
||||
setError(translate("mcp.pick_workspace_first"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await client.mcp.auth.callback({
|
||||
name: slug,
|
||||
directory,
|
||||
code,
|
||||
});
|
||||
const status = unwrap(result) as { status?: string; error?: string };
|
||||
if (status.status === "connected") {
|
||||
setAlreadyConnected(true);
|
||||
setManualAuthBusy(false);
|
||||
await props.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "needs_client_registration") {
|
||||
setError(status.error ?? translate("mcp.auth.client_registration_required"));
|
||||
} else if (status.status === "disabled") {
|
||||
setError(translate("mcp.auth.server_disabled"));
|
||||
} else if (status.status === "failed") {
|
||||
setError(status.error ?? translate("mcp.auth.oauth_failed"));
|
||||
} else {
|
||||
setError(translate("mcp.auth.authorization_still_required"));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.oauth_failed");
|
||||
setError(message);
|
||||
} finally {
|
||||
setManualAuthBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
const entry = props.entry;
|
||||
const client = props.client;
|
||||
if (!entry || !client) return;
|
||||
|
||||
setError(null);
|
||||
setStatusChecking(true);
|
||||
|
||||
let slug = "";
|
||||
try {
|
||||
const safeName = validateMcpServerName(entry.name);
|
||||
slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
||||
setError(message);
|
||||
setStatusChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEntry = await fetchMcpStatus(slug);
|
||||
if (statusEntry?.status === "connected") {
|
||||
setAlreadyConnected(true);
|
||||
setStatusChecking(false);
|
||||
await props.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusEntry?.status === "needs_client_registration") {
|
||||
setError(statusEntry.error ?? translate("mcp.auth.client_registration_required"));
|
||||
} else if (statusEntry?.status === "disabled") {
|
||||
setError(translate("mcp.auth.server_disabled"));
|
||||
} else if (statusEntry?.status === "failed") {
|
||||
setError(statusEntry.error ?? translate("mcp.auth.oauth_failed"));
|
||||
} else {
|
||||
setError(translate("mcp.auth.authorization_still_required"));
|
||||
}
|
||||
|
||||
setStatusChecking(false);
|
||||
};
|
||||
|
||||
const serverName = () => props.entry?.name ?? "MCP Server";
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
class="absolute inset-0 bg-gray-1/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div class="relative w-full max-w-lg bg-gray-2 border border-gray-6 rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-12">
|
||||
{translate("mcp.auth.connect_server", { server: serverName() })}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-11">{translate("mcp.auth.open_browser_signin")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-gray-11 hover:text-gray-12 hover:bg-gray-4 rounded-lg transition-colors"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
<Show when={isBusy()}>
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 px-5 py-6 text-center space-y-4">
|
||||
<div class="flex items-center justify-center">
|
||||
<Loader2 size={32} class="animate-spin text-gray-11" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-12">
|
||||
{translate("mcp.auth.waiting_authorization")}
|
||||
</p>
|
||||
<p class="text-xs text-gray-10">
|
||||
{translate("mcp.auth.follow_browser_steps")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-10 underline underline-offset-2 hover:text-gray-11 transition-colors"
|
||||
onClick={handleReopenBrowser}
|
||||
>
|
||||
{translate("mcp.auth.reopen_browser_link")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isBusy() && isPreparingReload()}>
|
||||
<div class="rounded-xl border border-amber-6/60 bg-amber-2/40 px-5 py-6 text-center space-y-4">
|
||||
<div class="flex items-center justify-center">
|
||||
<Loader2 size={32} class="animate-spin text-amber-11" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-12">
|
||||
{props.reloadBlocked
|
||||
? translate("mcp.auth.waiting_for_conversation_title")
|
||||
: translate("mcp.auth.applying_changes_title")}
|
||||
</p>
|
||||
<p class="text-xs text-gray-10">
|
||||
{props.reloadBlocked
|
||||
? translate("mcp.auth.waiting_for_conversation_body")
|
||||
: translate("mcp.auth.applying_changes_body")}
|
||||
</p>
|
||||
</div>
|
||||
<Show when={props.reloadBlocked && (props.activeSessions?.length ?? 0) > 0}>
|
||||
<div class="space-y-2 text-left">
|
||||
<For each={props.activeSessions ?? []}>
|
||||
{(session) => (
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-amber-6/50 bg-amber-1/40 px-3 py-2">
|
||||
<span class="text-xs text-gray-11">
|
||||
{translate("mcp.auth.waiting_for_session", { session: session.title })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-amber-11 underline underline-offset-2 hover:text-amber-12 transition-colors disabled:no-underline disabled:opacity-60"
|
||||
onClick={() => handleForceStopSession(session.id)}
|
||||
disabled={forceStopBusySessionID() === session.id}
|
||||
>
|
||||
{forceStopBusySessionID() === session.id
|
||||
? translate("mcp.auth.force_stopping")
|
||||
: translate("mcp.auth.force_stop")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isBusy() && alreadyConnected()}>
|
||||
<div class="bg-green-7/10 border border-green-7/20 rounded-xl p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-green-7/20 flex items-center justify-center">
|
||||
<CheckCircle2 size={24} class="text-green-11" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.already_connected")}</p>
|
||||
<p class="text-xs text-gray-11">
|
||||
{translate("mcp.auth.already_connected_description", { server: serverName() })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-10">
|
||||
{translate("mcp.auth.configured_previously")}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={reloadNotice()}>
|
||||
<div class="bg-gray-1/50 border border-gray-6/70 rounded-xl p-4 space-y-3">
|
||||
<p class="text-sm text-gray-11">{reloadNotice()}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
<Show when={props.onReloadEngine}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReloadAndRetry}
|
||||
disabled={props.reloadBlocked}
|
||||
title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
{translate("mcp.auth.reload_engine_retry")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant="ghost" onClick={handleRetry}>
|
||||
{translate("mcp.auth.retry_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="bg-red-7/10 border border-red-7/20 rounded-xl p-4 space-y-3">
|
||||
<p class="text-sm text-red-11">{error()}</p>
|
||||
|
||||
<Show when={needsReload()}>
|
||||
<div class="flex flex-wrap gap-2 pt-2">
|
||||
<Show when={props.onReloadEngine}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReloadAndRetry}
|
||||
disabled={props.reloadBlocked}
|
||||
title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
{translate("mcp.auth.reload_engine_retry")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant="ghost" onClick={handleRetry}>
|
||||
{translate("mcp.auth.retry_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!needsReload()}>
|
||||
<div class="pt-2">
|
||||
<Button variant="ghost" onClick={handleRetry}>
|
||||
{translate("mcp.auth.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isInvalidRefreshToken()}>
|
||||
<div class="pt-2 space-y-2">
|
||||
<p class="text-xs text-red-11">{translate("mcp.auth.invalid_refresh_token")}</p>
|
||||
<Show when={!props.isRemoteWorkspace}>
|
||||
<Show when={isTauriRuntime()}>
|
||||
<Button variant="secondary" onClick={handleCliReauth} disabled={cliAuthBusy()}>
|
||||
<Show
|
||||
when={cliAuthBusy()}
|
||||
fallback={translate("mcp.auth.reauth_action")}
|
||||
>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{translate("mcp.auth.reauth_running")}
|
||||
</Show>
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!isTauriRuntime()}>
|
||||
<div class="text-[11px] text-red-10">
|
||||
{translate("mcp.auth.reauth_cli_hint", { server: serverName() })}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.isRemoteWorkspace}>
|
||||
<div class="text-[11px] text-red-10">
|
||||
{translate("mcp.auth.reauth_remote_hint")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={cliAuthResult()}>
|
||||
<div class="text-[11px] text-red-10">{cliAuthResult()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isBusy() && authorizationUrl() && props.isRemoteWorkspace && !alreadyConnected()}>
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
|
||||
<div class="text-xs font-medium text-gray-12">
|
||||
{translate("mcp.auth.manual_finish_title")}
|
||||
</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
{translate("mcp.auth.manual_finish_hint")}
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-6/70 bg-gray-2/40 px-3 py-2 flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-8">{translate("mcp.auth.authorization_link")}</div>
|
||||
<div class="text-[11px] text-gray-11 font-mono truncate">
|
||||
{authorizationUrl()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-xs"
|
||||
onClick={handleCopyAuthorizationUrl}
|
||||
>
|
||||
{authUrlCopied() ? translate("mcp.auth.copied") : translate("mcp.auth.copy_link")}
|
||||
</Button>
|
||||
</div>
|
||||
<TextInput
|
||||
label={translate("mcp.auth.callback_label")}
|
||||
placeholder={translate("mcp.auth.callback_placeholder")}
|
||||
value={callbackInput()}
|
||||
onInput={(event) => setCallbackInput(event.currentTarget.value)}
|
||||
/>
|
||||
<div class="text-[11px] text-gray-9">
|
||||
{translate("mcp.auth.port_forward_hint")}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleManualComplete}
|
||||
disabled={manualAuthBusy() || !callbackInput().trim()}
|
||||
>
|
||||
<Show
|
||||
when={manualAuthBusy()}
|
||||
fallback={translate("mcp.auth.complete_connection")}
|
||||
>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{translate("mcp.auth.complete_connection")}
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isBusy() && !isPreparingReload() && !error() && !reloadNotice() && !alreadyConnected()}>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.step1_title")}</p>
|
||||
<p class="text-xs text-gray-10 mt-1">
|
||||
{translate("mcp.auth.step1_description", { server: serverName() })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.step2_title")}</p>
|
||||
<p class="text-xs text-gray-10 mt-1">
|
||||
{translate("mcp.auth.step2_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.step3_title")}</p>
|
||||
<p class="text-xs text-gray-10 mt-1">
|
||||
{translate("mcp.auth.step3_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-11">
|
||||
<div class="space-y-3">
|
||||
<p>{translate("mcp.auth.waiting_authorization")}</p>
|
||||
<p class="text-xs text-gray-10">
|
||||
{translate("mcp.auth.follow_browser_steps")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-10 underline underline-offset-2 hover:text-gray-11 transition-colors text-left"
|
||||
onClick={handleReopenBrowser}
|
||||
>
|
||||
{translate("mcp.auth.reopen_browser_link")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-6 bg-gray-2/50">
|
||||
<Show when={alreadyConnected()}>
|
||||
<Button variant="primary" onClick={handleComplete}>
|
||||
<CheckCircle2 size={16} />
|
||||
{translate("mcp.auth.done")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!alreadyConnected()}>
|
||||
<Button variant="ghost" onClick={handleClose}>
|
||||
{translate("mcp.auth.cancel")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleComplete}>
|
||||
<CheckCircle2 size={16} />
|
||||
{translate("mcp.auth.im_done")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { CheckCircle2, Circle, Search, X } from "lucide-solid";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import Button from "./button";
|
||||
import ProviderIcon from "./provider-icon";
|
||||
import { modelEquals } from "../utils";
|
||||
import type { ModelOption, ModelRef } from "../types";
|
||||
|
||||
export type ModelPickerModalProps = {
|
||||
open: boolean;
|
||||
options: ModelOption[];
|
||||
filteredOptions: ModelOption[];
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
target: "default" | "session";
|
||||
current: ModelRef;
|
||||
onSelect: (model: ModelRef) => void;
|
||||
onBehaviorChange: (model: ModelRef, value: string | null) => void;
|
||||
onOpenSettings: () => void;
|
||||
onClose: (options?: { restorePromptFocus?: boolean }) => void;
|
||||
};
|
||||
|
||||
export default function ModelPickerModal(props: ModelPickerModalProps) {
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
const translate = (key: string, params?: Record<string, string | number>) => t(key, undefined, params);
|
||||
|
||||
type RenderedItem =
|
||||
| { kind: "model"; opt: ModelOption }
|
||||
| { kind: "provider"; providerID: string; title: string; matchCount: number };
|
||||
|
||||
const [activeIndex, setActiveIndex] = createSignal(0);
|
||||
const optionRefs: HTMLButtonElement[] = [];
|
||||
|
||||
const otherProviderLinks = createMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const items: { providerID: string; title: string; matchCount: number }[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const opt of props.filteredOptions) {
|
||||
if (opt.isConnected) continue;
|
||||
counts.set(opt.providerID, (counts.get(opt.providerID) ?? 0) + 1);
|
||||
if (seen.has(opt.providerID)) continue;
|
||||
seen.add(opt.providerID);
|
||||
items.push({
|
||||
providerID: opt.providerID,
|
||||
title: opt.description ?? opt.providerID,
|
||||
matchCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
matchCount: counts.get(item.providerID) ?? 1,
|
||||
}));
|
||||
});
|
||||
|
||||
const renderedItems = createMemo<RenderedItem[]>(() => {
|
||||
const models = props.filteredOptions.filter((opt) => opt.isConnected);
|
||||
const recommended = models.filter((opt) => opt.isRecommended);
|
||||
const others = models.filter((opt) => !opt.isRecommended);
|
||||
|
||||
return [
|
||||
...recommended.map((opt) => ({ kind: "model" as const, opt })),
|
||||
...others.map((opt) => ({ kind: "model" as const, opt })),
|
||||
...otherProviderLinks().map((item) => ({ kind: "provider" as const, ...item })),
|
||||
];
|
||||
});
|
||||
|
||||
const activeModelIndex = createMemo(() => {
|
||||
const list = renderedItems();
|
||||
return list.findIndex(
|
||||
(item) =>
|
||||
item.kind === "model" &&
|
||||
modelEquals(props.current, {
|
||||
providerID: item.opt.providerID,
|
||||
modelID: item.opt.modelID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const recommendedOptions = createMemo(() =>
|
||||
renderedItems().flatMap((item, index) =>
|
||||
item.kind === "model" && item.opt.isRecommended ? [{ opt: item.opt, index }] : [],
|
||||
),
|
||||
);
|
||||
|
||||
const otherEnabledOptions = createMemo(() =>
|
||||
renderedItems().flatMap((item, index) =>
|
||||
item.kind === "model" && !item.opt.isRecommended ? [{ opt: item.opt, index }] : [],
|
||||
),
|
||||
);
|
||||
|
||||
const otherOptions = createMemo(() =>
|
||||
renderedItems().flatMap((item, index) =>
|
||||
item.kind === "provider"
|
||||
? [{ providerID: item.providerID, title: item.title, matchCount: item.matchCount, index }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
const clampIndex = (next: number) => {
|
||||
const last = renderedItems().length - 1;
|
||||
if (last < 0) return 0;
|
||||
return Math.max(0, Math.min(next, last));
|
||||
};
|
||||
|
||||
const scrollActiveIntoView = (idx: number) => {
|
||||
const el = optionRefs[idx];
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
requestAnimationFrame(() => {
|
||||
searchInputRef?.focus();
|
||||
if (searchInputRef?.value) {
|
||||
searchInputRef.select();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
const idx = activeModelIndex();
|
||||
const next = idx >= 0 ? idx : 0;
|
||||
setActiveIndex(clampIndex(next));
|
||||
requestAnimationFrame(() => scrollActiveIntoView(clampIndex(next)));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!props.open) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
props.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveIndex((current) => {
|
||||
const next = clampIndex(current + 1);
|
||||
requestAnimationFrame(() => scrollActiveIntoView(next));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveIndex((current) => {
|
||||
const next = clampIndex(current - 1);
|
||||
requestAnimationFrame(() => scrollActiveIntoView(next));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
const idx = activeIndex();
|
||||
const item = renderedItems()[idx];
|
||||
if (!item) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (item.kind === "provider") {
|
||||
props.onClose({ restorePromptFocus: false });
|
||||
props.onOpenSettings();
|
||||
return;
|
||||
}
|
||||
props.onSelect({ providerID: item.opt.providerID, modelID: item.opt.modelID });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", onKeyDown, true);
|
||||
});
|
||||
|
||||
const renderOption = (opt: ModelOption, index: number) => {
|
||||
const active = () =>
|
||||
modelEquals(props.current, {
|
||||
providerID: opt.providerID,
|
||||
modelID: opt.modelID,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
ref={(el) => {
|
||||
optionRefs[index] = el as unknown as HTMLButtonElement;
|
||||
}}
|
||||
class={`group w-full text-left rounded-xl px-3 py-2.5 transition-colors cursor-pointer ${
|
||||
active()
|
||||
? "bg-gray-3 text-gray-12"
|
||||
: index === activeIndex()
|
||||
? "bg-gray-2 text-gray-12"
|
||||
: "text-gray-10 hover:bg-gray-1/70 hover:text-gray-11"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => {
|
||||
props.onSelect({
|
||||
providerID: opt.providerID,
|
||||
modelID: opt.modelID,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
props.onSelect({
|
||||
providerID: opt.providerID,
|
||||
modelID: opt.modelID,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<ProviderIcon providerId={opt.providerID} providerName={opt.description} size={16} class={`mt-[1px] shrink-0 transition-colors ${active() ? 'text-gray-12' : 'text-gray-10 group-hover:text-gray-11'}`} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class={`text-[13px] flex items-center justify-between gap-2 ${active() ? 'font-medium text-gray-12' : 'text-current'}`}>
|
||||
<span class="truncate">{opt.title}</span>
|
||||
</div>
|
||||
<div class={`mt-0.5 flex items-center gap-3 text-[11px] ${active() ? 'text-gray-10' : 'text-gray-9 group-hover:text-gray-10'}`}>
|
||||
<span class="truncate">{opt.description ?? opt.providerID}</span>
|
||||
<span class="ml-auto opacity-70 font-mono">
|
||||
{opt.providerID}/{opt.modelID}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={opt.footer}>
|
||||
<div class={`text-[11px] mt-1 ${active() ? 'text-gray-10' : 'text-gray-8 group-hover:text-gray-9'}`}>{opt.footer}</div>
|
||||
</Show>
|
||||
<Show when={active() && (opt.behaviorOptions?.length ?? 0) > 0}>
|
||||
<div class="mt-3 flex items-center gap-2" onKeyDown={(e) => e.stopPropagation()}>
|
||||
<span class="text-[11px] font-medium text-gray-10 mr-1">{opt.behaviorTitle}:</span>
|
||||
<div class="flex flex-wrap items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
||||
<For each={opt.behaviorOptions}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`text-[11px] transition-colors ${
|
||||
opt.behaviorValue === option.value
|
||||
? "text-gray-12 font-semibold"
|
||||
: "text-gray-10 hover:text-gray-12"
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
props.onBehaviorChange(
|
||||
{ providerID: opt.providerID, modelID: opt.modelID },
|
||||
option.value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProviderLink = (provider: { providerID: string; title: string; matchCount: number }, index: number) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
ref={(el) => {
|
||||
optionRefs[index] = el as unknown as HTMLButtonElement;
|
||||
}}
|
||||
class={`group w-full text-left rounded-xl px-3 py-2.5 transition-colors cursor-pointer ${
|
||||
index === activeIndex()
|
||||
? "bg-gray-2 text-gray-12"
|
||||
: "text-gray-10 hover:bg-gray-1/70 hover:text-gray-11"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => {
|
||||
props.onClose({ restorePromptFocus: false });
|
||||
props.onOpenSettings();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
props.onClose({ restorePromptFocus: false });
|
||||
props.onOpenSettings();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<ProviderIcon providerId={provider.providerID} providerName={provider.title} size={16} class={`mt-[1px] shrink-0 transition-colors ${index === activeIndex() ? 'text-gray-12' : 'text-gray-10 group-hover:text-gray-11'}`} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class={`text-[13px] flex items-center justify-between gap-2 text-current`}>
|
||||
<span class="truncate">{provider.title}</span>
|
||||
</div>
|
||||
<div class={`mt-0.5 flex items-center gap-3 text-[11px] ${index === activeIndex() ? 'text-gray-10' : 'text-gray-9 group-hover:text-gray-10'}`}>
|
||||
<span class="truncate">{translate("model_picker.connect_provider_hint")}</span>
|
||||
<span class="ml-auto opacity-70">
|
||||
{translate(provider.matchCount === 1 ? "model_picker.model_count_one" : "model_picker.model_count", { count: provider.matchCount })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto">
|
||||
<div class="bg-dls-surface border border-dls-border w-full max-w-lg rounded-[24px] shadow-[var(--dls-shell-shadow)] overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col">
|
||||
<div class="p-6 flex flex-col min-h-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-12">
|
||||
{translate(props.target === "default" ? "model_picker.default_model_title" : "model_picker.chat_model_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-11 mt-1">
|
||||
{translate(props.target === "default"
|
||||
? "model_picker.default_model_desc"
|
||||
: "model_picker.chat_model_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!p-2 rounded-full"
|
||||
onClick={() => props.onClose()}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div class="relative">
|
||||
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-dls-secondary" />
|
||||
<input
|
||||
ref={(el) => (searchInputRef = el)}
|
||||
type="text"
|
||||
value={props.query}
|
||||
onInput={(e) => props.setQuery(e.currentTarget.value)}
|
||||
placeholder={translate("settings.search_models")}
|
||||
class="w-full bg-dls-surface border border-dls-border rounded-xl py-2.5 pl-9 pr-3 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"
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.query.trim()}>
|
||||
<div class="mt-2 text-xs text-dls-secondary">
|
||||
{translate("settings.showing_models", { count: props.filteredOptions.length, total: props.options.length })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4 overflow-y-auto pr-1 -mr-1 min-h-0">
|
||||
<Show when={recommendedOptions().length > 0}>
|
||||
<section class="space-y-2">
|
||||
<div class="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-9">
|
||||
{translate("model_picker.recommended")}
|
||||
</div>
|
||||
<For each={recommendedOptions()}>{({ opt, index }) => renderOption(opt, index)}</For>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={otherEnabledOptions().length > 0}>
|
||||
<section class="space-y-2">
|
||||
<div class="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-9">
|
||||
{translate("model_picker.other_connected_models")}
|
||||
</div>
|
||||
<For each={otherEnabledOptions()}>{({ opt, index }) => renderOption(opt, index)}</For>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={otherOptions().length > 0}>
|
||||
<section class="space-y-2">
|
||||
<div class="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-9">
|
||||
{translate("model_picker.more_providers")}
|
||||
</div>
|
||||
<For each={otherOptions()}>
|
||||
{(provider) => renderProviderLink(provider, provider.index)}
|
||||
</For>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={renderedItems().length === 0}>
|
||||
<div class="rounded-2xl border border-gray-6/70 bg-gray-1/40 px-4 py-6 text-sm text-gray-10">
|
||||
{translate("model_picker.no_results")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end shrink-0">
|
||||
<Button variant="outline" onClick={() => props.onClose()}>
|
||||
{translate("settings.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
||||
import type { QuestionInfo } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { Check, ChevronRight, HelpCircle } from "lucide-solid";
|
||||
|
||||
import Button from "./button";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export type QuestionModalProps = {
|
||||
open: boolean;
|
||||
questions: QuestionInfo[];
|
||||
busy: boolean;
|
||||
onReply: (answers: string[][]) => void;
|
||||
};
|
||||
|
||||
export default function QuestionModal(props: QuestionModalProps) {
|
||||
const [currentIndex, setCurrentIndex] = createSignal(0);
|
||||
const [answers, setAnswers] = createSignal<string[][]>([]);
|
||||
const [currentSelection, setCurrentSelection] = createSignal<string[]>([]);
|
||||
const [customInput, setCustomInput] = createSignal("");
|
||||
const [focusedOptionIndex, setFocusedOptionIndex] = createSignal(0);
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
setCurrentIndex(0);
|
||||
setAnswers(new Array(props.questions.length).fill([]));
|
||||
setCurrentSelection([]);
|
||||
setCustomInput("");
|
||||
setFocusedOptionIndex(0);
|
||||
}
|
||||
});
|
||||
|
||||
const currentQuestion = createMemo(() => props.questions[currentIndex()]);
|
||||
const isLastQuestion = createMemo(() => currentIndex() === props.questions.length - 1);
|
||||
const canProceed = createMemo(() => {
|
||||
const q = currentQuestion();
|
||||
if (!q) return false;
|
||||
if (q.custom && customInput().trim().length > 0) return true;
|
||||
return currentSelection().length > 0;
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (!canProceed()) return;
|
||||
|
||||
const q = currentQuestion();
|
||||
if (!q) return;
|
||||
|
||||
let answer: string[] = [...currentSelection()];
|
||||
if (q.custom && customInput().trim()) {
|
||||
answer.push(customInput().trim());
|
||||
}
|
||||
|
||||
const newAnswers = [...answers()];
|
||||
newAnswers[currentIndex()] = answer;
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (isLastQuestion()) {
|
||||
props.onReply(newAnswers);
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
setCurrentSelection([]);
|
||||
setCustomInput("");
|
||||
setFocusedOptionIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOption = (option: string) => {
|
||||
const q = currentQuestion();
|
||||
if (!q) return;
|
||||
|
||||
if (q.multiple) {
|
||||
setCurrentSelection((prev) =>
|
||||
prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option]
|
||||
);
|
||||
} else {
|
||||
setCurrentSelection([option]);
|
||||
if (!q.custom) {
|
||||
setTimeout(() => {
|
||||
const newAnswers = [...answers()];
|
||||
newAnswers[currentIndex()] = [option];
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (isLastQuestion()) {
|
||||
props.onReply(newAnswers);
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
setCurrentSelection([]);
|
||||
setCustomInput("");
|
||||
setFocusedOptionIndex(0);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const q = currentQuestion();
|
||||
if (!q) return;
|
||||
|
||||
const optionsCount = q.options.length;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusedOptionIndex((prev) => (prev + 1) % optionsCount);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusedOptionIndex((prev) => (prev - 1 + optionsCount) % optionsCount);
|
||||
} else if (e.key === "Enter") {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
e.preventDefault();
|
||||
if (q.custom && document.activeElement?.tagName === "INPUT") {
|
||||
handleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
const option = q.options[focusedOptionIndex()]?.description;
|
||||
if (option) {
|
||||
toggleOption(option);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.open && currentQuestion()}>
|
||||
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div class="p-6 border-b border-gray-6/40 bg-gray-2/50">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-9/20 flex items-center justify-center text-blue-9">
|
||||
<HelpCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-12">
|
||||
{currentQuestion()!.header || t("common.question")}
|
||||
</h3>
|
||||
<div class="text-xs text-gray-11 font-medium">
|
||||
{t("question_modal.question_counter", undefined, { current: currentIndex() + 1, total: props.questions.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-11 mt-2 leading-relaxed">
|
||||
{currentQuestion()!.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto min-h-0 flex-1">
|
||||
<div class="space-y-2">
|
||||
<For each={currentQuestion()!.options}>
|
||||
{(opt, idx) => {
|
||||
const isSelected = () => currentSelection().includes(opt.description);
|
||||
const isFocused = () => focusedOptionIndex() === idx();
|
||||
|
||||
return (
|
||||
<button
|
||||
class={`w-full text-left px-4 py-3 rounded-xl border text-sm transition-all duration-200 flex items-center justify-between group
|
||||
${isSelected()
|
||||
? "bg-blue-9/10 border-blue-9/30 text-gray-12 shadow-sm"
|
||||
: "bg-gray-1 border-gray-6 hover:border-gray-8 text-gray-11 hover:text-gray-12 hover:bg-gray-3"
|
||||
}
|
||||
${isFocused() ? "ring-2 ring-blue-9/20 border-blue-9/40 bg-gray-3" : ""}
|
||||
`}
|
||||
onClick={() => {
|
||||
setFocusedOptionIndex(idx());
|
||||
toggleOption(opt.description);
|
||||
}}
|
||||
>
|
||||
<span class="font-medium">{opt.description}</span>
|
||||
<Show when={isSelected()}>
|
||||
<div class="w-5 h-5 rounded-full bg-blue-9 flex items-center justify-center shadow-sm">
|
||||
<Check size={12} class="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={currentQuestion()!.custom}>
|
||||
<div class="mt-4 pt-4 border-t border-dls-border">
|
||||
<label class="block text-xs font-semibold text-dls-secondary mb-2 uppercase tracking-wide">
|
||||
{t("question_modal.custom_answer_label")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customInput()}
|
||||
onInput={(e) => setCustomInput(e.currentTarget.value)}
|
||||
class="w-full px-4 py-3 rounded-xl bg-dls-surface border border-dls-border focus:border-dls-accent focus:ring-4 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:outline-none text-sm text-dls-text placeholder:text-dls-secondary transition-shadow"
|
||||
placeholder={t("question_modal.custom_answer_placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
e.stopPropagation();
|
||||
handleNext();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-dls-border bg-dls-hover flex justify-between items-center">
|
||||
<div class="text-xs text-dls-secondary flex items-center gap-2">
|
||||
<span class="px-1.5 py-0.5 rounded border border-dls-border bg-dls-active font-mono">↑↓</span>
|
||||
<span>{t("common.navigate")}</span>
|
||||
<span class="px-1.5 py-0.5 rounded border border-gray-6 bg-gray-3 font-mono ml-2">↵</span>
|
||||
<span>{t("common.select")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Show when={currentQuestion()?.multiple || currentQuestion()?.custom}>
|
||||
<Button onClick={handleNext} disabled={!canProceed() || props.busy} class="!px-6">
|
||||
{isLastQuestion() ? t("common.submit") : t("common.next")}
|
||||
<Show when={!isLastQuestion()}>
|
||||
<ChevronRight size={16} class="ml-1 -mr-1 opacity-60" />
|
||||
</Show>
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
import { AlertTriangle, RefreshCcw, X } from "lucide-solid";
|
||||
|
||||
import Button from "./button";
|
||||
import type { ReloadTrigger } from "../types";
|
||||
|
||||
export type ReloadWorkspaceToastProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
trigger?: ReloadTrigger | null;
|
||||
warning?: string;
|
||||
blockedReason?: string | null;
|
||||
error?: string | null;
|
||||
reloadLabel: string;
|
||||
dismissLabel: string;
|
||||
busy?: boolean;
|
||||
canReload: boolean;
|
||||
hasActiveRuns: boolean;
|
||||
onReload: () => void;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export default function ReloadWorkspaceToast(props: ReloadWorkspaceToastProps) {
|
||||
const getDescription = () => {
|
||||
if (!props.trigger) return props.description;
|
||||
const { type, name, action } = props.trigger;
|
||||
const trimmedName = name?.trim();
|
||||
const verb =
|
||||
action === "removed"
|
||||
? "was removed"
|
||||
: action === "added"
|
||||
? "was added"
|
||||
: action === "updated"
|
||||
? "was updated"
|
||||
: "changed";
|
||||
|
||||
if (type === "skill") {
|
||||
return trimmedName
|
||||
? `Skill '${trimmedName}' ${verb}. Reload to use it.`
|
||||
: "Skills changed. Reload to apply.";
|
||||
}
|
||||
|
||||
if (type === "plugin") {
|
||||
return trimmedName
|
||||
? `Plugin '${trimmedName}' ${verb}. Reload to activate.`
|
||||
: "Plugins changed. Reload to apply.";
|
||||
}
|
||||
|
||||
if (type === "mcp") {
|
||||
return trimmedName
|
||||
? `MCP '${trimmedName}' ${verb}. Reload to connect.`
|
||||
: "MCP config changed. Reload to apply.";
|
||||
}
|
||||
|
||||
if (type === "config") {
|
||||
return trimmedName
|
||||
? `Config '${trimmedName}' ${verb}. Reload to apply.`
|
||||
: "Config changed. Reload to apply.";
|
||||
}
|
||||
|
||||
if (type === "agent") {
|
||||
return trimmedName
|
||||
? `Agent '${trimmedName}' ${verb}. Reload to use it.`
|
||||
: "Agents changed. Reload to apply.";
|
||||
}
|
||||
|
||||
if (type === "command") {
|
||||
return trimmedName
|
||||
? `Command '${trimmedName}' ${verb}. Reload to use it.`
|
||||
: "Commands changed. Reload to apply.";
|
||||
}
|
||||
|
||||
return "Config changed. Reload to apply.";
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<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 ${
|
||||
props.hasActiveRuns
|
||||
? "border-amber-6/40 bg-amber-4/80 text-amber-11"
|
||||
: "border-sky-6/40 bg-sky-4/80 text-sky-11"
|
||||
}`.trim()}
|
||||
>
|
||||
<RefreshCcw size={18} class={props.busy ? "animate-spin" : ""} />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-gray-12 truncate">{props.title}</span>
|
||||
<Show when={props.hasActiveRuns}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-4 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-11">
|
||||
Active tasks
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.description || props.error || props.warning || props.blockedReason}>
|
||||
<div class="mt-1 space-y-1 text-sm leading-relaxed text-gray-10">
|
||||
<div>
|
||||
{props.hasActiveRuns ? (
|
||||
<span class="font-medium text-amber-11">Reloading will stop active tasks.</span>
|
||||
) : props.error ? (
|
||||
<span class="font-medium text-red-11">{props.error}</span>
|
||||
) : (
|
||||
getDescription()
|
||||
)}
|
||||
</div>
|
||||
<Show when={props.warning}>
|
||||
<div class="flex items-start gap-2 rounded-2xl border border-amber-6/40 bg-amber-3/70 px-3 py-2 text-xs text-amber-11">
|
||||
<AlertTriangle size={14} class="mt-0.5 shrink-0" />
|
||||
<span>{props.warning}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.blockedReason}>
|
||||
<div class="text-xs text-gray-9">Blocked: {props.blockedReason}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onDismiss()}
|
||||
class="rounded-full p-1 text-gray-9 transition hover:bg-gray-3 hover:text-gray-12"
|
||||
aria-label={props.dismissLabel}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={props.hasActiveRuns ? "danger" : "primary"}
|
||||
class="rounded-full px-3 py-1.5 text-xs"
|
||||
onClick={() => props.onReload()}
|
||||
disabled={props.busy || !props.canReload}
|
||||
>
|
||||
{props.reloadLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" class="rounded-full px-3 py-1.5 text-xs" onClick={() => props.onDismiss()}>
|
||||
{props.dismissLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Show, createEffect } from "solid-js";
|
||||
import { X } from "lucide-solid";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
|
||||
import Button from "./button";
|
||||
import TextInput from "./text-input";
|
||||
|
||||
export type RenameSessionModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
busy: boolean;
|
||||
canSave: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onTitleChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function RenameSessionModal(props: RenameSessionModalProps) {
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
const translate = (key: string) => t(key, currentLocale());
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef?.focus();
|
||||
if (inputRef) {
|
||||
inputRef.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-12">{translate("session.rename_title")}</h3>
|
||||
<p class="text-sm text-gray-11 mt-1">{translate("session.rename_description")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label={translate("session.rename_label")}
|
||||
value={props.title}
|
||||
onInput={(e) => props.onTitleChange(e.currentTarget.value)}
|
||||
placeholder={translate("session.rename_placeholder")}
|
||||
class="bg-gray-3"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
if (props.canSave) props.onSave();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={props.onClose} disabled={props.busy}>
|
||||
{translate("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={props.onSave} disabled={!props.canSave}>
|
||||
{translate("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Show, createEffect } from "solid-js";
|
||||
import { X } from "lucide-solid";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
|
||||
import Button from "./button";
|
||||
import TextInput from "./text-input";
|
||||
|
||||
export type RenameWorkspaceModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
busy: boolean;
|
||||
canSave: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onTitleChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function RenameWorkspaceModal(props: RenameWorkspaceModalProps) {
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
const translate = (key: string) => t(key, currentLocale());
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef?.focus();
|
||||
if (inputRef) {
|
||||
inputRef.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-12">{translate("workspace.rename_title")}</h3>
|
||||
<p class="text-sm text-gray-11 mt-1">{translate("workspace.rename_description")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label={translate("workspace.rename_label")}
|
||||
value={props.title}
|
||||
onInput={(e) => props.onTitleChange(e.currentTarget.value)}
|
||||
placeholder={translate("workspace.rename_placeholder")}
|
||||
class="bg-gray-3"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
if (props.canSave) props.onSave();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={props.onClose} disabled={props.busy}>
|
||||
{translate("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={props.onSave} disabled={!props.canSave}>
|
||||
{translate("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { X } from "lucide-solid";
|
||||
import { t, type Language } from "../../i18n";
|
||||
|
||||
import Button from "./button";
|
||||
import TextInput from "./text-input";
|
||||
|
||||
const RESET_CONFIRM_PLACEHOLDER = "{resetWord}";
|
||||
const RESET_CONFIRM_WORD = "RESET";
|
||||
|
||||
export type ResetModalProps = {
|
||||
open: boolean;
|
||||
mode: "onboarding" | "all";
|
||||
text: string;
|
||||
busy: boolean;
|
||||
canReset: boolean;
|
||||
hasActiveRuns: boolean;
|
||||
language: Language;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onTextChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function ResetModal(props: ResetModalProps) {
|
||||
const translate = (key: string) => t(key, props.language);
|
||||
const resetConfirmationHint = () => {
|
||||
const template = translate("settings.reset_confirmation_hint");
|
||||
const parts = template.split(RESET_CONFIRM_PLACEHOLDER);
|
||||
|
||||
if (parts.length === 1) return template;
|
||||
|
||||
return parts.flatMap((part, index) =>
|
||||
index < parts.length - 1
|
||||
? [part, <span class="font-mono">{RESET_CONFIRM_WORD}</span>]
|
||||
: [part],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-xl rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-12">
|
||||
<Switch>
|
||||
<Match when={props.mode === "onboarding"}>{translate("settings.reset_onboarding_title")}</Match>
|
||||
<Match when={true}>{translate("settings.reset_app_data_title")}</Match>
|
||||
</Switch>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-11 mt-1">{resetConfirmationHint()}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!p-2 rounded-full"
|
||||
onClick={props.onClose}
|
||||
disabled={props.busy}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div class="rounded-xl bg-gray-1/20 border border-gray-6 p-3 text-xs text-gray-11">
|
||||
<Switch>
|
||||
<Match when={props.mode === "onboarding"}>
|
||||
{translate("settings.reset_onboarding_warning")}
|
||||
</Match>
|
||||
<Match when={true}>{translate("settings.reset_app_data_warning")}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={props.hasActiveRuns}>
|
||||
<div class="text-xs text-red-11">{translate("settings.reset_stop_active_runs")}</div>
|
||||
</Show>
|
||||
|
||||
<TextInput
|
||||
label={translate("settings.reset_confirmation_label")}
|
||||
placeholder={translate("settings.reset_confirmation_placeholder")}
|
||||
value={props.text}
|
||||
onInput={(e) => props.onTextChange(e.currentTarget.value)}
|
||||
disabled={props.busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={props.onClose} disabled={props.busy}>
|
||||
{translate("settings.reset_cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={props.onConfirm} disabled={!props.canReset}>
|
||||
{translate("settings.reset_confirm_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
import { X } from "lucide-solid";
|
||||
|
||||
import { currentLocale, t } from "../../i18n";
|
||||
import Button from "./button";
|
||||
|
||||
export type RestrictionNoticeModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function RestrictionNoticeModal(props: RestrictionNoticeModalProps) {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
|
||||
<div class="flex w-full max-w-[480px] flex-col overflow-hidden rounded-[24px] border border-dls-border bg-dls-surface shadow-[var(--dls-shell-shadow)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-dls-border px-6 py-5">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-[20px] font-semibold tracking-[-0.3px] text-dls-text">{props.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full text-dls-secondary transition-colors hover:bg-dls-hover hover:text-dls-text"
|
||||
aria-label={t("common.close", currentLocale())}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<p class="text-[14px] leading-6 text-dls-secondary">{props.message}</p>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<Button variant="primary" onClick={props.onClose}>
|
||||
{t("common.close", currentLocale())}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import { Check, ChevronDown } from "lucide-solid";
|
||||
|
||||
export type SelectMenuOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SelectMenuProps = {
|
||||
options: SelectMenuOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
/** For pairing with a visible `<label>` element */
|
||||
ariaLabelledBy?: string;
|
||||
/** When there is no visible label */
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
const triggerClass =
|
||||
"flex w-full items-center justify-between gap-2 rounded-xl border border-dls-border bg-dls-surface px-3.5 py-2.5 text-left text-[14px] text-dls-text shadow-none transition-[border-color,box-shadow] hover:border-dls-border focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.14)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
const panelClass =
|
||||
"absolute left-0 right-0 top-[calc(100%+6px)] z-[100] max-h-56 overflow-auto rounded-xl border border-dls-border bg-dls-surface py-1 shadow-[var(--dls-shell-shadow)]";
|
||||
|
||||
const optionRowClass =
|
||||
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] text-dls-text transition-colors hover:bg-dls-hover";
|
||||
|
||||
export default function SelectMenu(props: SelectMenuProps) {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
let rootEl: HTMLDivElement | undefined;
|
||||
|
||||
const displayLabel = createMemo(() => {
|
||||
const match = props.options.find((o) => o.value === props.value);
|
||||
if (match) return match.label;
|
||||
return props.placeholder?.trim() || "";
|
||||
});
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (rootEl && target && !rootEl.contains(target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
onCleanup(() => window.removeEventListener("pointerdown", onPointerDown, true));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={(el) => (rootEl = el)} class="relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
id={props.id}
|
||||
class={triggerClass}
|
||||
disabled={props.disabled}
|
||||
aria-expanded={open()}
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby={props.ariaLabelledBy}
|
||||
aria-label={props.ariaLabel}
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{displayLabel()}</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
class={`shrink-0 text-dls-secondary transition-transform duration-200 ${open() ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Show when={open() && !props.disabled}>
|
||||
<div class={panelClass} role="listbox">
|
||||
<For each={props.options}>
|
||||
{(opt) => (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === props.value}
|
||||
class={`${optionRowClass} ${opt.value === props.value ? "bg-dls-hover/80" : ""}`}
|
||||
onClick={() => {
|
||||
props.onChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||
<Show when={opt.value === props.value}>
|
||||
<Check size={16} class="shrink-0 text-[var(--dls-accent)]" aria-hidden />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
|
||||
const FOLLOW_LATEST_BOTTOM_GAP_PX = 96;
|
||||
const SCROLL_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
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;
|
||||
let lastGestureAt = 0;
|
||||
|
||||
const hasScrollGesture = () => Date.now() - lastGestureAt < SCROLL_GESTURE_WINDOW_MS;
|
||||
|
||||
const updateOverflowAnchor = () => {
|
||||
const container = options.containerRef();
|
||||
if (!container) return;
|
||||
container.style.overflowAnchor = mode() === "follow-latest" ? "none" : "auto";
|
||||
};
|
||||
|
||||
const markScrollGesture = (target?: EventTarget | null) => {
|
||||
const container = options.containerRef();
|
||||
if (!container) return;
|
||||
|
||||
const el = target instanceof Element ? target : undefined;
|
||||
const nested = el?.closest("[data-scrollable]");
|
||||
if (nested && nested !== container) return;
|
||||
|
||||
lastGestureAt = Date.now();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!hasScrollGesture()) {
|
||||
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(() => {
|
||||
mode();
|
||||
updateOverflowAnchor();
|
||||
});
|
||||
|
||||
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,
|
||||
markScrollGesture,
|
||||
scrollToBottom,
|
||||
jumpToLatest,
|
||||
jumpToStartOfMessage,
|
||||
};
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
} from "lucide-solid";
|
||||
|
||||
import { getDisplaySessionTitle } from "../../lib/session-title";
|
||||
import type { WorkspaceInfo } from "../../lib/tauri";
|
||||
import type {
|
||||
WorkspaceConnectionState,
|
||||
WorkspaceSessionGroup,
|
||||
} from "../../types";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getWorkspaceTaskLoadErrorDisplay,
|
||||
isSandboxWorkspace,
|
||||
isWindowsPlatform,
|
||||
} from "../../utils";
|
||||
import { t } from "../../../i18n";
|
||||
|
||||
type Props = {
|
||||
workspaceSessionGroups: WorkspaceSessionGroup[];
|
||||
showInitialLoading?: boolean;
|
||||
selectedWorkspaceId: string;
|
||||
developerMode: boolean;
|
||||
selectedSessionId: string | null;
|
||||
showSessionActions?: boolean;
|
||||
sessionStatusById?: Record<string, string>;
|
||||
connectingWorkspaceId: string | null;
|
||||
workspaceConnectionStateById: Record<string, WorkspaceConnectionState>;
|
||||
newTaskDisabled: boolean;
|
||||
onSelectWorkspace: (workspaceId: string) => Promise<boolean> | boolean | void;
|
||||
onOpenSession: (workspaceId: string, sessionId: string) => void;
|
||||
onPrefetchSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onCreateTaskInWorkspace: (workspaceId: string) => void;
|
||||
onOpenRenameSession?: () => void;
|
||||
onOpenDeleteSession?: () => void;
|
||||
onOpenRenameWorkspace: (workspaceId: string) => void;
|
||||
onShareWorkspace: (workspaceId: string) => void;
|
||||
onRevealWorkspace: (workspaceId: string) => void;
|
||||
onRecoverWorkspace: (
|
||||
workspaceId: string,
|
||||
) => Promise<boolean> | boolean | void;
|
||||
onTestWorkspaceConnection: (
|
||||
workspaceId: string,
|
||||
) => Promise<boolean> | boolean | void;
|
||||
onEditWorkspaceConnection: (workspaceId: string) => void;
|
||||
onForgetWorkspace: (workspaceId: string) => void;
|
||||
onOpenCreateWorkspace: () => void;
|
||||
};
|
||||
|
||||
const MAX_SESSIONS_PREVIEW = 6;
|
||||
|
||||
type SessionListItem = WorkspaceSessionGroup["sessions"][number];
|
||||
type FlattenedSessionRow = { session: SessionListItem; depth: number };
|
||||
type SessionTreeState = {
|
||||
childrenByParent: Map<string, SessionListItem[]>;
|
||||
ancestorIdsBySessionId: Map<string, string[]>;
|
||||
descendantCountBySessionId: Map<string, number>;
|
||||
activeIds: Set<string>;
|
||||
};
|
||||
|
||||
const normalizeSessionParentID = (session: SessionListItem) => {
|
||||
const parentID = session.parentID?.trim();
|
||||
return parentID || "";
|
||||
};
|
||||
|
||||
const getRootSessions = (sessions: WorkspaceSessionGroup["sessions"]) => {
|
||||
const byID = new Set(sessions.map((session) => session.id));
|
||||
return sessions.filter((session) => {
|
||||
const parentID = normalizeSessionParentID(session);
|
||||
return !parentID || !byID.has(parentID);
|
||||
});
|
||||
};
|
||||
|
||||
const buildSessionTreeState = (
|
||||
sessions: WorkspaceSessionGroup["sessions"],
|
||||
sessionStatusById: Record<string, string> | undefined,
|
||||
): SessionTreeState => {
|
||||
const childrenByParent = new Map<string, SessionListItem[]>();
|
||||
const ancestorIdsBySessionId = new Map<string, string[]>();
|
||||
const descendantCountBySessionId = new Map<string, number>();
|
||||
const activeIds = new Set<string>();
|
||||
const sessionIds = new Set(sessions.map((session) => session.id));
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const parentID = normalizeSessionParentID(session);
|
||||
if (!parentID || !sessionIds.has(parentID)) return;
|
||||
const siblings = childrenByParent.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
childrenByParent.set(parentID, siblings);
|
||||
});
|
||||
|
||||
const walk = (session: SessionListItem, ancestors: string[]) => {
|
||||
ancestorIdsBySessionId.set(session.id, ancestors);
|
||||
const children = childrenByParent.get(session.id) ?? [];
|
||||
let descendantCount = 0;
|
||||
let subtreeActive = (sessionStatusById?.[session.id] ?? "idle") !== "idle";
|
||||
|
||||
children.forEach((child) => {
|
||||
const childState = walk(child, [...ancestors, session.id]);
|
||||
descendantCount += 1 + childState.descendantCount;
|
||||
subtreeActive = subtreeActive || childState.subtreeActive;
|
||||
});
|
||||
|
||||
descendantCountBySessionId.set(session.id, descendantCount);
|
||||
if (subtreeActive) activeIds.add(session.id);
|
||||
return { descendantCount, subtreeActive };
|
||||
};
|
||||
|
||||
getRootSessions(sessions).forEach((session) => {
|
||||
walk(session, []);
|
||||
});
|
||||
|
||||
return {
|
||||
childrenByParent,
|
||||
ancestorIdsBySessionId,
|
||||
descendantCountBySessionId,
|
||||
activeIds,
|
||||
};
|
||||
};
|
||||
|
||||
const flattenSessionRows = (
|
||||
sessions: WorkspaceSessionGroup["sessions"],
|
||||
rootLimit: number,
|
||||
tree: SessionTreeState,
|
||||
expandedSessionIds: Set<string>,
|
||||
forcedExpandedSessionIds: Set<string>,
|
||||
) => {
|
||||
const roots = getRootSessions(sessions).slice(0, rootLimit);
|
||||
const rows: FlattenedSessionRow[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const walk = (session: SessionListItem, depth: number) => {
|
||||
if (visited.has(session.id)) return;
|
||||
visited.add(session.id);
|
||||
rows.push({ session, depth });
|
||||
const children = tree.childrenByParent.get(session.id) ?? [];
|
||||
if (!children.length) return;
|
||||
const expanded =
|
||||
expandedSessionIds.has(session.id) || forcedExpandedSessionIds.has(session.id);
|
||||
if (!expanded) return;
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
};
|
||||
|
||||
roots.forEach((root) => walk(root, 0));
|
||||
return rows;
|
||||
};
|
||||
|
||||
const workspaceLabel = (workspace: WorkspaceInfo) =>
|
||||
workspace.displayName?.trim() ||
|
||||
workspace.openworkWorkspaceName?.trim() ||
|
||||
workspace.name?.trim() ||
|
||||
workspace.path?.trim() ||
|
||||
t("workspace_list.workspace_fallback");
|
||||
|
||||
const workspaceKindLabel = (workspace: WorkspaceInfo) =>
|
||||
workspace.workspaceType === "remote"
|
||||
? isSandboxWorkspace(workspace)
|
||||
? t("workspace.sandbox_badge")
|
||||
: t("workspace.remote_badge")
|
||||
: t("workspace.local_badge");
|
||||
|
||||
const WORKSPACE_SWATCHES = ["#2563eb", "#5a67d8", "#f97316", "#10b981"];
|
||||
|
||||
const workspaceSwatchColor = (seed: string) => {
|
||||
const value = seed.trim() || "workspace";
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return WORKSPACE_SWATCHES[Math.abs(hash) % WORKSPACE_SWATCHES.length];
|
||||
};
|
||||
|
||||
export default function WorkspaceSessionList(props: Props) {
|
||||
const revealLabel = () => isWindowsPlatform()
|
||||
? t("workspace_list.reveal_explorer")
|
||||
: t("workspace_list.reveal_finder");
|
||||
const [expandedWorkspaceIds, setExpandedWorkspaceIds] = createSignal<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] =
|
||||
createSignal<Record<string, number>>({});
|
||||
const [workspaceMenuId, setWorkspaceMenuId] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
const [sessionMenuOpen, setSessionMenuOpen] = createSignal(false);
|
||||
const [expandedSessionIds, setExpandedSessionIds] = createSignal<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
let workspaceMenuRef: HTMLDivElement | undefined;
|
||||
let sessionMenuRef: HTMLDivElement | undefined;
|
||||
|
||||
const isWorkspaceExpanded = (workspaceId: string) =>
|
||||
expandedWorkspaceIds().has(workspaceId);
|
||||
|
||||
const expandWorkspace = (workspaceId: string) => {
|
||||
const id = workspaceId.trim();
|
||||
if (!id) return;
|
||||
setExpandedWorkspaceIds((prev) => {
|
||||
if (prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleWorkspaceExpanded = (workspaceId: string) => {
|
||||
const id = workspaceId.trim();
|
||||
if (!id) return;
|
||||
setExpandedWorkspaceIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
expandWorkspace(props.selectedWorkspaceId);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
expandWorkspace(props.selectedWorkspaceId);
|
||||
});
|
||||
|
||||
const previewCount = (workspaceId: string) =>
|
||||
previewCountByWorkspaceId()[workspaceId] ?? MAX_SESSIONS_PREVIEW;
|
||||
|
||||
const previewSessions = (
|
||||
workspaceId: string,
|
||||
sessions: WorkspaceSessionGroup["sessions"],
|
||||
tree: SessionTreeState,
|
||||
forcedExpandedSessionIds: Set<string>,
|
||||
) =>
|
||||
flattenSessionRows(
|
||||
sessions,
|
||||
previewCount(workspaceId),
|
||||
tree,
|
||||
expandedSessionIds(),
|
||||
forcedExpandedSessionIds,
|
||||
);
|
||||
|
||||
const toggleSessionExpanded = (sessionId: string) => {
|
||||
const id = sessionId.trim();
|
||||
if (!id) return;
|
||||
setExpandedSessionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const showMoreSessions = (workspaceId: string, totalRoots: number) => {
|
||||
expandWorkspace(workspaceId);
|
||||
setPreviewCountByWorkspaceId((current) => {
|
||||
const next = { ...current };
|
||||
const existing = next[workspaceId] ?? MAX_SESSIONS_PREVIEW;
|
||||
next[workspaceId] = Math.min(existing + MAX_SESSIONS_PREVIEW, totalRoots);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const showMoreLabel = (workspaceId: string, totalRoots: number) => {
|
||||
const remaining = Math.max(0, totalRoots - previewCount(workspaceId));
|
||||
const nextCount = Math.min(MAX_SESSIONS_PREVIEW, remaining);
|
||||
return nextCount > 0 ? t("workspace_list.show_more", undefined, { count: nextCount }) : t("workspace_list.show_more_fallback");
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!workspaceMenuId()) return;
|
||||
const closeMenu = (event: PointerEvent) => {
|
||||
if (!workspaceMenuRef) return;
|
||||
const target = event.target as Node | null;
|
||||
if (target && workspaceMenuRef.contains(target)) return;
|
||||
setWorkspaceMenuId(null);
|
||||
};
|
||||
window.addEventListener("pointerdown", closeMenu);
|
||||
onCleanup(() => window.removeEventListener("pointerdown", closeMenu));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
props.selectedSessionId;
|
||||
setSessionMenuOpen(false);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const workspaceId = props.selectedWorkspaceId.trim();
|
||||
if (!workspaceId) return;
|
||||
|
||||
const group = props.workspaceSessionGroups.find(
|
||||
(entry) => entry.workspace.id === workspaceId,
|
||||
);
|
||||
if (!group?.sessions.length) return;
|
||||
|
||||
const selectedId = props.selectedSessionId?.trim() ?? "";
|
||||
const selectedIndex = selectedId
|
||||
? group.sessions.findIndex((session) => session.id === selectedId)
|
||||
: -1;
|
||||
const start = selectedIndex >= 0 ? Math.max(0, selectedIndex - 2) : 0;
|
||||
const end = selectedIndex >= 0
|
||||
? Math.min(group.sessions.length, selectedIndex + 3)
|
||||
: Math.min(group.sessions.length, 4);
|
||||
|
||||
group.sessions.slice(start, end).forEach((session) => {
|
||||
props.onPrefetchSession?.(workspaceId, session.id);
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!sessionMenuOpen()) return;
|
||||
const closeMenu = (event: PointerEvent) => {
|
||||
if (!sessionMenuRef) return;
|
||||
const target = event.target as Node | null;
|
||||
if (target && sessionMenuRef.contains(target)) return;
|
||||
setSessionMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", closeMenu);
|
||||
onCleanup(() => window.removeEventListener("pointerdown", closeMenu));
|
||||
});
|
||||
|
||||
const renderSessionRow = (
|
||||
workspaceId: string,
|
||||
row: FlattenedSessionRow,
|
||||
tree: SessionTreeState,
|
||||
forcedExpandedSessionIds: Set<string>,
|
||||
) => {
|
||||
const session = () => row.session;
|
||||
const depth = () => row.depth;
|
||||
const isSelected = () => props.selectedSessionId === session().id;
|
||||
const displayTitle = () =>
|
||||
getDisplaySessionTitle(session().title);
|
||||
const hasChildren = () =>
|
||||
(tree.descendantCountBySessionId.get(session().id) ?? 0) > 0;
|
||||
const hiddenChildCount = () =>
|
||||
tree.descendantCountBySessionId.get(session().id) ?? 0;
|
||||
const isExpanded = () =>
|
||||
expandedSessionIds().has(session().id) ||
|
||||
forcedExpandedSessionIds.has(session().id);
|
||||
const isSessionActive = () => tree.activeIds.has(session().id);
|
||||
const canManageSession = () =>
|
||||
Boolean(
|
||||
props.showSessionActions &&
|
||||
isSelected() &&
|
||||
(props.onOpenRenameSession || props.onOpenDeleteSession),
|
||||
);
|
||||
|
||||
const openSession = () => {
|
||||
setSessionMenuOpen(false);
|
||||
props.onOpenSession(workspaceId, session().id);
|
||||
};
|
||||
|
||||
const prefetchSession = () => {
|
||||
if (workspaceId !== props.selectedWorkspaceId) return;
|
||||
props.onPrefetchSession?.(workspaceId, session().id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
class={`group flex min-h-9 w-full items-center justify-between rounded-xl px-3 py-1.5 text-left text-[13px] transition-colors ${
|
||||
isSelected()
|
||||
? "bg-gray-3 text-gray-12"
|
||||
: "text-gray-10 hover:bg-gray-1/70 hover:text-gray-11"
|
||||
}`}
|
||||
style={{ "margin-left": `${Math.min(depth(), 4) * 16}px` }}
|
||||
onPointerEnter={prefetchSession}
|
||||
onFocusIn={prefetchSession}
|
||||
onClick={openSession}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
openSession();
|
||||
}}
|
||||
>
|
||||
<div class="mr-2.5 flex min-w-0 flex-1 items-center gap-2">
|
||||
<Show
|
||||
when={hasChildren()}
|
||||
fallback={
|
||||
<Show when={depth() > 0}>
|
||||
<span class="h-[1px] w-3 shrink-0 rounded-full bg-dls-border" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="-ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-9 transition-colors hover:bg-gray-3/80 hover:text-gray-11"
|
||||
aria-label={isExpanded() ? t("workspace_list.hide_child_sessions") : t("workspace_list.show_child_sessions")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleSessionExpanded(session().id);
|
||||
}}
|
||||
>
|
||||
<Show when={isExpanded()} fallback={<ChevronRight size={13} />}>
|
||||
<ChevronDown size={13} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isSessionActive()}>
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-9" />
|
||||
</Show>
|
||||
<span
|
||||
class={`block min-w-0 truncate ${
|
||||
isSelected() ? "font-medium text-gray-12" : "font-normal text-current"
|
||||
}`}
|
||||
title={displayTitle()}
|
||||
>
|
||||
{displayTitle()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Show when={canManageSession()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-9 transition-colors hover:bg-gray-3/80 hover:text-gray-11"
|
||||
aria-label={t("workspace_list.session_actions")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSessionMenuOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={canManageSession() && sessionMenuOpen()}>
|
||||
<div
|
||||
ref={(el) => (sessionMenuRef = el)}
|
||||
class="absolute right-0 top-[calc(100%+6px)] z-20 w-48 rounded-[18px] border border-dls-border bg-dls-surface p-1.5 shadow-[var(--dls-shell-shadow)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Show when={props.onOpenRenameSession}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
setSessionMenuOpen(false);
|
||||
props.onOpenRenameSession?.();
|
||||
}}
|
||||
>
|
||||
{t("workspace_list.rename_session")}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onOpenDeleteSession}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-red-11 transition-colors hover:bg-red-1/40"
|
||||
onClick={() => {
|
||||
setSessionMenuOpen(false);
|
||||
props.onOpenDeleteSession?.();
|
||||
}}
|
||||
>
|
||||
{t("workspace_list.delete_session")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pr-1">
|
||||
<div class="space-y-2 pb-3">
|
||||
<For each={props.workspaceSessionGroups}>
|
||||
{(group) => {
|
||||
const tree = buildSessionTreeState(
|
||||
group.sessions,
|
||||
props.sessionStatusById,
|
||||
);
|
||||
const forcedExpandedSessionIds = new Set(
|
||||
props.selectedSessionId
|
||||
? tree.ancestorIdsBySessionId.get(props.selectedSessionId) ?? []
|
||||
: [],
|
||||
);
|
||||
const workspace = () => group.workspace;
|
||||
const isConnecting = () =>
|
||||
props.connectingWorkspaceId === workspace().id;
|
||||
const connectionState = () =>
|
||||
props.workspaceConnectionStateById[workspace().id] ?? {
|
||||
status: "idle",
|
||||
message: null,
|
||||
};
|
||||
const isConnectionActionBusy = () =>
|
||||
isConnecting() || connectionState().status === "connecting";
|
||||
const canRecover = () =>
|
||||
workspace().workspaceType === "remote" &&
|
||||
connectionState().status === "error";
|
||||
const isMenuOpen = () => workspaceMenuId() === workspace().id;
|
||||
const taskLoadError = () =>
|
||||
getWorkspaceTaskLoadErrorDisplay(workspace(), group.error);
|
||||
const statusLabel = () => {
|
||||
if (group.status === "error") return taskLoadError().label;
|
||||
if (isConnectionActionBusy()) return t("workspace_list.connecting");
|
||||
if (!props.developerMode) return "";
|
||||
if (props.selectedWorkspaceId === workspace().id) return t("workspace.selected");
|
||||
return workspaceKindLabel(workspace());
|
||||
};
|
||||
const statusTone = () => {
|
||||
if (group.status === "error") {
|
||||
return taskLoadError().tone === "offline"
|
||||
? "text-amber-11"
|
||||
: "text-red-11";
|
||||
}
|
||||
return "text-gray-9";
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-2">
|
||||
<div class="relative group">
|
||||
<div
|
||||
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.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.onSelectWorkspace(workspace().id),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
event.preventDefault();
|
||||
expandWorkspace(workspace().id);
|
||||
void Promise.resolve(
|
||||
props.onSelectWorkspace(workspace().id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3.5">
|
||||
<div
|
||||
class="flex h-5.5 w-5.5 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
"background-color": workspaceSwatchColor(
|
||||
workspace().id || workspaceLabel(workspace()),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="min-w-0 truncate text-[14px] font-normal text-dls-text">
|
||||
{workspaceLabel(workspace())}
|
||||
</div>
|
||||
<Show when={statusLabel()}>
|
||||
<div class={`mt-0.5 text-[11px] ${statusTone()}`}>
|
||||
{statusLabel()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4 flex shrink-0 items-center gap-1.5">
|
||||
<Show when={group.status === "loading" || isConnecting()}>
|
||||
<Loader2 size={14} class="animate-spin text-gray-9" />
|
||||
</Show>
|
||||
|
||||
<div class="hidden items-center gap-0.5 group-hover:flex group-focus-within:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-gray-9 hover:bg-gray-3/80 hover:text-gray-11"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onCreateTaskInWorkspace(workspace().id);
|
||||
}}
|
||||
disabled={props.newTaskDisabled}
|
||||
aria-label={t("session.new_task")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-gray-9 hover:bg-gray-3/80 hover:text-gray-11"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setWorkspaceMenuId((current) =>
|
||||
current === workspace().id
|
||||
? null
|
||||
: workspace().id,
|
||||
);
|
||||
}}
|
||||
aria-label={t("workspace_list.workspace_options")}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-gray-9 hover:bg-gray-3/80 hover:text-gray-11"
|
||||
aria-label={
|
||||
isWorkspaceExpanded(workspace().id)
|
||||
? t("sidebar.collapse")
|
||||
: t("sidebar.expand")
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleWorkspaceExpanded(workspace().id);
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={isWorkspaceExpanded(workspace().id)}
|
||||
fallback={<ChevronRight size={14} />}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isMenuOpen()}>
|
||||
<div
|
||||
ref={(el) => (workspaceMenuRef = el)}
|
||||
class="absolute right-0 top-[calc(100%+6px)] z-20 w-48 rounded-[18px] border border-dls-border bg-dls-surface p-1.5 shadow-[var(--dls-shell-shadow)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
props.onOpenRenameWorkspace(workspace().id);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
>
|
||||
{t("workspace_list.edit_name")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
props.onShareWorkspace(workspace().id);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
>
|
||||
{t("workspace_list.share")}
|
||||
</button>
|
||||
<Show when={workspace().workspaceType === "local"}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
props.onRevealWorkspace(workspace().id);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
>
|
||||
{revealLabel()}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={workspace().workspaceType === "remote"}>
|
||||
<Show when={canRecover()}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
void Promise.resolve(
|
||||
props.onRecoverWorkspace(workspace().id),
|
||||
);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
disabled={isConnectionActionBusy()}
|
||||
>
|
||||
{t("workspace_list.recover")}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
void Promise.resolve(
|
||||
props.onTestWorkspaceConnection(workspace().id),
|
||||
);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
disabled={isConnectionActionBusy()}
|
||||
>
|
||||
{t("workspace_list.test_connection")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-11 transition-colors hover:bg-gray-2"
|
||||
onClick={() => {
|
||||
props.onEditWorkspaceConnection(workspace().id);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
disabled={isConnectionActionBusy()}
|
||||
>
|
||||
{t("workspace_list.edit_connection")}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl px-3 py-2 text-left text-sm text-red-11 transition-colors hover:bg-red-1/40"
|
||||
onClick={() => {
|
||||
props.onForgetWorkspace(workspace().id);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
>
|
||||
{t("workspace_list.remove_workspace")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isWorkspaceExpanded(workspace().id)}>
|
||||
<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={props.showInitialLoading}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
group.status === "loading" &&
|
||||
group.sessions.length === 0
|
||||
}
|
||||
fallback={
|
||||
<Show
|
||||
when={group.sessions.length > 0}
|
||||
fallback={
|
||||
<Show when={group.status === "error"}>
|
||||
<div
|
||||
class={`w-full rounded-[15px] border px-3 py-2.5 text-left text-[11px] ${
|
||||
taskLoadError().tone === "offline"
|
||||
? "border-amber-7/35 bg-amber-2/50 text-amber-11"
|
||||
: "border-red-7/35 bg-red-1/40 text-red-11"
|
||||
}`}
|
||||
title={taskLoadError().title}
|
||||
>
|
||||
{taskLoadError().message}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={previewSessions(
|
||||
workspace().id,
|
||||
group.sessions,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
>
|
||||
{(row) =>
|
||||
renderSessionRow(
|
||||
workspace().id,
|
||||
row,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={
|
||||
group.sessions.length === 0 &&
|
||||
group.status === "ready"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group/empty 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={() =>
|
||||
props.onCreateTaskInWorkspace(workspace().id)
|
||||
}
|
||||
disabled={props.newTaskDisabled}
|
||||
>
|
||||
<span class="group-hover/empty:hidden">
|
||||
{t("workspace.no_tasks")}
|
||||
</span>
|
||||
<span class="hidden group-hover/empty:inline font-medium">
|
||||
{t("workspace.new_task_inline")}
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<div class="w-full rounded-[15px] px-3 py-2.5 text-left text-[11px] text-gray-10">
|
||||
{t("workspace.loading_tasks")}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={[0, 1, 2]}>
|
||||
{(idx) => (
|
||||
<div class="w-full rounded-[15px] border border-dls-border/70 bg-dls-hover/30 px-3 py-2.5">
|
||||
<div
|
||||
class="h-2.5 rounded-full bg-dls-hover/80 animate-pulse"
|
||||
style={{ width: idx === 0 ? "62%" : idx === 1 ? "78%" : "54%" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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={props.onOpenCreateWorkspace}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("workspace_list.add_workspace")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Show, createMemo } from "solid-js";
|
||||
import { BookOpen, MessageCircle, Settings } from "lucide-solid";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { useConnections } from "../connections/provider";
|
||||
import { usePlatform } from "../context/platform";
|
||||
import type { OpenworkServerStatus } from "../lib/openwork-server";
|
||||
|
||||
const DOCS_URL = "https://openworklabs.com/docs";
|
||||
|
||||
type StatusBarProps = {
|
||||
clientConnected: boolean;
|
||||
openworkServerStatus: OpenworkServerStatus;
|
||||
developerMode: boolean;
|
||||
settingsOpen: boolean;
|
||||
onSendFeedback: () => void;
|
||||
onOpenSettings: () => void;
|
||||
providerConnectedIds: string[];
|
||||
statusLabel?: string;
|
||||
statusDetail?: string;
|
||||
statusDotClass?: string;
|
||||
statusPingClass?: string;
|
||||
statusPulse?: boolean;
|
||||
showSettingsButton?: boolean;
|
||||
};
|
||||
|
||||
export default function StatusBar(props: StatusBarProps) {
|
||||
const connections = useConnections();
|
||||
const platform = usePlatform();
|
||||
const providerConnectedCount = createMemo(() => props.providerConnectedIds?.length ?? 0);
|
||||
const mcpConnectedCount = createMemo(
|
||||
() => Object.values(connections.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(t("status.providers_connected", undefined, { count: providers, plural: providers === 1 ? "" : "s" }));
|
||||
}
|
||||
if (mcp > 0) {
|
||||
detailBits.push(t("status.mcp_connected", undefined, { count: mcp }));
|
||||
}
|
||||
if (!detailBits.length) {
|
||||
detailBits.push(t("status.ready_for_tasks"));
|
||||
}
|
||||
if (props.developerMode) {
|
||||
detailBits.push(t("status.developer_mode"));
|
||||
}
|
||||
return {
|
||||
label: t("status.openwork_ready"),
|
||||
detail: detailBits.join(" · "),
|
||||
dotClass: "bg-green-9",
|
||||
pingClass: "bg-green-9/45 animate-ping",
|
||||
pulse: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (props.openworkServerStatus === "limited") {
|
||||
return {
|
||||
label: t("status.limited_mode"),
|
||||
detail:
|
||||
mcp > 0
|
||||
? t("status.limited_mcp_hint", undefined, { count: mcp })
|
||||
: t("status.limited_hint"),
|
||||
dotClass: "bg-amber-9",
|
||||
pingClass: "bg-amber-9/35",
|
||||
pulse: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t("status.disconnected_label"),
|
||||
detail: t("status.disconnected_hint"),
|
||||
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={() => platform.openLink(DOCS_URL)}
|
||||
title={t("status.open_docs")}
|
||||
aria-label={t("status.open_docs")}
|
||||
>
|
||||
<BookOpen class="h-4 w-4" />
|
||||
<span class="text-[11px] font-medium">{t("status.docs")}</span>
|
||||
</button>
|
||||
<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={t("status.send_feedback")}
|
||||
aria-label={t("status.send_feedback")}
|
||||
>
|
||||
<MessageCircle class="h-4 w-4" />
|
||||
<span class="text-[11px] font-medium">{t("status.feedback")}</span>
|
||||
</button>
|
||||
<Show when={props.showSettingsButton !== false}>
|
||||
<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 ? t("status.back") : t("status.settings")}
|
||||
aria-label={props.settingsOpen ? t("status.back") : t("status.settings")}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from "lucide-solid";
|
||||
|
||||
import Button from "./button";
|
||||
|
||||
export type StatusToastProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
tone?: "success" | "info" | "warning" | "error";
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
dismissLabel?: string;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export default function StatusToast(props: StatusToastProps) {
|
||||
const tone = () => props.tone ?? "info";
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<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={
|
||||
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="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-12">{props.title}</div>
|
||||
<Show when={props.description?.trim()}>
|
||||
<p class="mt-1 text-sm leading-relaxed text-gray-10">{props.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onDismiss}
|
||||
class="rounded-full p-1 text-gray-9 transition hover:bg-gray-3 hover:text-gray-12"
|
||||
aria-label={props.dismissLabel ?? "Dismiss"}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={props.actionLabel && props.onAction}>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Button variant="primary" class="rounded-full px-3 py-1.5 text-xs" onClick={() => props.onAction?.()}>
|
||||
{props.actionLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" class="rounded-full px-3 py-1.5 text-xs" onClick={props.onDismiss}>
|
||||
{props.dismissLabel ?? "Dismiss"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { splitProps, JSX } from "solid-js";
|
||||
|
||||
type TextInputProps = JSX.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export default function TextInput(props: TextInputProps) {
|
||||
const [local, rest] = splitProps(props, ["label", "hint", "class", "ref"]);
|
||||
|
||||
return (
|
||||
<label class="block">
|
||||
{local.label ? (
|
||||
<div class="mb-1 text-xs font-medium text-dls-secondary">{local.label}</div>
|
||||
) : null}
|
||||
<input
|
||||
{...rest}
|
||||
ref={local.ref}
|
||||
class={`w-full rounded-lg bg-dls-surface px-3 py-2 text-sm text-dls-text placeholder:text-dls-secondary border border-dls-border shadow-sm focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] ${
|
||||
local.class ?? ""
|
||||
}`.trim()}
|
||||
/>
|
||||
{local.hint ? <div class="mt-1 text-xs text-dls-secondary">{local.hint}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Show, createEffect } from "solid-js";
|
||||
import { ArrowUpRight } from "lucide-solid";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
type WebUnavailableSurfaceProps = {
|
||||
unavailable: boolean;
|
||||
children: JSX.Element;
|
||||
compact?: boolean;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
};
|
||||
|
||||
const MESSAGE =
|
||||
"This feature is currently unavailable in OpenWork Web, check OpenWork Desktop for full functionality.";
|
||||
|
||||
export default function WebUnavailableSurface(props: WebUnavailableSurfaceProps) {
|
||||
let contentRef: HTMLDivElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (!contentRef) return;
|
||||
if (props.unavailable) {
|
||||
contentRef.setAttribute("inert", "");
|
||||
contentRef.setAttribute("aria-disabled", "true");
|
||||
return;
|
||||
}
|
||||
contentRef.removeAttribute("inert");
|
||||
contentRef.removeAttribute("aria-disabled");
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<Show when={props.unavailable}>
|
||||
<div
|
||||
class={props.compact
|
||||
? "mb-3 rounded-xl border border-amber-7/30 bg-amber-2/45 px-3 py-2 text-[11px] text-amber-12"
|
||||
: "mb-4 rounded-2xl border border-amber-7/30 bg-amber-2/45 px-4 py-3 text-[13px] text-amber-12"}
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{MESSAGE}</span>
|
||||
<a
|
||||
href="https://openworklabs.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex items-center gap-1 underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
<span>Download OpenWork Desktop</span>
|
||||
<ArrowUpRight size={props.compact ? 12 : 14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={`relative ${props.contentClass ?? ""}`}>
|
||||
<div ref={contentRef} classList={{ "opacity-55": props.unavailable }}>
|
||||
{props.children}
|
||||
</div>
|
||||
<Show when={props.unavailable}>
|
||||
<div class="absolute inset-0 z-10 cursor-not-allowed" aria-hidden="true" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createContext, 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>
|
||||
);
|
||||
}
|
||||
@@ -1,673 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js";
|
||||
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
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 bootStartedAt = Date.now();
|
||||
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 [openworkServerHostInfoReady, setOpenworkServerHostInfoReady] = createSignal(!isTauriRuntime());
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
const shouldWaitForLocalHostInfo = () =>
|
||||
isTauriRuntime() &&
|
||||
options.startupPreference() !== "server" &&
|
||||
!openworkServerHostInfoReady();
|
||||
|
||||
const shouldRetryStartupCheck = (status: OpenworkServerStatus) =>
|
||||
status !== "connected" &&
|
||||
isTauriRuntime() &&
|
||||
options.startupPreference() !== "server" &&
|
||||
Date.now() - bootStartedAt < 5_000;
|
||||
|
||||
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;
|
||||
if (shouldWaitForLocalHostInfo()) 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 {
|
||||
let result = await checkOpenworkServer(url, token, hostToken);
|
||||
|
||||
if (shouldRetryStartupCheck(result.status)) {
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 250));
|
||||
if (!active) return;
|
||||
|
||||
try {
|
||||
const info = await openworkServerInfo();
|
||||
if (!active) return;
|
||||
|
||||
setOpenworkServerHostInfo(info);
|
||||
setOpenworkServerHostInfoReady(true);
|
||||
|
||||
const retryUrl = info.baseUrl?.trim() ?? "";
|
||||
const retryToken = info.clientToken?.trim() || undefined;
|
||||
const retryHostToken = info.hostToken?.trim() || undefined;
|
||||
if (retryUrl) {
|
||||
result = await checkOpenworkServer(retryUrl, retryToken, retryHostToken);
|
||||
}
|
||||
} catch {
|
||||
// ignore retry failures and surface the original result below
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} finally {
|
||||
if (active) setOpenworkServerHostInfoReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
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 : t("app.error_audit_load", currentLocale()));
|
||||
} 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(t("app.error_restart_local_worker", currentLocale()));
|
||||
}
|
||||
await reconnectOpenworkServer();
|
||||
}
|
||||
} catch (error) {
|
||||
updateOpenworkServerSettings(previous);
|
||||
setShareRemoteAccessError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("app.error_remote_access", currentLocale()),
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
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";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
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: t("automations.schedule_required") };
|
||||
}
|
||||
if (!prompt) {
|
||||
return { ok: false, error: t("automations.prompt_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: t("automations.prompt_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"
|
||||
? t("automations.server_unavailable")
|
||||
: options.openworkServer.openworkServerStatus() === "limited"
|
||||
? t("automations.server_needs_token")
|
||||
: t("automations.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 || t("automations.failed_to_load"));
|
||||
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 || t("automations.failed_to_load"));
|
||||
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(t("automations.server_unavailable"));
|
||||
}
|
||||
const response = await client.deleteScheduledJob(workspaceId, name);
|
||||
setScheduledJobs((current) => current.filter((entry) => entry.slug !== response.job.slug));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
throw new Error(t("automations.desktop_required"));
|
||||
}
|
||||
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,172 +0,0 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client";
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus";
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type ParentProps,
|
||||
} from "solid-js";
|
||||
|
||||
import { usePlatform } from "./platform";
|
||||
import { useServer } from "./server";
|
||||
|
||||
type GlobalSDKContextValue = {
|
||||
url: () => string;
|
||||
client: () => ReturnType<typeof createOpencodeClient>;
|
||||
event: ReturnType<typeof createGlobalEmitter<{ [key: string]: Event }>>;
|
||||
};
|
||||
|
||||
const GlobalSDKContext = createContext<GlobalSDKContextValue | undefined>(undefined);
|
||||
|
||||
export function GlobalSDKProvider(props: ParentProps) {
|
||||
const server = useServer();
|
||||
const platform = usePlatform();
|
||||
const emitter = createGlobalEmitter<{ [key: string]: Event }>();
|
||||
const [client, setClient] = createSignal(
|
||||
createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
}),
|
||||
);
|
||||
const [url, setUrl] = createSignal(server.url);
|
||||
|
||||
createEffect(() => {
|
||||
const baseUrl = server.url;
|
||||
const isHealthy = server.healthy() === true;
|
||||
|
||||
const token = (() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return (window.localStorage.getItem("openwork.server.token") ?? "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const headers = token && baseUrl.includes("/opencode") ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
setUrl(baseUrl);
|
||||
|
||||
// Always keep the request client in sync with the active URL.
|
||||
setClient(
|
||||
createOpencodeClient({
|
||||
baseUrl,
|
||||
headers,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Avoid silent retry loops (SSE reconnects) when the dependency is unavailable.
|
||||
if (!baseUrl || !isHealthy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
const eventClient = createOpencodeClient({
|
||||
baseUrl,
|
||||
headers,
|
||||
signal: abort.signal,
|
||||
fetch: platform.fetch,
|
||||
});
|
||||
|
||||
type Queued = { directory: string; payload: Event };
|
||||
|
||||
let queue: Array<Queued | undefined> = [];
|
||||
const coalesced = new Map<string, number>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let last = 0;
|
||||
|
||||
const keyForEvent = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`;
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`;
|
||||
if (payload.type === "todo.updated") return `todo.updated:${directory}:${payload.properties.sessionID}`;
|
||||
if (payload.type === "mcp.tools.changed") return `mcp.tools.changed:${directory}:${payload.properties.server}`;
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part;
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
|
||||
const events = queue;
|
||||
queue = [];
|
||||
coalesced.clear();
|
||||
if (events.length === 0) return;
|
||||
|
||||
last = Date.now();
|
||||
batch(() => {
|
||||
for (const entry of events) {
|
||||
if (!entry) continue;
|
||||
emitter.emit(entry.directory, entry.payload);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return;
|
||||
const elapsed = Date.now() - last;
|
||||
timer = setTimeout(flush, Math.max(0, 16 - elapsed));
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
flush();
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
const subscription = await eventClient.event.subscribe(undefined, { signal: abort.signal });
|
||||
let yielded = Date.now();
|
||||
|
||||
for await (const event of subscription.stream as AsyncIterable<unknown>) {
|
||||
const record = event as Event & { directory?: string; payload?: Event };
|
||||
const payload = record.payload ?? record;
|
||||
if (!payload?.type) continue;
|
||||
|
||||
const directory = typeof record.directory === "string" ? record.directory : "global";
|
||||
const key = keyForEvent(directory, payload);
|
||||
if (key) {
|
||||
const index = coalesced.get(key);
|
||||
if (index !== undefined) {
|
||||
queue[index] = undefined;
|
||||
}
|
||||
coalesced.set(key, queue.length);
|
||||
}
|
||||
|
||||
queue.push({ directory, payload });
|
||||
schedule();
|
||||
|
||||
if (Date.now() - yielded < 8) continue;
|
||||
yielded = Date.now();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
})()
|
||||
.finally(stop)
|
||||
.catch(() => undefined);
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort();
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
const value: GlobalSDKContextValue = {
|
||||
url,
|
||||
client,
|
||||
event: emitter,
|
||||
};
|
||||
|
||||
return <GlobalSDKContext.Provider value={value}>{props.children}</GlobalSDKContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGlobalSDK() {
|
||||
const context = useContext(GlobalSDKContext);
|
||||
if (!context) {
|
||||
throw new Error("Global SDK context is missing");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { createContext, createEffect, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store";
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ConfigProvidersResponse,
|
||||
Event,
|
||||
GlobalHealthResponse,
|
||||
LspStatus,
|
||||
Project,
|
||||
ProviderListResponse,
|
||||
ProviderAuthResponse,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { McpStatusMap, TodoItem } from "../types";
|
||||
import { unwrap } from "../lib/opencode";
|
||||
import { safeStringify } from "../utils";
|
||||
import { filterProviderList, mapConfigProvidersToList } from "../utils/providers";
|
||||
import { useGlobalSDK } from "./global-sdk";
|
||||
|
||||
export type WorkspaceState = {
|
||||
status: "idle" | "loading" | "partial" | "ready";
|
||||
session: Session[];
|
||||
session_status: Record<string, string>;
|
||||
message: Record<string, Message[]>;
|
||||
part: Record<string, Part[]>;
|
||||
todo: Record<string, TodoItem[]>;
|
||||
};
|
||||
|
||||
type WorkspaceStore = [Store<WorkspaceState>, SetStoreFunction<WorkspaceState>];
|
||||
|
||||
type ProjectMeta = {
|
||||
name?: string;
|
||||
icon?: Project["icon"];
|
||||
};
|
||||
|
||||
type GlobalState = {
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
serverVersion?: string;
|
||||
config: Config;
|
||||
provider: ProviderListResponse;
|
||||
providerAuth: ProviderAuthResponse;
|
||||
mcp: Record<string, McpStatusMap>;
|
||||
lsp: Record<string, LspStatus[]>;
|
||||
project: Project[];
|
||||
projectMeta: Record<string, ProjectMeta>;
|
||||
vcs: Record<string, VcsInfo | null>;
|
||||
};
|
||||
|
||||
type GlobalSyncContextValue = {
|
||||
data: Store<GlobalState>;
|
||||
set: SetStoreFunction<GlobalState>;
|
||||
child: (directory: string) => WorkspaceStore;
|
||||
refresh: () => Promise<void>;
|
||||
refreshDirectory: (directory: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const GlobalSyncContext = createContext<GlobalSyncContextValue | undefined>(undefined);
|
||||
|
||||
const createWorkspaceState = (): WorkspaceState => ({
|
||||
status: "idle",
|
||||
session: [],
|
||||
session_status: {},
|
||||
message: {},
|
||||
part: {},
|
||||
todo: {},
|
||||
});
|
||||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const globalSDK = useGlobalSDK();
|
||||
const defaultProvider: ProviderListResponse = { all: [], connected: [], default: {} };
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalState>({
|
||||
ready: false,
|
||||
error: undefined,
|
||||
serverVersion: undefined,
|
||||
config: {},
|
||||
provider: defaultProvider,
|
||||
providerAuth: {},
|
||||
mcp: {},
|
||||
lsp: {},
|
||||
project: [],
|
||||
projectMeta: {},
|
||||
vcs: {},
|
||||
});
|
||||
const children = new Map<string, WorkspaceStore>();
|
||||
const subscriptions = new Map<string, () => void>();
|
||||
|
||||
const keyFor = (directory: string) => directory || "global";
|
||||
|
||||
const setError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : safeStringify(error);
|
||||
setGlobalStore("error", message || t("app.unknown_error", currentLocale()));
|
||||
};
|
||||
|
||||
const setProjectMeta = (projects: Project[]) => {
|
||||
const next: Record<string, ProjectMeta> = {};
|
||||
for (const project of projects) {
|
||||
if (!project?.worktree) continue;
|
||||
next[project.worktree] = {
|
||||
name: project.name,
|
||||
icon: project.icon,
|
||||
};
|
||||
}
|
||||
setGlobalStore("projectMeta", next);
|
||||
};
|
||||
|
||||
const refreshConfig = async () => {
|
||||
const result = unwrap(await globalSDK.client().config.get());
|
||||
setGlobalStore("config", result);
|
||||
};
|
||||
|
||||
const refreshProviders = async () => {
|
||||
let disabledProviders = globalStore.config.disabled_providers ?? [];
|
||||
try {
|
||||
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",
|
||||
filterProviderList(
|
||||
{
|
||||
all: mapConfigProvidersToList(fallback.providers),
|
||||
connected: [],
|
||||
default: fallback.default,
|
||||
},
|
||||
disabledProviders,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshProviderAuth = async () => {
|
||||
try {
|
||||
const result = await globalSDK.client().provider.auth();
|
||||
setGlobalStore("providerAuth", result.data ?? {});
|
||||
} catch {
|
||||
setGlobalStore("providerAuth", {});
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMcp = async (directory?: string) => {
|
||||
const result = unwrap(await globalSDK.client().mcp.status({ directory })) as McpStatusMap;
|
||||
setGlobalStore("mcp", keyFor(directory ?? ""), result as McpStatusMap);
|
||||
};
|
||||
|
||||
const refreshLsp = async (directory?: string) => {
|
||||
const result = unwrap(await globalSDK.client().lsp.status({ directory })) as LspStatus[];
|
||||
setGlobalStore("lsp", keyFor(directory ?? ""), result as LspStatus[]);
|
||||
};
|
||||
|
||||
const refreshVcs = async (directory: string) => {
|
||||
try {
|
||||
const result = unwrap(await globalSDK.client().vcs.get({ directory })) as VcsInfo;
|
||||
setGlobalStore("vcs", keyFor(directory), result ?? null);
|
||||
} catch {
|
||||
setGlobalStore("vcs", keyFor(directory), null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshProjects = async () => {
|
||||
const projects = unwrap(await globalSDK.client().project.list()) as Project[];
|
||||
setGlobalStore("project", projects);
|
||||
setProjectMeta(projects);
|
||||
await Promise.allSettled(
|
||||
projects
|
||||
.map((project) => project.worktree)
|
||||
.filter((worktree): worktree is string => typeof worktree === "string" && worktree.length > 0)
|
||||
.map((worktree) => refreshVcs(worktree)),
|
||||
);
|
||||
};
|
||||
|
||||
const refreshDirectory = async (directory: string) => {
|
||||
if (!directory) return;
|
||||
await Promise.allSettled([
|
||||
refreshMcp(directory),
|
||||
refreshLsp(directory),
|
||||
refreshVcs(directory),
|
||||
]);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setGlobalStore("ready", false);
|
||||
setGlobalStore("error", undefined);
|
||||
|
||||
try {
|
||||
const health = unwrap(await globalSDK.client().global.health()) as GlobalHealthResponse;
|
||||
if (!health?.healthy) {
|
||||
setGlobalStore("error", "Server reported unhealthy status.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalStore.serverVersion && health.version !== globalStore.serverVersion) {
|
||||
setGlobalStore("mcp", {});
|
||||
setGlobalStore("lsp", {});
|
||||
setGlobalStore("project", []);
|
||||
setGlobalStore("projectMeta", {});
|
||||
setGlobalStore("vcs", {});
|
||||
}
|
||||
setGlobalStore("serverVersion", health.version);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
refreshConfig(),
|
||||
refreshProviders(),
|
||||
refreshProviderAuth(),
|
||||
refreshMcp(),
|
||||
refreshLsp(),
|
||||
refreshProjects(),
|
||||
]);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
setError(result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalStore("ready", true);
|
||||
};
|
||||
|
||||
const child = (directory: string): WorkspaceStore => {
|
||||
const key = keyFor(directory);
|
||||
const existing = children.get(key);
|
||||
if (existing) return existing;
|
||||
const store = createStore<WorkspaceState>(createWorkspaceState());
|
||||
children.set(key, store);
|
||||
void refreshDirectory(directory);
|
||||
if (!subscriptions.has(key)) {
|
||||
const unsubscribe = globalSDK.event.listen((payload) => {
|
||||
if (payload.name !== key) return;
|
||||
const event = payload.details as Event;
|
||||
if (event.type === "lsp.updated") {
|
||||
void refreshLsp(directory);
|
||||
}
|
||||
if (event.type === "mcp.tools.changed") {
|
||||
void refreshMcp(directory);
|
||||
}
|
||||
});
|
||||
subscriptions.set(key, unsubscribe);
|
||||
}
|
||||
return store;
|
||||
};
|
||||
|
||||
const value: GlobalSyncContextValue = {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
child,
|
||||
refresh,
|
||||
refreshDirectory,
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const url = globalSDK.url();
|
||||
if (!url) return;
|
||||
void refresh();
|
||||
});
|
||||
|
||||
const globalKey = keyFor("");
|
||||
if (!subscriptions.has(globalKey)) {
|
||||
const unsubscribe = globalSDK.event.listen((payload) => {
|
||||
if (payload.name !== globalKey) return;
|
||||
const event = payload.details as Event;
|
||||
if (event.type === "lsp.updated") {
|
||||
void refreshLsp();
|
||||
}
|
||||
if (event.type === "mcp.tools.changed") {
|
||||
void refreshMcp();
|
||||
}
|
||||
});
|
||||
subscriptions.set(globalKey, unsubscribe);
|
||||
}
|
||||
|
||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
const context = useContext(GlobalSyncContext);
|
||||
if (!context) {
|
||||
throw new Error("Global sync context is missing");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createContext, createEffect, useContext, type ParentProps } from "solid-js";
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store";
|
||||
|
||||
import { THINKING_PREF_KEY } from "../constants";
|
||||
import type { ModelRef, SettingsTab, View } from "../types";
|
||||
import { Persist, persisted } from "../utils/persist";
|
||||
|
||||
type LocalUIState = {
|
||||
view: View;
|
||||
tab: SettingsTab;
|
||||
};
|
||||
|
||||
type LocalPreferences = {
|
||||
showThinking: boolean;
|
||||
modelVariant: string | null;
|
||||
defaultModel: ModelRef | null;
|
||||
featureFlags: {
|
||||
microsandboxCreateSandbox: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type LocalContextValue = {
|
||||
ui: Store<LocalUIState>;
|
||||
setUi: SetStoreFunction<LocalUIState>;
|
||||
prefs: Store<LocalPreferences>;
|
||||
setPrefs: SetStoreFunction<LocalPreferences>;
|
||||
ready: () => boolean;
|
||||
};
|
||||
|
||||
const LocalContext = createContext<LocalContextValue | undefined>(undefined);
|
||||
|
||||
export function LocalProvider(props: ParentProps) {
|
||||
const [ui, setUi, , uiReady] = persisted(
|
||||
Persist.global("local.ui", ["openwork.ui"]),
|
||||
createStore<LocalUIState>({
|
||||
view: "settings",
|
||||
tab: "general",
|
||||
}),
|
||||
);
|
||||
|
||||
const [prefs, setPrefs, , prefsReady] = persisted(
|
||||
Persist.global("local.preferences", ["openwork.preferences"]),
|
||||
createStore<LocalPreferences>({
|
||||
showThinking: false,
|
||||
modelVariant: null,
|
||||
defaultModel: null,
|
||||
featureFlags: {
|
||||
microsandboxCreateSandbox: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
prefs,
|
||||
setPrefs,
|
||||
ready,
|
||||
};
|
||||
|
||||
return <LocalContext.Provider value={value}>{props.children}</LocalContext.Provider>;
|
||||
}
|
||||
|
||||
export function useLocal() {
|
||||
const context = useContext(LocalContext);
|
||||
if (!context) {
|
||||
throw new Error("Local context is missing");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
export type SyncStorage = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
export type AsyncStorage = {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type Platform = {
|
||||
platform: "web" | "desktop";
|
||||
os?: "macos" | "windows" | "linux";
|
||||
version?: string;
|
||||
openLink(url: string): void;
|
||||
restart(): Promise<void>;
|
||||
notify(title: string, description?: string, href?: string): Promise<void>;
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage;
|
||||
checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>;
|
||||
update?: () => Promise<void>;
|
||||
fetch?: typeof fetch;
|
||||
getDefaultServerUrl?: () => Promise<string | null>;
|
||||
setDefaultServerUrl?: (url: string | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const PlatformContext = createContext<Platform | undefined>(undefined);
|
||||
|
||||
export function PlatformProvider(props: ParentProps & { value: Platform }) {
|
||||
return (
|
||||
<PlatformContext.Provider value={props.value}>
|
||||
{props.children}
|
||||
</PlatformContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatform() {
|
||||
const context = useContext(PlatformContext);
|
||||
if (!context) {
|
||||
throw new Error("Platform context is missing");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createProvidersStore } from "./store";
|
||||
export type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store";
|
||||
export { default as ProviderAuthModal } from "./provider-auth-modal";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
export type SandboxBackendType = "docker" | "microsandbox";
|
||||
|
||||
export type SandboxCreateModeConfig = {
|
||||
backend: SandboxBackendType;
|
||||
sandboxImageRef: string | null;
|
||||
runtimeReadyLabel: string;
|
||||
runtimeCheckingStage: string;
|
||||
};
|
||||
|
||||
export const MICRO_SANDBOX_IMAGE_REF = "openwork-microsandbox:dev";
|
||||
|
||||
export function resolveSandboxCreateMode(useMicrosandbox: boolean): SandboxCreateModeConfig {
|
||||
if (useMicrosandbox) {
|
||||
return {
|
||||
backend: "microsandbox",
|
||||
sandboxImageRef: MICRO_SANDBOX_IMAGE_REF,
|
||||
runtimeReadyLabel: "Microsandbox runtime ready",
|
||||
runtimeCheckingStage: "Checking sandbox runtime...",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backend: "docker",
|
||||
sandboxImageRef: null,
|
||||
runtimeReadyLabel: "Docker ready",
|
||||
runtimeCheckingStage: "Checking Docker...",
|
||||
};
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js";
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
import { isWebDeployment } from "../lib/openwork-deployment";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||
return withProtocol.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function serverDisplayName(url: string) {
|
||||
if (!url) return "";
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
type ServerContextValue = {
|
||||
url: string;
|
||||
name: string;
|
||||
list: string[];
|
||||
healthy: () => boolean | undefined;
|
||||
setActive: (url: string) => void;
|
||||
add: (url: string) => void;
|
||||
remove: (url: string) => void;
|
||||
};
|
||||
|
||||
const ServerContext = createContext<ServerContextValue | undefined>(undefined);
|
||||
|
||||
export function ServerProvider(props: ParentProps & { defaultUrl: string }) {
|
||||
const [list, setList] = createSignal<string[]>([]);
|
||||
const [active, setActiveRaw] = createSignal("");
|
||||
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined);
|
||||
const [ready, setReady] = createSignal(false);
|
||||
|
||||
const readStoredList = () => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem("openwork.server.list");
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readStoredActive = () => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("openwork.server.active");
|
||||
return typeof stored === "string" ? stored : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (ready()) return;
|
||||
|
||||
const fallback = normalizeServerUrl(props.defaultUrl) ?? "";
|
||||
|
||||
// In hosted web deployments served by OpenWork, OpenCode
|
||||
// traffic should go through the server proxy (usually same-origin `/opencode`).
|
||||
// Do not reuse any persisted localhost targets.
|
||||
const forceProxy =
|
||||
!isTauriRuntime() &&
|
||||
isWebDeployment() &&
|
||||
(import.meta.env.PROD ||
|
||||
(typeof import.meta.env?.VITE_OPENWORK_URL === "string" &&
|
||||
import.meta.env.VITE_OPENWORK_URL.trim().length > 0));
|
||||
if (forceProxy && fallback) {
|
||||
setList([fallback]);
|
||||
setActiveRaw(fallback);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedList = readStoredList();
|
||||
const storedActive = normalizeServerUrl(readStoredActive());
|
||||
|
||||
const initialList = storedList.length ? storedList : fallback ? [fallback] : [];
|
||||
const initialActive = storedActive || initialList[0] || fallback || "";
|
||||
|
||||
setList(initialList);
|
||||
setActiveRaw(initialActive);
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem("openwork.server.list", JSON.stringify(list()));
|
||||
window.localStorage.setItem("openwork.server.active", active());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
const activeUrl = createMemo(() => active());
|
||||
|
||||
const readOpenworkToken = () => {
|
||||
try {
|
||||
return (window.localStorage.getItem("openwork.server.token") ?? "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (url: string) => {
|
||||
if (!url) return false;
|
||||
const token = readOpenworkToken();
|
||||
const headers = token && url.includes("/opencode") ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
fetch: isTauriRuntime() ? tauriFetch : undefined,
|
||||
});
|
||||
return client.global
|
||||
.health()
|
||||
.then((result) => result.data?.healthy === true)
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const url = activeUrl();
|
||||
if (!url) return;
|
||||
|
||||
setHealthy(undefined);
|
||||
|
||||
let activeRun = true;
|
||||
let busy = false;
|
||||
|
||||
const run = () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
void checkHealth(url)
|
||||
.then((next) => {
|
||||
if (!activeRun) return;
|
||||
setHealthy(next);
|
||||
})
|
||||
.finally(() => {
|
||||
busy = false;
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
const interval = window.setInterval(run, 10_000);
|
||||
|
||||
onCleanup(() => {
|
||||
activeRun = false;
|
||||
window.clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
const setActive = (input: string) => {
|
||||
const next = normalizeServerUrl(input);
|
||||
if (!next) return;
|
||||
setActiveRaw(next);
|
||||
};
|
||||
|
||||
const add = (input: string) => {
|
||||
const next = normalizeServerUrl(input);
|
||||
if (!next) return;
|
||||
|
||||
setList((current) => {
|
||||
if (current.includes(next)) return current;
|
||||
return [...current, next];
|
||||
});
|
||||
setActiveRaw(next);
|
||||
};
|
||||
|
||||
const remove = (input: string) => {
|
||||
const next = normalizeServerUrl(input);
|
||||
if (!next) return;
|
||||
|
||||
setList((current) => current.filter((item) => item !== next));
|
||||
setActiveRaw((current) => {
|
||||
if (current !== next) return current;
|
||||
const remaining = list().filter((item) => item !== next);
|
||||
return remaining[0] ?? "";
|
||||
});
|
||||
};
|
||||
|
||||
const value: ServerContextValue = {
|
||||
get url() {
|
||||
return activeUrl();
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(activeUrl());
|
||||
},
|
||||
get list() {
|
||||
return list();
|
||||
},
|
||||
healthy,
|
||||
setActive,
|
||||
add,
|
||||
remove,
|
||||
};
|
||||
|
||||
return <ServerContext.Provider value={value}>{props.children}</ServerContext.Provider>;
|
||||
}
|
||||
|
||||
export function useServer() {
|
||||
const context = useContext(ServerContext);
|
||||
if (!context) {
|
||||
throw new Error("Server context is missing");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,224 +0,0 @@
|
||||
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,
|
||||
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 = toSessionTransportDirectory(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,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
import type { UpdateHandle } from "../types";
|
||||
import type { UpdaterEnvironment } from "../lib/tauri";
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: "idle"; lastCheckedAt: number | null }
|
||||
| { state: "checking"; startedAt: number }
|
||||
| { state: "available"; lastCheckedAt: number; version: string; date?: string; notes?: string }
|
||||
| {
|
||||
state: "downloading";
|
||||
lastCheckedAt: number;
|
||||
version: string;
|
||||
totalBytes: number | null;
|
||||
downloadedBytes: number;
|
||||
notes?: string;
|
||||
}
|
||||
| { state: "ready"; lastCheckedAt: number; version: string; notes?: string }
|
||||
| { state: "error"; lastCheckedAt: number | null; message: string };
|
||||
|
||||
export type PendingUpdate = { update: UpdateHandle; version: string; notes?: string } | null;
|
||||
|
||||
export function createUpdaterState() {
|
||||
const [updateAutoCheck, setUpdateAutoCheck] = createSignal(true);
|
||||
const [updateAutoDownload, setUpdateAutoDownload] = createSignal(false);
|
||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({ state: "idle", lastCheckedAt: null });
|
||||
const [pendingUpdate, setPendingUpdate] = createSignal<PendingUpdate>(null);
|
||||
const [updateEnv, setUpdateEnv] = createSignal<UpdaterEnvironment | null>(null);
|
||||
|
||||
return {
|
||||
updateAutoCheck,
|
||||
setUpdateAutoCheck,
|
||||
updateAutoDownload,
|
||||
setUpdateAutoDownload,
|
||||
updateStatus,
|
||||
setUpdateStatus,
|
||||
pendingUpdate,
|
||||
setPendingUpdate,
|
||||
updateEnv,
|
||||
setUpdateEnv,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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
@@ -1,55 +0,0 @@
|
||||
import App from "./app";
|
||||
import { DenAuthProvider } from "./cloud/den-auth-provider";
|
||||
import { DesktopConfigProvider } from "./cloud/desktop-config-provider";
|
||||
import { GlobalSDKProvider } from "./context/global-sdk";
|
||||
import { GlobalSyncProvider } from "./context/global-sync";
|
||||
import { LocalProvider } from "./context/local";
|
||||
import { ServerProvider } from "./context/server";
|
||||
import { isWebDeployment } from "./lib/openwork-deployment";
|
||||
import { isTauriRuntime } from "./utils";
|
||||
|
||||
export default function AppEntry() {
|
||||
const defaultUrl = (() => {
|
||||
// Desktop app connects to the local OpenCode engine.
|
||||
if (isTauriRuntime()) return "http://127.0.0.1:4096";
|
||||
|
||||
// When running the web UI against an OpenWork server (e.g. Docker dev stack),
|
||||
// use the server's `/opencode` proxy instead of loopback.
|
||||
const openworkUrl =
|
||||
typeof import.meta.env?.VITE_OPENWORK_URL === "string"
|
||||
? import.meta.env.VITE_OPENWORK_URL.trim()
|
||||
: "";
|
||||
if (openworkUrl) {
|
||||
return `${openworkUrl.replace(/\/+$/, "")}/opencode`;
|
||||
}
|
||||
|
||||
// When the hosted web deployment is served by the OpenWork server,
|
||||
// OpenCode is proxied at same-origin `/opencode`.
|
||||
if (isWebDeployment() && import.meta.env.PROD && typeof window !== "undefined") {
|
||||
return `${window.location.origin}/opencode`;
|
||||
}
|
||||
|
||||
// Dev fallback (Vite) - allow overriding for remote debugging.
|
||||
const envUrl =
|
||||
typeof import.meta.env?.VITE_OPENCODE_URL === "string"
|
||||
? import.meta.env.VITE_OPENCODE_URL.trim()
|
||||
: "";
|
||||
return envUrl || "http://127.0.0.1:4096";
|
||||
})();
|
||||
|
||||
return (
|
||||
<ServerProvider defaultUrl={defaultUrl}>
|
||||
<DenAuthProvider>
|
||||
<DesktopConfigProvider>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<LocalProvider>
|
||||
<App />
|
||||
</LocalProvider>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</DesktopConfigProvider>
|
||||
</DenAuthProvider>
|
||||
</ServerProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -44,6 +44,10 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--openwork-font-size, 16px);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
import { createDenClient, type DenTemplate } from "./den";
|
||||
|
||||
type DenTemplateCacheKeyInput = {
|
||||
@@ -17,7 +15,6 @@ type DenTemplateCacheEntry = {
|
||||
};
|
||||
|
||||
const templateCache = new Map<string, DenTemplateCacheEntry>();
|
||||
const [templateCacheVersion, setTemplateCacheVersion] = createSignal(0);
|
||||
|
||||
function getCacheKey(input: DenTemplateCacheKeyInput): string | null {
|
||||
const baseUrl = input.baseUrl?.trim() ?? "";
|
||||
@@ -51,7 +48,6 @@ function readEntry(key: string | null): DenTemplateCacheEntry {
|
||||
|
||||
function writeEntry(key: string, next: DenTemplateCacheEntry) {
|
||||
templateCache.set(key, next);
|
||||
setTemplateCacheVersion((value) => value + 1);
|
||||
}
|
||||
|
||||
function toMessage(error: unknown, fallback: string) {
|
||||
@@ -59,7 +55,6 @@ function toMessage(error: unknown, fallback: string) {
|
||||
}
|
||||
|
||||
export function readDenTemplateCacheSnapshot(input: DenTemplateCacheKeyInput) {
|
||||
templateCacheVersion();
|
||||
const key = getCacheKey(input);
|
||||
const entry = readEntry(key);
|
||||
return {
|
||||
@@ -127,5 +122,4 @@ export async function loadDenTemplateCache(
|
||||
export function clearDenTemplateCache() {
|
||||
if (templateCache.size === 0) return;
|
||||
templateCache.clear();
|
||||
setTemplateCacheVersion((value) => value + 1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { normalizeDesktopConfig, type DesktopConfig as SharedDesktopConfig } from "@openwork/types/den/desktop-app-restrictions";
|
||||
import {
|
||||
normalizeDesktopConfig,
|
||||
type DesktopConfig as SharedDesktopConfig,
|
||||
} from "@openwork/types/den/desktop-app-restrictions";
|
||||
|
||||
// Re-export the shared schema under the local alias so React consumers
|
||||
// (e.g. the cloud domain's desktop-config provider) can import it alongside
|
||||
// the helpers they need. Solid references it internally only; the React
|
||||
// port wants it as part of the public surface of this module.
|
||||
export type { SharedDesktopConfig };
|
||||
export { normalizeDesktopConfig };
|
||||
|
||||
import { isDesktopDeployment } from "./openwork-deployment";
|
||||
import {
|
||||
dispatchDenSettingsChanged,
|
||||
|
||||
@@ -266,6 +266,32 @@ const resolveAuthHeader = (auth?: OpencodeAuth) => {
|
||||
return encoded ? `Basic ${encoded}` : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* URLs whose response body we must stream chunk-by-chunk (SSE, long-running
|
||||
* message streams, event subscriptions). The Tauri HTTP plugin's
|
||||
* `fetch_read_body` IPC call blocks until the entire body is delivered, so
|
||||
* pointing it at an infinite stream freezes the webview's main thread for
|
||||
* minutes. For these endpoints we always use the webview's native fetch —
|
||||
* CORS is already wide open on the openwork/opencode stack, so there's no
|
||||
* reason to route them through the plugin.
|
||||
*/
|
||||
const STREAM_URL_RE = /\/(event|stream)(\b|\/|$|\?)/;
|
||||
|
||||
function requestIsStreaming(input: RequestInfo | URL, init?: RequestInit): boolean {
|
||||
const url = getRequestUrl(input);
|
||||
if (STREAM_URL_RE.test(url)) return true;
|
||||
const accept =
|
||||
input instanceof Request
|
||||
? input.headers.get("accept") ?? input.headers.get("Accept")
|
||||
: new Headers(init?.headers).get("accept") ?? new Headers(init?.headers).get("Accept");
|
||||
return typeof accept === "string" && accept.toLowerCase().includes("text/event-stream");
|
||||
}
|
||||
|
||||
function nativeFetchRef(): typeof globalThis.fetch {
|
||||
if (typeof window !== "undefined" && typeof window.fetch === "function") return window.fetch.bind(window);
|
||||
return globalThis.fetch as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const createTauriFetch = (auth?: OpencodeAuth) => {
|
||||
const authHeader = resolveAuthHeader(auth);
|
||||
const addAuth = (headers: Headers) => {
|
||||
@@ -274,28 +300,33 @@ const createTauriFetch = (auth?: OpencodeAuth) => {
|
||||
};
|
||||
|
||||
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// Streams must go through the webview's native fetch to avoid the
|
||||
// Tauri HTTP plugin's `fetch_read_body` hang on never-closing bodies.
|
||||
const shouldStream = requestIsStreaming(input, init);
|
||||
const underlyingFetch = shouldStream
|
||||
? nativeFetchRef()
|
||||
: (tauriFetch as unknown as typeof globalThis.fetch);
|
||||
// Streams should never be timed out at the transport layer; the caller
|
||||
// aborts via AbortSignal when the subscription unmounts.
|
||||
const timeoutMs = shouldStream ? 0 : DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS;
|
||||
|
||||
if (input instanceof Request) {
|
||||
const headers = new Headers(input.headers);
|
||||
addAuth(headers);
|
||||
const request = new Request(input, { headers });
|
||||
return fetchWithTimeout(
|
||||
tauriFetch as unknown as typeof globalThis.fetch,
|
||||
request,
|
||||
undefined,
|
||||
DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
return fetchWithTimeout(underlyingFetch, request, undefined, timeoutMs);
|
||||
}
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
addAuth(headers);
|
||||
return fetchWithTimeout(
|
||||
tauriFetch as unknown as typeof globalThis.fetch,
|
||||
underlyingFetch,
|
||||
input,
|
||||
{
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS,
|
||||
timeoutMs,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -751,8 +751,22 @@ function buildAuthHeaders(token?: string, hostToken?: string, extra?: Record<str
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Use Tauri's fetch when running in the desktop app to avoid CORS issues
|
||||
const resolveFetch = () => (isTauriRuntime() ? tauriFetch : globalThis.fetch);
|
||||
// Use Tauri's fetch when running in the desktop app to avoid CORS issues.
|
||||
// Stream URLs (SSE) bypass the plugin because its `fetch_read_body` IPC call
|
||||
// blocks until the body closes — that freezes the webview for infinite bodies.
|
||||
const OPENWORK_STREAM_URL_RE = /\/events(\b|\?)|\/event-stream\b|\/stream\b/;
|
||||
|
||||
function isStreamUrl(url: string): boolean {
|
||||
return OPENWORK_STREAM_URL_RE.test(url);
|
||||
}
|
||||
|
||||
const resolveFetch = (url?: string) => {
|
||||
if (!isTauriRuntime()) return globalThis.fetch;
|
||||
if (url && isStreamUrl(url)) {
|
||||
return typeof window !== "undefined" ? window.fetch.bind(window) : globalThis.fetch;
|
||||
}
|
||||
return tauriFetch;
|
||||
};
|
||||
|
||||
const DEFAULT_OPENWORK_SERVER_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -803,7 +817,7 @@ async function requestJson<T>(
|
||||
options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const fetchImpl = resolveFetch();
|
||||
const fetchImpl = resolveFetch(url);
|
||||
const response = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
url,
|
||||
@@ -833,7 +847,7 @@ async function requestJsonRaw<T>(
|
||||
options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {},
|
||||
): Promise<RawJsonResponse<T>> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const fetchImpl = resolveFetch();
|
||||
const fetchImpl = resolveFetch(url);
|
||||
const response = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
url,
|
||||
@@ -862,7 +876,7 @@ async function requestMultipartRaw(
|
||||
options: { method?: string; token?: string; hostToken?: string; body?: FormData; timeoutMs?: number } = {},
|
||||
): Promise<{ ok: boolean; status: number; text: string }>{
|
||||
const url = `${baseUrl}${path}`;
|
||||
const fetchImpl = resolveFetch();
|
||||
const fetchImpl = resolveFetch(url);
|
||||
const response = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
url,
|
||||
@@ -883,7 +897,7 @@ async function requestBinary(
|
||||
options: { method?: string; token?: string; hostToken?: string; timeoutMs?: number } = {},
|
||||
): Promise<{ data: ArrayBuffer; contentType: string | null; filename: string | null }>{
|
||||
const url = `${baseUrl}${path}`;
|
||||
const fetchImpl = resolveFetch();
|
||||
const fetchImpl = resolveFetch(url);
|
||||
const response = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
url,
|
||||
|
||||
65
apps/app/src/app/lib/release-channels.ts
Normal file
65
apps/app/src/app/lib/release-channels.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Release-channel concept for OpenWork desktop builds.
|
||||
*
|
||||
* There are two channels users can opt into:
|
||||
*
|
||||
* - "stable": the default. The desktop app auto-updates from the rolling
|
||||
* "latest" GitHub release attached to whichever semver tag most recently
|
||||
* finished the Release App workflow. macOS, Linux, Windows.
|
||||
*
|
||||
* - "alpha": a macOS-only rolling channel that auto-updates on every merge
|
||||
* to `dev`. Alpha builds are published to a fixed GitHub release tag
|
||||
* (`alpha-macos-latest`) so the updater endpoint stays stable while the
|
||||
* underlying artifact is replaced on every dev push.
|
||||
*
|
||||
* Only the macOS (arm64) build is published to the alpha channel today.
|
||||
* Linux and Windows always resolve to the stable channel.
|
||||
*/
|
||||
|
||||
import type { ReleaseChannel } from "../types";
|
||||
|
||||
/** Stable channel's Tauri updater manifest URL. */
|
||||
export const STABLE_UPDATER_ENDPOINT =
|
||||
"https://github.com/different-ai/openwork/releases/latest/download/latest.json";
|
||||
|
||||
/** Alpha channel's Tauri updater manifest URL (macOS-only, rolling). */
|
||||
export const ALPHA_UPDATER_ENDPOINT =
|
||||
"https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json";
|
||||
|
||||
/** Rolling GitHub release tag that alpha macOS artifacts are published to. */
|
||||
export const ALPHA_MACOS_RELEASE_TAG = "alpha-macos-latest";
|
||||
|
||||
export type PlatformKind = "darwin" | "linux" | "windows" | "web" | "unknown";
|
||||
|
||||
/**
|
||||
* Returns true when the given platform supports the alpha channel.
|
||||
*
|
||||
* Today alpha builds are produced only for macOS (arm64). The type-level
|
||||
* conservatism here is deliberate: it's easier to widen later than to
|
||||
* silently start advertising an alpha endpoint that serves no artifact.
|
||||
*/
|
||||
export function isAlphaChannelSupported(platform: PlatformKind): boolean {
|
||||
return platform === "darwin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Tauri updater manifest URL for the requested channel.
|
||||
*
|
||||
* Falls back to the stable endpoint whenever alpha isn't supported on the
|
||||
* current platform, so the caller never needs to special-case "alpha chosen
|
||||
* on Linux" / "alpha chosen on Windows" etc.
|
||||
*/
|
||||
export function resolveUpdaterEndpoint(
|
||||
channel: ReleaseChannel,
|
||||
platform: PlatformKind = "darwin",
|
||||
): string {
|
||||
if (channel === "alpha" && isAlphaChannelSupported(platform)) {
|
||||
return ALPHA_UPDATER_ENDPOINT;
|
||||
}
|
||||
return STABLE_UPDATER_ENDPOINT;
|
||||
}
|
||||
|
||||
/** Narrow an arbitrary string to a valid ReleaseChannel, defaulting to stable. */
|
||||
export function coerceReleaseChannel(value: unknown): ReleaseChannel {
|
||||
return value === "alpha" ? "alpha" : "stable";
|
||||
}
|
||||
147
apps/app/src/app/lib/version-gate.ts
Normal file
147
apps/app/src/app/lib/version-gate.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Version comparator + update gating helpers.
|
||||
//
|
||||
// Ported from dev's Solid system-state.ts (#1476 + #1512). Pure functions
|
||||
// so they're reusable from any React feature site once the updater flow
|
||||
// gets wired.
|
||||
|
||||
import { createDenClient, readDenSettings, type DenDesktopConfig } from "./den";
|
||||
|
||||
type ParsedVersion = {
|
||||
release: number[];
|
||||
prerelease: string[];
|
||||
};
|
||||
|
||||
function parseComparableVersion(value: string): ParsedVersion | null {
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
const [versionCore] = normalized.split("+", 1);
|
||||
if (!versionCore) return null;
|
||||
|
||||
const [releasePart, prereleasePart = ""] = versionCore.split("-", 2);
|
||||
const release = releasePart.split(".").map((segment) => Number(segment));
|
||||
if (!release.length || release.some((segment) => !Number.isInteger(segment) || segment < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prerelease = prereleasePart
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { release, prerelease };
|
||||
}
|
||||
|
||||
function comparePrereleaseIdentifiers(left: string[], right: string[]): number {
|
||||
// semver-ish: absence of prerelease ranks higher than presence.
|
||||
if (!left.length && !right.length) return 0;
|
||||
if (!left.length) return 1;
|
||||
if (!right.length) return -1;
|
||||
|
||||
const count = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = left[index];
|
||||
const rightPart = right[index];
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
|
||||
const leftNumeric = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
|
||||
const rightNumeric = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
|
||||
|
||||
if (leftNumeric !== null && rightNumeric !== null) {
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric < rightNumeric ? -1 : 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftNumeric !== null) return -1;
|
||||
if (rightNumeric !== null) return 1;
|
||||
|
||||
const comparison = leftPart.localeCompare(rightPart);
|
||||
if (comparison !== 0) return comparison < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings. Returns -1 / 0 / 1 as usual, or null if
|
||||
* either side fails to parse. Accepts an optional leading `v` and handles
|
||||
* prerelease tags (e.g. `0.11.212-alpha.3`).
|
||||
*/
|
||||
export function compareVersions(left: string, right: string): number | null {
|
||||
const parsedLeft = parseComparableVersion(left);
|
||||
const parsedRight = parseComparableVersion(right);
|
||||
if (!parsedLeft || !parsedRight) return null;
|
||||
|
||||
const count = Math.max(parsedLeft.release.length, parsedRight.release.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = parsedLeft.release[index] ?? 0;
|
||||
const rightPart = parsedRight.release[index] ?? 0;
|
||||
if (leftPart !== rightPart) return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
|
||||
return comparePrereleaseIdentifiers(parsedLeft.prerelease, parsedRight.prerelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the org-level `allowedDesktopVersions` filter (dev #1512). When
|
||||
* the array is unset, everything is allowed; when it's set, the candidate
|
||||
* update version must match one of the allowed versions exactly (by
|
||||
* semver comparison, so leading `v` prefixes and trailing build metadata
|
||||
* are treated equivalently).
|
||||
*/
|
||||
export function isUpdateAllowedByDesktopConfig(
|
||||
updateVersion: string,
|
||||
desktopConfig: DenDesktopConfig | null | undefined,
|
||||
): boolean {
|
||||
if (!Array.isArray(desktopConfig?.allowedDesktopVersions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return desktopConfig.allowedDesktopVersions.some(
|
||||
(allowedVersion) => compareVersions(updateVersion, allowedVersion) === 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Den for the currently-supported latest app version (dev #1476) and
|
||||
* return true only when the candidate update version is the latest
|
||||
* version or older. If Den is unreachable or returns an invalid payload,
|
||||
* this returns `false` — the caller must treat that as "do not surface
|
||||
* the update".
|
||||
*
|
||||
* No-op safe: callers can invoke this without any Den auth; the client
|
||||
* will omit the token when none is persisted.
|
||||
*/
|
||||
export async function isUpdateSupportedByDen(updateVersion: string): Promise<boolean> {
|
||||
try {
|
||||
const settings = readDenSettings();
|
||||
const token = settings.authToken?.trim() ?? "";
|
||||
const client = createDenClient({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
...(token ? { token } : {}),
|
||||
});
|
||||
const metadata = await client.getAppVersionMetadata();
|
||||
const comparison = compareVersions(updateVersion, metadata.latestAppVersion);
|
||||
return comparison !== null && comparison <= 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined gate: the update must be supported by Den (version metadata
|
||||
* endpoint) AND allowed by the active org's `allowedDesktopVersions` if
|
||||
* one is configured. Intended to be the single call site the React
|
||||
* updater flow makes before surfacing an update as installable.
|
||||
*/
|
||||
export async function isUpdateAllowed(
|
||||
updateVersion: string,
|
||||
desktopConfig: DenDesktopConfig | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!isUpdateAllowedByDesktopConfig(updateVersion, desktopConfig)) {
|
||||
return false;
|
||||
}
|
||||
return isUpdateSupportedByDen(updateVersion);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
const LEFT_SIDEBAR_WIDTH_KEY = "openwork.workspace-shell.left-width.v1";
|
||||
const RIGHT_SIDEBAR_EXPANDED_KEY = "openwork.workspace-shell.right-expanded.v3";
|
||||
|
||||
export const DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH = 260;
|
||||
export const MIN_WORKSPACE_LEFT_SIDEBAR_WIDTH = 220;
|
||||
export const MAX_WORKSPACE_LEFT_SIDEBAR_WIDTH = 420;
|
||||
export const DEFAULT_WORKSPACE_RIGHT_SIDEBAR_COLLAPSED_WIDTH = 72;
|
||||
|
||||
type WorkspaceShellLayoutOptions = {
|
||||
defaultLeftWidth?: number;
|
||||
minLeftWidth?: number;
|
||||
maxLeftWidth?: number;
|
||||
collapsedRightWidth?: number;
|
||||
expandedRightWidth: number;
|
||||
};
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function createWorkspaceShellLayout(options: WorkspaceShellLayoutOptions) {
|
||||
const minLeftWidth = Math.max(180, options.minLeftWidth ?? MIN_WORKSPACE_LEFT_SIDEBAR_WIDTH);
|
||||
const maxLeftWidth = Math.max(minLeftWidth, options.maxLeftWidth ?? MAX_WORKSPACE_LEFT_SIDEBAR_WIDTH);
|
||||
const defaultLeftWidth = clampNumber(
|
||||
options.defaultLeftWidth ?? DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
||||
minLeftWidth,
|
||||
maxLeftWidth,
|
||||
);
|
||||
const collapsedRightWidth = Math.max(
|
||||
56,
|
||||
options.collapsedRightWidth ?? DEFAULT_WORKSPACE_RIGHT_SIDEBAR_COLLAPSED_WIDTH,
|
||||
);
|
||||
const expandedRightWidth = Math.max(collapsedRightWidth, options.expandedRightWidth);
|
||||
|
||||
const readLeftSidebarWidth = () => {
|
||||
const raw = readStorage(LEFT_SIDEBAR_WIDTH_KEY);
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return defaultLeftWidth;
|
||||
return clampNumber(parsed, minLeftWidth, maxLeftWidth);
|
||||
};
|
||||
|
||||
const readRightSidebarExpanded = () => {
|
||||
const raw = readStorage(RIGHT_SIDEBAR_EXPANDED_KEY);
|
||||
if (raw == null) return false;
|
||||
return raw === "1";
|
||||
};
|
||||
|
||||
const [leftSidebarWidth, setLeftSidebarWidth] = createSignal(readLeftSidebarWidth());
|
||||
const [rightSidebarExpanded, setRightSidebarExpanded] = createSignal(readRightSidebarExpanded());
|
||||
|
||||
createEffect(() => {
|
||||
writeStorage(LEFT_SIDEBAR_WIDTH_KEY, String(clampNumber(leftSidebarWidth(), minLeftWidth, maxLeftWidth)));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
writeStorage(RIGHT_SIDEBAR_EXPANDED_KEY, rightSidebarExpanded() ? "1" : "0");
|
||||
});
|
||||
|
||||
const rightSidebarWidth = createMemo(() =>
|
||||
rightSidebarExpanded() ? expandedRightWidth : collapsedRightWidth,
|
||||
);
|
||||
|
||||
let dragCleanup: (() => void) | null = null;
|
||||
|
||||
const stopLeftSidebarResize = () => {
|
||||
dragCleanup?.();
|
||||
dragCleanup = null;
|
||||
if (typeof document === "undefined") return;
|
||||
document.body.style.removeProperty("cursor");
|
||||
document.body.style.removeProperty("user-select");
|
||||
};
|
||||
|
||||
const startLeftSidebarResize = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || typeof window === "undefined") return;
|
||||
|
||||
stopLeftSidebarResize();
|
||||
const initialX = event.clientX;
|
||||
const initialWidth = leftSidebarWidth();
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
const delta = moveEvent.clientX - initialX;
|
||||
setLeftSidebarWidth(clampNumber(initialWidth + delta, minLeftWidth, maxLeftWidth));
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
stopLeftSidebarResize();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleStop);
|
||||
window.addEventListener("pointercancel", handleStop);
|
||||
dragCleanup = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleStop);
|
||||
window.removeEventListener("pointercancel", handleStop);
|
||||
};
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const toggleRightSidebar = () => {
|
||||
setRightSidebarExpanded((current) => !current);
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
stopLeftSidebarResize();
|
||||
});
|
||||
|
||||
return {
|
||||
leftSidebarWidth,
|
||||
rightSidebarExpanded,
|
||||
rightSidebarWidth,
|
||||
setRightSidebarExpanded,
|
||||
startLeftSidebarResize,
|
||||
toggleRightSidebar,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,559 +0,0 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
import { readDevLogs } from "../lib/dev-log";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
import { readPerfLogs } from "../lib/perf-log";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import Button from "../components/button";
|
||||
import TextInput from "../components/text-input";
|
||||
|
||||
import { RefreshCcw } from "lucide-solid";
|
||||
|
||||
import { buildOpenworkWorkspaceBaseUrl, parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server";
|
||||
import type { OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
|
||||
import type { OpenworkServerInfo } from "../lib/tauri";
|
||||
|
||||
export type ConfigViewProps = {
|
||||
busy: boolean;
|
||||
clientConnected: boolean;
|
||||
anyActiveRuns: boolean;
|
||||
|
||||
openworkServerStatus: OpenworkServerStatus;
|
||||
openworkServerUrl: string;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
openworkServerHostInfo: OpenworkServerInfo | null;
|
||||
runtimeWorkspaceId: string | null;
|
||||
|
||||
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
|
||||
resetOpenworkServerSettings: () => void;
|
||||
testOpenworkServerConnection: (next: OpenworkServerSettings) => Promise<boolean>;
|
||||
|
||||
canReloadWorkspace: boolean;
|
||||
reloadWorkspaceEngine: () => Promise<void>;
|
||||
reloadBusy: boolean;
|
||||
reloadError: string | null;
|
||||
|
||||
developerMode: boolean;
|
||||
};
|
||||
|
||||
export default function ConfigView(props: ConfigViewProps) {
|
||||
const [openworkUrl, setOpenworkUrl] = createSignal("");
|
||||
const [openworkToken, setOpenworkToken] = createSignal("");
|
||||
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
|
||||
const [openworkTestState, setOpenworkTestState] = createSignal<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [openworkTestMessage, setOpenworkTestMessage] = createSignal<string | null>(null);
|
||||
const [clientTokenVisible, setClientTokenVisible] = createSignal(false);
|
||||
const [ownerTokenVisible, setOwnerTokenVisible] = createSignal(false);
|
||||
const [hostTokenVisible, setHostTokenVisible] = createSignal(false);
|
||||
const [copyingField, setCopyingField] = createSignal<string | null>(null);
|
||||
let copyTimeout: number | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
setOpenworkUrl(props.openworkServerSettings.urlOverride ?? "");
|
||||
setOpenworkToken(props.openworkServerSettings.token ?? "");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
openworkUrl();
|
||||
openworkToken();
|
||||
setOpenworkTestState("idle");
|
||||
setOpenworkTestMessage(null);
|
||||
});
|
||||
|
||||
const openworkStatusLabel = createMemo(() => {
|
||||
switch (props.openworkServerStatus) {
|
||||
case "connected":
|
||||
return t("config.status_connected");
|
||||
case "limited":
|
||||
return t("config.status_limited");
|
||||
default:
|
||||
return t("config.status_not_connected");
|
||||
}
|
||||
});
|
||||
|
||||
const openworkStatusStyle = createMemo(() => {
|
||||
switch (props.openworkServerStatus) {
|
||||
case "connected":
|
||||
return "bg-green-7/10 text-green-11 border-green-7/20";
|
||||
case "limited":
|
||||
return "bg-amber-7/10 text-amber-11 border-amber-7/20";
|
||||
default:
|
||||
return "bg-gray-4/60 text-gray-11 border-gray-7/50";
|
||||
}
|
||||
});
|
||||
|
||||
const reloadAvailabilityReason = createMemo(() => {
|
||||
if (!props.clientConnected) return t("config.reload_connect_hint");
|
||||
if (!props.canReloadWorkspace) {
|
||||
return t("config.reload_availability_hint");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const reloadButtonLabel = createMemo(() => (props.reloadBusy ? t("config.reloading") : t("config.reload_engine")));
|
||||
const reloadButtonTone = createMemo(() => (props.anyActiveRuns ? "danger" : "secondary"));
|
||||
const reloadButtonDisabled = createMemo(() => props.reloadBusy || Boolean(reloadAvailabilityReason()));
|
||||
|
||||
const buildOpenworkSettings = () => ({
|
||||
...props.openworkServerSettings,
|
||||
urlOverride: openworkUrl().trim() || undefined,
|
||||
token: openworkToken().trim() || undefined,
|
||||
});
|
||||
|
||||
const hasOpenworkChanges = createMemo(() => {
|
||||
const currentUrl = props.openworkServerSettings.urlOverride ?? "";
|
||||
const currentToken = props.openworkServerSettings.token ?? "";
|
||||
return openworkUrl().trim() !== currentUrl || openworkToken().trim() !== currentToken;
|
||||
});
|
||||
|
||||
const resolvedWorkspaceId = createMemo(() => {
|
||||
const explicitId = props.runtimeWorkspaceId?.trim() ?? "";
|
||||
if (explicitId) return explicitId;
|
||||
return parseOpenworkWorkspaceIdFromUrl(openworkUrl()) ?? "";
|
||||
});
|
||||
|
||||
const resolvedWorkspaceUrl = createMemo(() => {
|
||||
const baseUrl = openworkUrl().trim();
|
||||
if (!baseUrl) return "";
|
||||
return buildOpenworkWorkspaceBaseUrl(baseUrl, resolvedWorkspaceId()) ?? baseUrl;
|
||||
});
|
||||
|
||||
const hostInfo = createMemo(() => props.openworkServerHostInfo);
|
||||
const hostRemoteAccessEnabled = createMemo(
|
||||
() => hostInfo()?.remoteAccessEnabled === true,
|
||||
);
|
||||
const hostStatusLabel = createMemo(() => {
|
||||
if (!hostInfo()?.running) return t("config.host_offline");
|
||||
return hostRemoteAccessEnabled() ? t("config.host_remote_enabled") : t("config.host_local_only");
|
||||
});
|
||||
const hostStatusStyle = createMemo(() => {
|
||||
if (!hostInfo()?.running) return "bg-gray-4/60 text-gray-11 border-gray-7/50";
|
||||
return "bg-green-7/10 text-green-11 border-green-7/20";
|
||||
});
|
||||
const hostConnectUrl = createMemo(() => {
|
||||
const info = hostInfo();
|
||||
return info?.connectUrl ?? info?.mdnsUrl ?? info?.lanUrl ?? info?.baseUrl ?? "";
|
||||
});
|
||||
const hostConnectUrlUsesMdns = createMemo(() => hostConnectUrl().includes(".local"));
|
||||
|
||||
const diagnosticsBundle = createMemo(() => {
|
||||
const urlOverride = props.openworkServerSettings.urlOverride?.trim() ?? "";
|
||||
const token = props.openworkServerSettings.token?.trim() ?? "";
|
||||
const host = hostInfo();
|
||||
const developerLogs = props.developerMode ? readDevLogs(80) : [];
|
||||
const perfLogs = props.developerMode ? readPerfLogs(80) : [];
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
runtime: {
|
||||
tauri: isTauriRuntime(),
|
||||
developerMode: props.developerMode,
|
||||
},
|
||||
workspace: {
|
||||
runtimeWorkspaceId: props.runtimeWorkspaceId ?? null,
|
||||
clientConnected: props.clientConnected,
|
||||
anyActiveRuns: props.anyActiveRuns,
|
||||
},
|
||||
openworkServer: {
|
||||
status: props.openworkServerStatus,
|
||||
url: props.openworkServerUrl,
|
||||
settings: {
|
||||
urlOverride: urlOverride || null,
|
||||
tokenPresent: Boolean(token),
|
||||
},
|
||||
host: host
|
||||
? {
|
||||
running: Boolean(host.running),
|
||||
remoteAccessEnabled: host.remoteAccessEnabled,
|
||||
baseUrl: host.baseUrl ?? null,
|
||||
connectUrl: host.connectUrl ?? null,
|
||||
mdnsUrl: host.mdnsUrl ?? null,
|
||||
lanUrl: host.lanUrl ?? null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
reload: {
|
||||
canReloadWorkspace: props.canReloadWorkspace,
|
||||
},
|
||||
sharing: {
|
||||
hostConnectUrl: hostConnectUrl() || null,
|
||||
hostConnectUrlUsesMdns: hostConnectUrlUsesMdns(),
|
||||
},
|
||||
performance: {
|
||||
retainedEntries: perfLogs.length,
|
||||
recent: perfLogs,
|
||||
},
|
||||
developerLogs: {
|
||||
retainedEntries: developerLogs.length,
|
||||
recent: developerLogs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const diagnosticsBundleJson = createMemo(() => JSON.stringify(diagnosticsBundle(), null, 2));
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopyingField(field);
|
||||
if (copyTimeout !== undefined) {
|
||||
window.clearTimeout(copyTimeout);
|
||||
}
|
||||
copyTimeout = window.setTimeout(() => {
|
||||
setCopyingField(null);
|
||||
copyTimeout = undefined;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyTimeout !== undefined) {
|
||||
window.clearTimeout(copyTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="space-y-6">
|
||||
<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">{t("config.workspace_config_title")}</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
{t("config.workspace_config_desc")}
|
||||
</div>
|
||||
<Show when={props.runtimeWorkspaceId}>
|
||||
<div class="text-[11px] text-gray-7 font-mono truncate">
|
||||
{t("config.workspace_id_prefix")}{props.runtimeWorkspaceId}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">{t("config.engine_reload_title")}</div>
|
||||
<div class="text-xs text-gray-10">{t("config.engine_reload_desc")}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-sm text-gray-12">{t("config.reload_now_title")}</div>
|
||||
<div class="text-xs text-gray-7">{t("config.reload_now_desc")}</div>
|
||||
<Show when={props.anyActiveRuns}>
|
||||
<div class="text-[11px] text-amber-11">{t("config.reload_active_tasks_warning")}</div>
|
||||
</Show>
|
||||
<Show when={props.reloadError}>
|
||||
<div class="text-[11px] text-red-11">{props.reloadError}</div>
|
||||
</Show>
|
||||
<Show when={reloadAvailabilityReason()}>
|
||||
<div class="text-[11px] text-gray-9">{reloadAvailabilityReason()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Button
|
||||
variant={reloadButtonTone()}
|
||||
class="text-xs h-8 py-0 px-3 shrink-0"
|
||||
onClick={props.reloadWorkspaceEngine}
|
||||
disabled={reloadButtonDisabled()}
|
||||
>
|
||||
<RefreshCcw size={14} class={props.reloadBusy ? "animate-spin" : ""} />
|
||||
{reloadButtonLabel()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Show when={props.developerMode}>
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-3">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">{t("config.diagnostics_title")}</div>
|
||||
<div class="text-xs text-gray-10">{t("config.diagnostics_desc")}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="text-xs h-8 py-0 px-3 shrink-0"
|
||||
onClick={() => void handleCopy(diagnosticsBundleJson(), "debug-bundle")}
|
||||
disabled={props.busy}
|
||||
>
|
||||
{copyingField() === "debug-bundle" ? t("config.copied") : t("config.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="text-xs text-gray-12 whitespace-pre-wrap break-words max-h-64 overflow-auto bg-gray-1/20 border border-gray-6 rounded-xl p-3">
|
||||
{diagnosticsBundleJson()}
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hostInfo()}>
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">{t("config.server_sharing_title")}</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
{t("config.server_sharing_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div class={`text-xs px-2 py-1 rounded-full border ${hostStatusStyle()}`}>
|
||||
{hostStatusLabel()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-11">{t("config.server_url_label")}</div>
|
||||
<div class="text-xs text-gray-7 font-mono truncate">{hostConnectUrl() || t("config.starting_server")}</div>
|
||||
<Show when={hostConnectUrl()}>
|
||||
<div class="text-[11px] text-gray-8 mt-1">
|
||||
{!hostRemoteAccessEnabled()
|
||||
? t("config.remote_access_off_hint")
|
||||
: hostConnectUrlUsesMdns()
|
||||
? t("config.mdns_hint")
|
||||
: t("config.local_ip_hint")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3 shrink-0"
|
||||
onClick={() => handleCopy(hostConnectUrl(), "host-url")}
|
||||
disabled={!hostConnectUrl()}
|
||||
>
|
||||
{copyingField() === "host-url" ? t("config.copied") : t("config.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-11">{t("config.collaborator_token_label")}</div>
|
||||
<div class="text-xs text-gray-7 font-mono truncate">
|
||||
{clientTokenVisible()
|
||||
? hostInfo()?.clientToken || "—"
|
||||
: hostInfo()?.clientToken
|
||||
? "••••••••••••"
|
||||
: "—"}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">
|
||||
{hostRemoteAccessEnabled()
|
||||
? t("config.collaborator_token_remote_hint")
|
||||
: t("config.collaborator_token_disabled_hint")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => setClientTokenVisible((prev) => !prev)}
|
||||
disabled={!hostInfo()?.clientToken}
|
||||
>
|
||||
{clientTokenVisible() ? t("common.hide") : t("common.show")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => handleCopy(hostInfo()?.clientToken ?? "", "client-token")}
|
||||
disabled={!hostInfo()?.clientToken}
|
||||
>
|
||||
{copyingField() === "client-token" ? t("config.copied") : t("config.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-11">{t("config.owner_token_label")}</div>
|
||||
<div class="text-xs text-gray-7 font-mono truncate">
|
||||
{ownerTokenVisible()
|
||||
? hostInfo()?.ownerToken || "—"
|
||||
: hostInfo()?.ownerToken
|
||||
? "••••••••••••"
|
||||
: "—"}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">
|
||||
{hostRemoteAccessEnabled()
|
||||
? t("config.owner_token_remote_hint")
|
||||
: t("config.owner_token_disabled_hint")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => setOwnerTokenVisible((prev) => !prev)}
|
||||
disabled={!hostInfo()?.ownerToken}
|
||||
>
|
||||
{ownerTokenVisible() ? t("common.hide") : t("common.show")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => handleCopy(hostInfo()?.ownerToken ?? "", "owner-token")}
|
||||
disabled={!hostInfo()?.ownerToken}
|
||||
>
|
||||
{copyingField() === "owner-token" ? t("config.copied") : t("config.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-11">{t("config.host_admin_token_label")}</div>
|
||||
<div class="text-xs text-gray-7 font-mono truncate">
|
||||
{hostTokenVisible()
|
||||
? hostInfo()?.hostToken || "—"
|
||||
: hostInfo()?.hostToken
|
||||
? "••••••••••••"
|
||||
: "—"}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">{t("config.host_admin_token_hint")}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => setHostTokenVisible((prev) => !prev)}
|
||||
disabled={!hostInfo()?.hostToken}
|
||||
>
|
||||
{hostTokenVisible() ? t("common.hide") : t("common.show")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => handleCopy(hostInfo()?.hostToken ?? "", "host-token")}
|
||||
disabled={!hostInfo()?.hostToken}
|
||||
>
|
||||
{copyingField() === "host-token" ? t("config.copied") : t("config.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-9">
|
||||
{t("config.server_sharing_menu_hint")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">{t("config.server_section_title")}</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
{t("config.server_section_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div class={`text-xs px-2 py-1 rounded-full border ${openworkStatusStyle()}`}>{openworkStatusLabel()}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<TextInput
|
||||
label={t("config.server_url_input_label")}
|
||||
value={openworkUrl()}
|
||||
onInput={(event) => setOpenworkUrl(event.currentTarget.value)}
|
||||
placeholder="http://127.0.0.1:<port>"
|
||||
hint={t("config.server_url_hint")}
|
||||
disabled={props.busy}
|
||||
/>
|
||||
|
||||
<label class="block">
|
||||
<div class="mb-1 text-xs font-medium text-gray-11">{t("config.token_label")}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type={openworkTokenVisible() ? "text" : "password"}
|
||||
value={openworkToken()}
|
||||
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
|
||||
placeholder={t("config.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() ? t("common.hide") : t("common.show")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-10">{t("config.token_hint")}</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[11px] text-gray-7 font-mono truncate">{t("config.resolved_worker_url")}{resolvedWorkspaceUrl() || t("config.not_set")}</div>
|
||||
<div class="text-[11px] text-gray-8 font-mono truncate">{t("config.worker_id")}{resolvedWorkspaceId() || t("config.unavailable")}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
if (openworkTestState() === "testing") return;
|
||||
const next = buildOpenworkSettings();
|
||||
props.updateOpenworkServerSettings(next);
|
||||
setOpenworkTestState("testing");
|
||||
setOpenworkTestMessage(null);
|
||||
try {
|
||||
const ok = await props.testOpenworkServerConnection(next);
|
||||
setOpenworkTestState(ok ? "success" : "error");
|
||||
setOpenworkTestMessage(
|
||||
ok ? t("config.connection_successful") : t("config.connection_failed"),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("config.connection_failed_check");
|
||||
setOpenworkTestState("error");
|
||||
setOpenworkTestMessage(message);
|
||||
}
|
||||
}}
|
||||
disabled={props.busy || openworkTestState() === "testing"}
|
||||
>
|
||||
{openworkTestState() === "testing" ? t("config.testing") : t("config.test_connection")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => props.updateOpenworkServerSettings(buildOpenworkSettings())}
|
||||
disabled={props.busy || !hasOpenworkChanges()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={props.resetOpenworkServerSettings} disabled={props.busy}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={openworkTestState() !== "idle"}>
|
||||
<div
|
||||
class={`text-xs ${
|
||||
openworkTestState() === "success"
|
||||
? "text-green-11"
|
||||
: openworkTestState() === "error"
|
||||
? "text-red-11"
|
||||
: "text-gray-9"
|
||||
}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{openworkTestState() === "testing" ? t("config.testing_connection") : openworkTestMessage() ?? t("config.connection_status_updated")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={openworkStatusLabel() !== t("config.status_connected")}>
|
||||
<div class="text-xs text-gray-9">{t("config.server_needed_hint")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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">{t("config.messaging_identities_title")}</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
{t("config.messaging_identities_desc")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isTauriRuntime()}>
|
||||
<div class="text-xs text-gray-9">
|
||||
{t("config.desktop_only_hint")}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Show, createEffect, createMemo, createSignal, on } from "solid-js";
|
||||
|
||||
import { Box, Cpu } from "lucide-solid";
|
||||
|
||||
import Button from "../components/button";
|
||||
import McpView from "../connections/mcp-view";
|
||||
import { useConnections } from "../connections/provider";
|
||||
import { useExtensions } from "../extensions/provider";
|
||||
import PluginsView, { type PluginsViewProps } from "./plugins";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export type ExtensionsSection = "all" | "mcp" | "plugins";
|
||||
|
||||
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(
|
||||
on(
|
||||
() => props.initialSection,
|
||||
(nextSection, previousSection) => {
|
||||
if (!nextSection || nextSection === previousSection) return;
|
||||
setSection(nextSection);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const connectedAppsCount = createMemo(() =>
|
||||
connections.mcpServers().filter((entry) => {
|
||||
if (entry.config.enabled === false) return false;
|
||||
const status = connections.mcpStatuses()[entry.name];
|
||||
return status?.status === "connected";
|
||||
}).length,
|
||||
);
|
||||
|
||||
const pluginCount = createMemo(() => extensions.pluginList().length);
|
||||
|
||||
const refreshAll = () => {
|
||||
void connections.refreshMcpServers();
|
||||
void extensions.refreshPlugins();
|
||||
};
|
||||
|
||||
const selectSection = (nextSection: ExtensionsSection) => {
|
||||
setSection(nextSection);
|
||||
if (nextSection === "mcp" || nextSection === "plugins") {
|
||||
props.setSectionRoute?.(nextSection);
|
||||
}
|
||||
};
|
||||
|
||||
const pillClass = (active: boolean) =>
|
||||
`px-3 py-1 rounded-full text-xs font-medium border transition-colors flex items-center gap-2 ${
|
||||
active ? "bg-gray-12/10 text-gray-12 border-gray-6/20" : "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Show when={props.showHeader !== false}>
|
||||
<h2 class="text-3xl font-bold text-dls-text">{t("extensions.title")}</h2>
|
||||
<p class="text-sm text-dls-secondary mt-1.5">
|
||||
{t("extensions.subtitle")}
|
||||
</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" />
|
||||
<span class="text-xs font-medium text-green-11">
|
||||
{t(connectedAppsCount() === 1 ? "extensions.app_count_one" : "extensions.app_count_many", undefined, { count: connectedAppsCount() })}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={pluginCount() > 0}>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-gray-3 px-3 py-1">
|
||||
<Cpu size={14} class="text-gray-11" />
|
||||
<span class="text-xs font-medium text-gray-11">
|
||||
{t(pluginCount() === 1 ? "extensions.plugin_count_one" : "extensions.plugin_count_many", undefined, { count: pluginCount() })}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class={pillClass(section() === "all")}
|
||||
aria-pressed={section() === "all"}
|
||||
onClick={() => selectSection("all")}
|
||||
>
|
||||
{t("extensions.filter_all")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={pillClass(section() === "mcp")}
|
||||
aria-pressed={section() === "mcp"}
|
||||
onClick={() => selectSection("mcp")}
|
||||
>
|
||||
<Box size={14} />
|
||||
{t("extensions.filter_apps")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={pillClass(section() === "plugins")}
|
||||
aria-pressed={section() === "plugins"}
|
||||
onClick={() => selectSection("plugins")}
|
||||
>
|
||||
<Cpu size={14} />
|
||||
{t("extensions.filter_plugins")}
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={refreshAll}>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={section() === "all" || section() === "mcp"}>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-12">
|
||||
<Box size={16} class="text-gray-11" />
|
||||
<span>{t("extensions.apps_mcp_header")}</span>
|
||||
</div>
|
||||
<McpView
|
||||
showHeader={false}
|
||||
busy={props.busy}
|
||||
selectedWorkspaceRoot={props.selectedWorkspaceRoot}
|
||||
isRemoteWorkspace={props.isRemoteWorkspace}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={section() === "all" || section() === "plugins"}>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-12">
|
||||
<Cpu size={16} class="text-gray-11" />
|
||||
<span>{t("extensions.plugins_opencode_header")}</span>
|
||||
</div>
|
||||
<PluginsView
|
||||
busy={props.busy}
|
||||
selectedWorkspaceRoot={props.selectedWorkspaceRoot}
|
||||
canEditPlugins={props.canEditPlugins}
|
||||
canUseGlobalScope={props.canUseGlobalScope}
|
||||
accessHint={props.accessHint}
|
||||
suggestedPlugins={props.suggestedPlugins}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,777 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import type { McpServerEntry, McpStatusMap } from "../types";
|
||||
import type { McpDirectoryInfo } from "../constants";
|
||||
import { formatRelativeTime, isTauriRuntime, isWindowsPlatform } from "../utils";
|
||||
import { readOpencodeConfig, type OpencodeConfigFile } from "../lib/tauri";
|
||||
import {
|
||||
buildChromeDevtoolsCommand,
|
||||
getMcpIdentityKey,
|
||||
isChromeDevtoolsMcp,
|
||||
normalizeMcpSlug,
|
||||
usesChromeDevtoolsAutoConnect,
|
||||
} from "../mcp";
|
||||
|
||||
import Button from "../components/button";
|
||||
import AddMcpModal from "../components/add-mcp-modal";
|
||||
import ConfirmModal from "../components/confirm-modal";
|
||||
import ControlChromeSetupModal from "../components/control-chrome-setup-modal";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
Code2,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Loader2,
|
||||
MonitorSmartphone,
|
||||
Plug2,
|
||||
Plus,
|
||||
Settings,
|
||||
Settings2,
|
||||
Unplug,
|
||||
Zap,
|
||||
} from "lucide-solid";
|
||||
import { currentLocale, t, type Language } from "../../i18n";
|
||||
|
||||
export type McpViewProps = {
|
||||
busy: boolean;
|
||||
selectedWorkspaceRoot: string;
|
||||
isRemoteWorkspace: boolean;
|
||||
readConfigFile?: (scope: "project" | "global") => Promise<OpencodeConfigFile | null>;
|
||||
showHeader?: boolean;
|
||||
mcpServers: McpServerEntry[];
|
||||
mcpStatus: string | null;
|
||||
mcpLastUpdatedAt: number | null;
|
||||
mcpStatuses: McpStatusMap;
|
||||
mcpConnectingName: string | null;
|
||||
selectedMcp: string | null;
|
||||
setSelectedMcp: (name: string | null) => void;
|
||||
quickConnect: McpDirectoryInfo[];
|
||||
connectMcp: (entry: McpDirectoryInfo) => void;
|
||||
authorizeMcp: (entry: McpServerEntry) => void;
|
||||
logoutMcpAuth: (name: string) => Promise<void> | void;
|
||||
removeMcp: (name: string) => void;
|
||||
};
|
||||
|
||||
/* ── Status helpers ─────────────────────────────────── */
|
||||
|
||||
type McpStatus = "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected";
|
||||
|
||||
const statusDot = (status: McpStatus) => {
|
||||
switch (status) {
|
||||
case "connected": return "bg-green-9";
|
||||
case "needs_auth":
|
||||
case "needs_client_registration": return "bg-amber-9";
|
||||
case "disabled": return "bg-gray-8";
|
||||
case "disconnected": return "bg-gray-7";
|
||||
default: return "bg-red-9";
|
||||
}
|
||||
};
|
||||
|
||||
const friendlyStatus = (status: McpStatus, locale: Language) => {
|
||||
switch (status) {
|
||||
case "connected": return t("mcp.friendly_status_ready", locale);
|
||||
case "needs_auth":
|
||||
case "needs_client_registration": return t("mcp.friendly_status_needs_signin", locale);
|
||||
case "disabled": return t("mcp.friendly_status_paused", locale);
|
||||
case "disconnected": return t("mcp.friendly_status_offline", locale);
|
||||
default: return t("mcp.friendly_status_issue", locale);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadgeStyle = (status: McpStatus) => {
|
||||
switch (status) {
|
||||
case "connected": return "bg-green-3 text-green-11";
|
||||
case "needs_auth":
|
||||
case "needs_client_registration": return "bg-amber-3 text-amber-11";
|
||||
case "disabled":
|
||||
case "disconnected": return "bg-gray-3 text-gray-11";
|
||||
default: return "bg-red-3 text-red-11";
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Icon mapping for known services ────────────────── */
|
||||
|
||||
const serviceIcon = (name: string) => {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("notion")) return BookOpen;
|
||||
if (lower.includes("linear")) return Zap;
|
||||
if (lower.includes("sentry")) return CircleAlert;
|
||||
if (lower.includes("stripe")) return CreditCard;
|
||||
if (lower.includes("context")) return Globe;
|
||||
if (lower.includes("chrome") || lower.includes("devtools")) return MonitorSmartphone;
|
||||
return Plug2;
|
||||
};
|
||||
|
||||
const serviceColor = (name: string) => {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("notion")) return "text-gray-12";
|
||||
if (lower.includes("linear")) return "text-blue-11";
|
||||
if (lower.includes("sentry")) return "text-purple-11";
|
||||
if (lower.includes("stripe")) return "text-blue-11";
|
||||
if (lower.includes("context")) return "text-green-11";
|
||||
if (lower.includes("chrome") || lower.includes("devtools")) return "text-amber-11";
|
||||
return "text-dls-secondary";
|
||||
};
|
||||
|
||||
const serviceIconBg = (name: string) => {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("notion")) return "bg-gray-3 border-gray-6";
|
||||
if (lower.includes("linear")) return "bg-blue-3 border-blue-6";
|
||||
if (lower.includes("sentry")) return "bg-purple-3 border-purple-6";
|
||||
if (lower.includes("stripe")) return "bg-blue-3 border-blue-6";
|
||||
if (lower.includes("context")) return "bg-green-3 border-green-6";
|
||||
if (lower.includes("chrome") || lower.includes("devtools")) return "bg-amber-3 border-amber-6";
|
||||
return "bg-dls-hover border-dls-border";
|
||||
};
|
||||
|
||||
/* ── Component ──────────────────────────────────────── */
|
||||
|
||||
export default function McpView(props: McpViewProps) {
|
||||
const locale = () => currentLocale();
|
||||
const tr = (key: string) => t(key, locale());
|
||||
const showHeader = () => props.showHeader !== false;
|
||||
|
||||
const [logoutOpen, setLogoutOpen] = createSignal(false);
|
||||
const [logoutTarget, setLogoutTarget] = createSignal<string | null>(null);
|
||||
const [logoutBusy, setLogoutBusy] = createSignal(false);
|
||||
|
||||
const [removeOpen, setRemoveOpen] = createSignal(false);
|
||||
const [removeTarget, setRemoveTarget] = createSignal<string | null>(null);
|
||||
|
||||
const [configScope, setConfigScope] = createSignal<"project" | "global">("project");
|
||||
const [projectConfig, setProjectConfig] = createSignal<OpencodeConfigFile | null>(null);
|
||||
const [globalConfig, setGlobalConfig] = createSignal<OpencodeConfigFile | null>(null);
|
||||
const [configError, setConfigError] = createSignal<string | null>(null);
|
||||
const [revealBusy, setRevealBusy] = createSignal(false);
|
||||
const [showAdvanced, setShowAdvanced] = createSignal(false);
|
||||
const [addMcpModalOpen, setAddMcpModalOpen] = createSignal(false);
|
||||
const [controlChromeModalOpen, setControlChromeModalOpen] = createSignal(false);
|
||||
const [controlChromeModalMode, setControlChromeModalMode] = createSignal<"connect" | "edit">("connect");
|
||||
const [controlChromeExistingProfile, setControlChromeExistingProfile] = createSignal(false);
|
||||
|
||||
const selectedEntry = createMemo(() =>
|
||||
props.mcpServers.find((entry) => entry.name === props.selectedMcp) ?? null,
|
||||
);
|
||||
|
||||
const quickConnectList = createMemo(() => props.quickConnect);
|
||||
|
||||
let configRequestId = 0;
|
||||
createEffect(() => {
|
||||
const root = props.selectedWorkspaceRoot.trim();
|
||||
const nextId = (configRequestId += 1);
|
||||
const readConfig = props.readConfigFile;
|
||||
|
||||
if (!readConfig && !isTauriRuntime()) {
|
||||
setProjectConfig(null);
|
||||
setGlobalConfig(null);
|
||||
setConfigError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
setConfigError(null);
|
||||
const [project, global] = await Promise.all([
|
||||
root
|
||||
? (readConfig ? readConfig("project") : readOpencodeConfig("project", root))
|
||||
: Promise.resolve(null),
|
||||
readConfig ? readConfig("global") : readOpencodeConfig("global", root),
|
||||
]);
|
||||
if (nextId !== configRequestId) return;
|
||||
setProjectConfig(project);
|
||||
setGlobalConfig(global);
|
||||
} catch (e) {
|
||||
if (nextId !== configRequestId) return;
|
||||
setProjectConfig(null);
|
||||
setGlobalConfig(null);
|
||||
setConfigError(e instanceof Error ? e.message : tr("mcp.config_load_failed"));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const activeConfig = createMemo(() =>
|
||||
configScope() === "project" ? projectConfig() : globalConfig(),
|
||||
);
|
||||
|
||||
const revealLabel = () =>
|
||||
isWindowsPlatform() ? tr("mcp.open_file") : tr("mcp.reveal_in_finder");
|
||||
|
||||
const canRevealConfig = () => {
|
||||
if (!isTauriRuntime() || revealBusy()) return false;
|
||||
if (configScope() === "project" && !props.selectedWorkspaceRoot.trim()) return false;
|
||||
return Boolean(activeConfig()?.exists);
|
||||
};
|
||||
|
||||
const revealConfig = async () => {
|
||||
if (!isTauriRuntime() || revealBusy()) return;
|
||||
const root = props.selectedWorkspaceRoot.trim();
|
||||
|
||||
if (configScope() === "project" && !root) {
|
||||
setConfigError(tr("mcp.pick_workspace_error"));
|
||||
return;
|
||||
}
|
||||
|
||||
setRevealBusy(true);
|
||||
setConfigError(null);
|
||||
try {
|
||||
const resolved = props.readConfigFile
|
||||
? await props.readConfigFile(configScope())
|
||||
: await readOpencodeConfig(configScope(), root);
|
||||
if (!resolved) {
|
||||
throw new Error(tr("mcp.config_load_failed"));
|
||||
}
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
if (isWindowsPlatform()) {
|
||||
await openPath(resolved.path);
|
||||
} else {
|
||||
await revealItemInDir(resolved.path);
|
||||
}
|
||||
} catch (e) {
|
||||
setConfigError(e instanceof Error ? e.message : tr("mcp.reveal_config_failed"));
|
||||
} finally {
|
||||
setRevealBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveQuickConnectMatch = (name: string) =>
|
||||
quickConnectList().find((candidate) => {
|
||||
const candidateKey = getMcpIdentityKey(candidate);
|
||||
return candidateKey === name || candidate.name === name || normalizeMcpSlug(candidate.name) === name;
|
||||
});
|
||||
|
||||
const displayName = (name: string) => resolveQuickConnectMatch(name)?.name ?? name;
|
||||
|
||||
const quickConnectStatus = (entry: McpDirectoryInfo) => props.mcpStatuses[getMcpIdentityKey(entry)];
|
||||
|
||||
const isQuickConnectConfigured = (entry: McpDirectoryInfo) =>
|
||||
props.mcpServers.some((server) => server.name === getMcpIdentityKey(entry));
|
||||
|
||||
const openControlChromeModal = (mode: "connect" | "edit", existingEntry?: McpServerEntry | null) => {
|
||||
setControlChromeModalMode(mode);
|
||||
setControlChromeExistingProfile(usesChromeDevtoolsAutoConnect(existingEntry?.config.command));
|
||||
setControlChromeModalOpen(true);
|
||||
};
|
||||
|
||||
const saveControlChromeSettings = (useExistingProfile: boolean) => {
|
||||
const controlChrome = quickConnectList().find((entry) => isChromeDevtoolsMcp(entry));
|
||||
if (!controlChrome) return;
|
||||
const existingEntry = props.mcpServers.find((entry) => isChromeDevtoolsMcp(entry.name));
|
||||
|
||||
props.connectMcp({
|
||||
...controlChrome,
|
||||
command: buildChromeDevtoolsCommand(existingEntry?.config.command ?? controlChrome.command, useExistingProfile),
|
||||
});
|
||||
setControlChromeModalOpen(false);
|
||||
};
|
||||
|
||||
const canConnect = () => !props.busy;
|
||||
|
||||
const supportsOauth = (entry: McpServerEntry) =>
|
||||
entry.config.type === "remote" && entry.config.oauth !== false;
|
||||
|
||||
const resolveStatus = (entry: McpServerEntry): McpStatus => {
|
||||
if (entry.config.enabled === false) return "disabled";
|
||||
const resolved = props.mcpStatuses[entry.name];
|
||||
return resolved?.status ? resolved.status : "disconnected";
|
||||
};
|
||||
|
||||
const connectedCount = createMemo(() =>
|
||||
props.mcpServers.filter((e) => resolveStatus(e) === "connected").length,
|
||||
);
|
||||
|
||||
const requestLogout = (name: string) => {
|
||||
if (!name.trim()) return;
|
||||
setLogoutTarget(name);
|
||||
setLogoutOpen(true);
|
||||
};
|
||||
|
||||
const confirmLogout = async () => {
|
||||
const name = logoutTarget();
|
||||
if (!name || logoutBusy()) return;
|
||||
setLogoutBusy(true);
|
||||
try {
|
||||
await props.logoutMcpAuth(name);
|
||||
} finally {
|
||||
setLogoutBusy(false);
|
||||
setLogoutOpen(false);
|
||||
setLogoutTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="space-y-8 animate-in fade-in duration-300">
|
||||
{/* ── Header ───────────────────────────────────── */}
|
||||
<Show when={showHeader()}>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-dls-text">{tr("mcp.apps_title")}</h2>
|
||||
<p class="text-sm text-dls-secondary mt-1.5">
|
||||
{tr("mcp.apps_subtitle")}
|
||||
</p>
|
||||
<Show when={connectedCount() > 0}>
|
||||
<div class="mt-3 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" />
|
||||
<span class="text-xs font-medium text-green-11">
|
||||
{connectedCount()} {connectedCount() === 1 ? tr("mcp.app_connected") : tr("mcp.apps_connected")}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</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">
|
||||
{props.mcpStatus}
|
||||
</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">
|
||||
<h3 class="text-[11px] font-bold text-dls-secondary uppercase tracking-widest">
|
||||
{tr("mcp.available_apps")}
|
||||
</h3>
|
||||
<span class="text-[11px] text-dls-secondary">{tr("mcp.one_click_connect")}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={quickConnectList()}>
|
||||
{(entry) => {
|
||||
const configured = () => isQuickConnectConfigured(entry);
|
||||
const connecting = () => props.mcpConnectingName === entry.name;
|
||||
const Icon = serviceIcon(entry.name);
|
||||
const isControlChrome = () => isChromeDevtoolsMcp(entry);
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<Show when={isControlChrome() && configured()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-green-6 bg-white/90 text-green-11 transition-colors hover:bg-white"
|
||||
aria-label={tr("mcp.control_chrome_edit")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const existingEntry = props.mcpServers.find((server) => server.name === getMcpIdentityKey(entry));
|
||||
openControlChromeModal("edit", existingEntry);
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={configured() || !canConnect() || connecting()}
|
||||
onClick={() => {
|
||||
if (configured()) return;
|
||||
if (isControlChrome()) {
|
||||
openControlChromeModal("connect");
|
||||
return;
|
||||
}
|
||||
props.connectMcp(entry);
|
||||
}}
|
||||
class={`group w-full text-left rounded-xl border p-4 transition-all ${
|
||||
configured()
|
||||
? "border-green-6 bg-green-2"
|
||||
: "border-dls-border bg-dls-surface hover:bg-dls-hover hover:shadow-[0_4px_16px_rgba(17,24,39,0.06)]"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 border ${
|
||||
configured() ? "bg-green-3 border-green-6" : serviceIconBg(entry.name)
|
||||
}`}>
|
||||
<Show
|
||||
when={!connecting()}
|
||||
fallback={<Loader2 size={18} class="animate-spin text-dls-secondary" />}
|
||||
>
|
||||
<Show
|
||||
when={!configured()}
|
||||
fallback={<CheckCircle2 size={18} class="text-green-11" />}
|
||||
>
|
||||
<Icon size={18} class={serviceColor(entry.name)} />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 pr-10">
|
||||
<h4 class="text-sm font-semibold text-dls-text">{entry.name}</h4>
|
||||
<Show when={configured()}>
|
||||
<span class="text-[10px] font-medium text-green-11 bg-green-3 px-1.5 py-0.5 rounded-md">
|
||||
{tr("mcp.connected_badge")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!configured() && quickConnectStatus(entry)}>
|
||||
{(status) => (
|
||||
<span class={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${statusBadgeStyle(status().status)}`}>
|
||||
{friendlyStatus(status().status, locale())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-dls-secondary mt-0.5 line-clamp-2">
|
||||
{entry.description}
|
||||
</p>
|
||||
<Show when={!configured() && !connecting()}>
|
||||
<div class="mt-2 text-[11px] font-medium text-blue-11 group-hover:text-blue-12 transition-colors">
|
||||
{tr("mcp.tap_to_connect")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Your connected apps ──────────────────────── */}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-[11px] font-bold text-dls-secondary uppercase tracking-widest">
|
||||
{tr("mcp.your_apps")}
|
||||
</h3>
|
||||
<Show when={props.mcpLastUpdatedAt}>
|
||||
<span class="text-[11px] text-dls-secondary tabular-nums">
|
||||
{tr("mcp.last_synced")} {formatRelativeTime(props.mcpLastUpdatedAt ?? Date.now())}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={props.mcpServers.length}
|
||||
fallback={
|
||||
<div class="rounded-xl border border-dashed border-dls-border px-5 py-10 text-center">
|
||||
<Unplug size={24} class="mx-auto text-dls-secondary/30 mb-3" />
|
||||
<div class="text-sm font-medium text-dls-secondary">{tr("mcp.no_apps_yet")}</div>
|
||||
<div class="text-xs text-dls-secondary/60 mt-1">{tr("mcp.no_apps_hint")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={props.mcpServers}>
|
||||
{(entry) => {
|
||||
const status = () => resolveStatus(entry);
|
||||
const Icon = serviceIcon(entry.name);
|
||||
const isSelected = () => props.selectedMcp === entry.name;
|
||||
const errorInfo = () => {
|
||||
const resolved = props.mcpStatuses[entry.name];
|
||||
if (!resolved || resolved.status !== "failed") return null;
|
||||
return "error" in resolved ? resolved.error : tr("mcp.connection_failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`rounded-xl border transition-all ${
|
||||
isSelected()
|
||||
? "border-blue-7 bg-blue-2 shadow-sm"
|
||||
: "border-dls-border bg-dls-surface hover:bg-dls-hover"
|
||||
}`}>
|
||||
{/* Clickable row */}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3.5"
|
||||
onClick={() => props.setSelectedMcp(isSelected() ? null : entry.name)}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border ${
|
||||
status() === "connected" ? "bg-green-3 border-green-6" : serviceIconBg(entry.name)
|
||||
}`}>
|
||||
<Icon size={15} class={status() === "connected" ? "text-green-11" : serviceColor(entry.name)} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-dls-text truncate">{displayName(entry.name)}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class={`w-2 h-2 rounded-full ${statusDot(status())}`} />
|
||||
<span class="text-[11px] text-dls-secondary">
|
||||
{friendlyStatus(status(), locale())}
|
||||
</span>
|
||||
</div>
|
||||
<div class={`transition-transform ${isSelected() ? "rotate-180" : ""}`}>
|
||||
<ChevronDown size={14} class="text-dls-secondary/40" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable details */}
|
||||
<Show when={isSelected()}>
|
||||
<div class="border-t border-blue-6/20 px-4 py-3 space-y-3 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
{/* Connection type */}
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<span class="text-dls-secondary">{tr("mcp.connection_type")}</span>
|
||||
<span class="text-dls-text">
|
||||
{entry.config.type === "remote" ? tr("mcp.type_cloud") : tr("mcp.type_local")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-medium bg-dls-surface text-dls-text border border-dls-border px-2 py-0.5 rounded-md">
|
||||
{tr("mcp.cap_tools")}
|
||||
</span>
|
||||
<Show when={entry.config.type === "remote"}>
|
||||
<span class="text-[10px] font-medium bg-dls-surface text-dls-text border border-dls-border px-2 py-0.5 rounded-md">
|
||||
{tr("mcp.cap_signin")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
<Show when={errorInfo()}>
|
||||
{(err) => (
|
||||
<div class="rounded-lg bg-red-2 border border-red-6 px-3 py-2 text-xs text-red-11">
|
||||
{err()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Technical details */}
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-1.5 text-[11px] text-dls-secondary cursor-pointer hover:text-dls-text transition-colors list-none">
|
||||
<Code2 size={11} />
|
||||
{tr("mcp.technical_details")}
|
||||
<ChevronDown size={10} class="group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div class="mt-1.5 rounded-lg bg-dls-hover px-3 py-2 text-[11px] font-mono text-dls-secondary break-all">
|
||||
{entry.config.type === "remote"
|
||||
? entry.config.url
|
||||
: entry.config.command?.join(" ")}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Show when={supportsOauth(entry) && status() !== "connected"}>
|
||||
<div class="pt-1 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-dls-secondary">
|
||||
{tr("mcp.logout_label")}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="px-3 py-1.5 text-xs"
|
||||
disabled={props.busy}
|
||||
onClick={() => props.authorizeMcp(entry)}
|
||||
>
|
||||
{tr("mcp.login_action")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-[11px] text-dls-secondary/70">
|
||||
{tr("mcp.login_hint")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={supportsOauth(entry) && status() === "connected"}>
|
||||
<div class="pt-1 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-dls-secondary">
|
||||
{tr("mcp.logout_label")}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
class="px-3 py-1.5 text-xs"
|
||||
disabled={props.busy || logoutBusy()}
|
||||
onClick={() => requestLogout(entry.name)}
|
||||
>
|
||||
{logoutBusy() && logoutTarget() === entry.name ? tr("mcp.logout_working") : tr("mcp.logout_action")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-[11px] text-dls-secondary/70">
|
||||
{tr("mcp.logout_hint")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<Show when={isChromeDevtoolsMcp(entry.name)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="!px-3 !py-1.5 !text-xs"
|
||||
onClick={() => openControlChromeModal("edit", entry)}
|
||||
>
|
||||
<Settings size={13} />
|
||||
{tr("mcp.control_chrome_edit")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="danger"
|
||||
class="!px-3 !py-1.5 !text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRemoveTarget(entry.name);
|
||||
setRemoveOpen(true);
|
||||
}}
|
||||
>
|
||||
{tr("mcp.remove_app")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={logoutOpen()}
|
||||
title={tr("mcp.logout_modal_title")}
|
||||
message={tr("mcp.logout_modal_message").replace("{server}", displayName(logoutTarget() ?? ""))}
|
||||
confirmLabel={logoutBusy() ? tr("mcp.logout_working") : tr("mcp.logout_action")}
|
||||
cancelLabel={tr("common.cancel")}
|
||||
variant="danger"
|
||||
onCancel={() => {
|
||||
if (logoutBusy()) return;
|
||||
setLogoutOpen(false);
|
||||
setLogoutTarget(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmLogout();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={removeOpen()}
|
||||
title={tr("mcp.remove_modal_title")}
|
||||
message={tr("mcp.remove_modal_message").replace("{server}", displayName(removeTarget() ?? ""))}
|
||||
confirmLabel={tr("mcp.remove_app")}
|
||||
cancelLabel={tr("common.cancel")}
|
||||
variant="danger"
|
||||
onCancel={() => {
|
||||
setRemoveOpen(false);
|
||||
setRemoveTarget(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
const target = removeTarget();
|
||||
if (target) props.removeMcp(target);
|
||||
setRemoveOpen(false);
|
||||
setRemoveTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Advanced: Config editor ───────────────────── */}
|
||||
<div class="rounded-xl border border-dls-border bg-dls-surface overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-5 py-4 hover:bg-dls-hover transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced())}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Settings2 size={16} class="text-dls-secondary" />
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium text-dls-text">{tr("mcp.advanced_settings")}</div>
|
||||
<div class="text-xs text-dls-secondary">{tr("mcp.advanced_settings_hint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`transition-transform ${showAdvanced() ? "rotate-180" : ""}`}>
|
||||
<ChevronDown size={16} class="text-dls-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={showAdvanced()}>
|
||||
<div class="border-t border-dls-border px-5 py-4 space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
{/* Scope toggle */}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
class={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
configScope() === "project"
|
||||
? "bg-dls-active text-dls-text"
|
||||
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
|
||||
}`}
|
||||
onClick={() => setConfigScope("project")}
|
||||
>
|
||||
{tr("mcp.scope_project")}
|
||||
</button>
|
||||
<button
|
||||
class={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
configScope() === "global"
|
||||
? "bg-dls-active text-dls-text"
|
||||
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
|
||||
}`}
|
||||
onClick={() => setConfigScope("global")}
|
||||
>
|
||||
{tr("mcp.scope_global")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Config path */}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<div class="text-dls-secondary">{tr("mcp.config_file")}</div>
|
||||
<div class="text-dls-secondary/80 font-mono text-[11px] truncate">
|
||||
{activeConfig()?.path ?? tr("mcp.config_not_loaded")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={revealConfig} disabled={!canRevealConfig()}>
|
||||
<Show
|
||||
when={revealBusy()}
|
||||
fallback={<><FolderOpen size={14} /> {revealLabel()}</>}
|
||||
>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{tr("mcp.opening_label")}
|
||||
</Show>
|
||||
</Button>
|
||||
<a
|
||||
href="https://opencode.ai/docs/mcp-servers/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-xs text-dls-secondary hover:text-dls-text transition-colors"
|
||||
>
|
||||
{tr("mcp.docs_link")}
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
<Show when={activeConfig() && activeConfig()!.exists === false}>
|
||||
<div class="text-[11px] text-dls-secondary">{tr("mcp.file_not_found")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={configError()}>
|
||||
<div class="text-xs text-red-11">{configError()}</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AddMcpModal
|
||||
open={addMcpModalOpen()}
|
||||
onClose={() => setAddMcpModalOpen(false)}
|
||||
onAdd={(entry) => props.connectMcp(entry)}
|
||||
busy={props.busy}
|
||||
isRemoteWorkspace={props.isRemoteWorkspace}
|
||||
language={locale()}
|
||||
/>
|
||||
|
||||
<ControlChromeSetupModal
|
||||
open={controlChromeModalOpen()}
|
||||
busy={props.busy || props.mcpConnectingName === "Control Chrome"}
|
||||
language={locale()}
|
||||
mode={controlChromeModalMode()}
|
||||
initialUseExistingProfile={controlChromeExistingProfile()}
|
||||
onClose={() => setControlChromeModalOpen(false)}
|
||||
onSave={(useExistingProfile) => saveControlChromeSettings(useExistingProfile)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { For, Show } from "solid-js";
|
||||
|
||||
import { useExtensions } from "../extensions/provider";
|
||||
|
||||
import Button from "../components/button";
|
||||
import TextInput from "../components/text-input";
|
||||
import { Cpu } from "lucide-solid";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export type PluginsViewProps = {
|
||||
busy: boolean;
|
||||
selectedWorkspaceRoot: string;
|
||||
canEditPlugins: boolean;
|
||||
canUseGlobalScope: boolean;
|
||||
accessHint?: string | null;
|
||||
suggestedPlugins: Array<{
|
||||
name: string;
|
||||
packageName: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
aliases?: string[];
|
||||
installMode?: "simple" | "guided";
|
||||
steps?: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
note?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
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">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-12">{t("plugins.title")}</div>
|
||||
<div class="text-xs text-gray-10">{t("plugins.desc")}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
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={() => {
|
||||
extensions.setPluginScope("project");
|
||||
void extensions.refreshPlugins("project");
|
||||
}}
|
||||
>
|
||||
{t("plugins.scope_project")}
|
||||
</button>
|
||||
<button
|
||||
disabled={!props.canUseGlobalScope}
|
||||
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
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;
|
||||
extensions.setPluginScope("global");
|
||||
void extensions.refreshPlugins("global");
|
||||
}}
|
||||
>
|
||||
{t("plugins.scope_global")}
|
||||
</button>
|
||||
<Button variant="ghost" onClick={() => void extensions.refreshPlugins()}>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-gray-10">
|
||||
<div>{t("plugins.config_label")}</div>
|
||||
<div class="text-gray-7 font-mono truncate">{extensions.pluginConfigPath() ?? extensions.pluginConfig()?.path ?? t("plugins.not_loaded_yet")}</div>
|
||||
<Show when={props.accessHint}>
|
||||
<div class="text-gray-9">{props.accessHint}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-medium text-gray-11 uppercase tracking-wider">{t("plugins.suggested_heading")}</div>
|
||||
<div class="grid gap-3">
|
||||
<For each={props.suggestedPlugins}>
|
||||
{(plugin) => {
|
||||
const isGuided = () => plugin.installMode === "guided";
|
||||
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">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12 font-mono">{plugin.name}</div>
|
||||
<div class="text-xs text-gray-10 mt-1">{plugin.description}</div>
|
||||
<Show when={plugin.packageName !== plugin.name}>
|
||||
<div class="text-xs text-gray-7 font-mono mt-1">{plugin.packageName}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isGuided()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => extensions.setActivePluginGuide(isGuideOpen() ? null : plugin.packageName)}
|
||||
>
|
||||
{isGuideOpen() ? t("plugins.hide_setup") : t("plugins.setup")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={isInstalled() ? "outline" : "secondary"}
|
||||
onClick={() => extensions.addPlugin(plugin.packageName)}
|
||||
disabled={
|
||||
props.busy ||
|
||||
isInstalled() ||
|
||||
!props.canEditPlugins ||
|
||||
(extensions.pluginScope() === "project" && !props.selectedWorkspaceRoot.trim())
|
||||
}
|
||||
>
|
||||
{isInstalled() ? t("plugins.added") : t("plugins.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={plugin.tags}>
|
||||
{(tag) => (
|
||||
<span class="text-[10px] uppercase tracking-wide bg-gray-4/70 text-gray-11 px-2 py-0.5 rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={isGuided() && isGuideOpen()}>
|
||||
<div class="rounded-xl border border-gray-6/70 bg-gray-1/60 p-4 space-y-3">
|
||||
<For each={plugin.steps ?? []}>
|
||||
{(step, idx) => (
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-medium text-gray-11">
|
||||
{idx() + 1}. {step.title}
|
||||
</div>
|
||||
<div class="text-xs text-gray-10">{step.description}</div>
|
||||
<Show when={step.command}>
|
||||
<div class="text-xs font-mono text-gray-12 bg-gray-2/60 border border-gray-6/70 rounded-lg px-3 py-2">
|
||||
{step.command}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={step.note}>
|
||||
<div class="text-xs text-gray-10">{step.note}</div>
|
||||
</Show>
|
||||
<Show when={step.url}>
|
||||
<div class="text-xs text-gray-10">
|
||||
Open: <span class="font-mono text-gray-11">{step.url}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={step.path}>
|
||||
<div class="text-xs text-gray-10">
|
||||
Path: <span class="font-mono text-gray-11">{step.path}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
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">
|
||||
{t("plugins.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-10">{t("plugins.enabled")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[11px] text-red-11 hover:text-red-12"
|
||||
onClick={() => extensions.removePlugin(pluginName)}
|
||||
disabled={props.busy || !props.canEditPlugins}
|
||||
>
|
||||
{t("plugins.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
label={t("plugins.add_label")}
|
||||
placeholder="opencode-wakatime"
|
||||
value={extensions.pluginInput()}
|
||||
onInput={(e) => extensions.setPluginInput(e.currentTarget.value)}
|
||||
hint={t("plugins.add_hint")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => extensions.addPlugin()}
|
||||
disabled={props.busy || !extensions.pluginInput().trim() || !props.canEditPlugins}
|
||||
class="md:mt-6"
|
||||
>
|
||||
{t("plugins.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={extensions.pluginStatus()}>
|
||||
<div class="text-xs text-gray-10">{extensions.pluginStatus()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js";
|
||||
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
|
||||
import { createDenClient, writeDenSettings } from "../lib/den";
|
||||
import { dispatchDenSessionUpdated } from "../lib/den-session-events";
|
||||
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 recentHandledDenGrants = new Map<string, number>();
|
||||
|
||||
const pruneRecentHandledDenGrants = (now: number) => {
|
||||
for (const [grant, seenAt] of recentHandledDenGrants) {
|
||||
if (now - seenAt > 5 * 60 * 1000) {
|
||||
recentHandledDenGrants.delete(grant);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
pruneRecentHandledDenGrants(now);
|
||||
if (recentHandledDenGrants.has(parsed.grant)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentPending = pendingDenAuthDeepLink();
|
||||
if (currentPending?.grant === parsed.grant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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: t("app.error_deep_link_unrecognized", currentLocale()) };
|
||||
}
|
||||
|
||||
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: t("app.deep_link_auth_queued", currentLocale()) };
|
||||
}
|
||||
|
||||
setPendingRemoteConnectDeepLink(parsed.kind === "remote" ? parsed.link : null);
|
||||
options.setSettingsTab("automations");
|
||||
return { ok: true, message: t("app.deep_link_remote_queued", currentLocale()) };
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingDenAuthDeepLink();
|
||||
if (!pending || options.booting() || processingDenAuthDeepLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingDenAuthDeepLink(true);
|
||||
setPendingDenAuthDeepLink(null);
|
||||
recentHandledDenGrants.set(pending.grant, Date.now());
|
||||
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(t("app.error_desktop_signin", currentLocale()));
|
||||
}
|
||||
|
||||
writeDenSettings({
|
||||
baseUrl: pending.denBaseUrl,
|
||||
authToken: result.token,
|
||||
activeOrgId: null,
|
||||
activeOrgSlug: null,
|
||||
activeOrgName: null,
|
||||
});
|
||||
|
||||
dispatchDenSessionUpdated({
|
||||
status: "success",
|
||||
baseUrl: pending.denBaseUrl,
|
||||
token: result.token,
|
||||
user: result.user,
|
||||
email: result.user?.email ?? null,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
recentHandledDenGrants.delete(pending.grant);
|
||||
dispatchDenSessionUpdated({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : t("app.error_cloud_signin", currentLocale()),
|
||||
});
|
||||
})
|
||||
.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
@@ -1,127 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,812 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js";
|
||||
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client";
|
||||
import type { ProviderListItem } from "./types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
|
||||
import type {
|
||||
Client,
|
||||
PluginScope,
|
||||
ReloadReason,
|
||||
ReloadTrigger,
|
||||
ResetOpenworkMode,
|
||||
UpdateHandle,
|
||||
} from "./types";
|
||||
import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils";
|
||||
import { filterProviderList, mapConfigProvidersToList } from "./utils/providers";
|
||||
import { createUpdaterState, type UpdateStatus } from "./context/updater";
|
||||
import { createDenClient, readDenSettings, type DenDesktopConfig } from "./lib/den";
|
||||
import { recordDevLog } from "./lib/dev-log";
|
||||
import {
|
||||
resetOpenworkState,
|
||||
resetOpencodeCache,
|
||||
sandboxCleanupOpenworkContainers,
|
||||
} from "./lib/tauri";
|
||||
import { unwrap, waitForHealthy } from "./lib/opencode";
|
||||
|
||||
function throttle<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delayMs: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastCall = 0;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
lastArgs = args;
|
||||
|
||||
if (now - lastCall >= delayMs) {
|
||||
lastCall = now;
|
||||
fn(...args);
|
||||
} else if (!timeoutId){
|
||||
timeoutId = setTimeout(() => {
|
||||
lastCall = Date.now();
|
||||
timeoutId = null;
|
||||
if (lastArgs) fn(...lastArgs);
|
||||
}, delayMs - (now - lastCall));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
function parseComparableVersion(value: string): { release: number[]; prerelease: string[] } | null {
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
const [versionCore] = normalized.split("+", 1);
|
||||
if (!versionCore) return null;
|
||||
|
||||
const [releasePart, prereleasePart = ""] = versionCore.split("-", 2);
|
||||
const release = releasePart.split(".").map((segment) => Number(segment));
|
||||
if (!release.length || release.some((segment) => !Number.isInteger(segment) || segment < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prerelease = prereleasePart
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { release, prerelease };
|
||||
}
|
||||
|
||||
function comparePrereleaseIdentifiers(left: string[], right: string[]): number {
|
||||
if (!left.length && !right.length) return 0;
|
||||
if (!left.length) return 1;
|
||||
if (!right.length) return -1;
|
||||
|
||||
const count = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = left[index];
|
||||
const rightPart = right[index];
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
|
||||
const leftNumeric = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
|
||||
const rightNumeric = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
|
||||
|
||||
if (leftNumeric !== null && rightNumeric !== null) {
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric < rightNumeric ? -1 : 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftNumeric !== null) return -1;
|
||||
if (rightNumeric !== null) return 1;
|
||||
|
||||
const comparison = leftPart.localeCompare(rightPart);
|
||||
if (comparison !== 0) return comparison < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareVersions(left: string, right: string): number | null {
|
||||
const parsedLeft = parseComparableVersion(left);
|
||||
const parsedRight = parseComparableVersion(right);
|
||||
if (!parsedLeft || !parsedRight) return null;
|
||||
|
||||
const count = Math.max(parsedLeft.release.length, parsedRight.release.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = parsedLeft.release[index] ?? 0;
|
||||
const rightPart = parsedRight.release[index] ?? 0;
|
||||
if (leftPart !== rightPart) return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
|
||||
return comparePrereleaseIdentifiers(parsedLeft.prerelease, parsedRight.prerelease);
|
||||
}
|
||||
|
||||
function logUpdateGateFailure(label: string, payload?: unknown) {
|
||||
try {
|
||||
recordDevLog(true, {
|
||||
level: "warn",
|
||||
source: "updates",
|
||||
label,
|
||||
payload,
|
||||
});
|
||||
if (payload === undefined) {
|
||||
console.warn(`[UPDATES] ${label}`);
|
||||
} else {
|
||||
console.warn(`[UPDATES] ${label}`, payload);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function logUpdateGateDebug(label: string, payload?: unknown) {
|
||||
try {
|
||||
recordDevLog(true, {
|
||||
level: "debug",
|
||||
source: "updates",
|
||||
label,
|
||||
payload,
|
||||
});
|
||||
if (payload === undefined) {
|
||||
console.log(`[UPDATES] ${label}`);
|
||||
} else {
|
||||
console.log(`[UPDATES] ${label}`, payload);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function isUpdateAllowedByDesktopConfig(
|
||||
updateVersion: string,
|
||||
desktopConfig: DenDesktopConfig | null | undefined,
|
||||
) {
|
||||
if (!Array.isArray(desktopConfig?.allowedDesktopVersions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return desktopConfig.allowedDesktopVersions.some(
|
||||
(allowedVersion) => compareVersions(updateVersion, allowedVersion) === 0,
|
||||
);
|
||||
}
|
||||
|
||||
async function isUpdateSupportedByDen(updateVersion: string) {
|
||||
try {
|
||||
const settings = readDenSettings();
|
||||
const token = settings.authToken?.trim() ?? "";
|
||||
logUpdateGateDebug("den-update-check-start", {
|
||||
updateVersion,
|
||||
hasToken: Boolean(token),
|
||||
activeOrgId: settings.activeOrgId ?? null,
|
||||
activeOrgSlug: settings.activeOrgSlug ?? null,
|
||||
baseUrl: settings.baseUrl,
|
||||
apiBaseUrl: settings.apiBaseUrl ?? null,
|
||||
});
|
||||
const client = createDenClient({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
...(token ? { token } : {}),
|
||||
});
|
||||
const metadata = await client.getAppVersionMetadata();
|
||||
const comparison = compareVersions(updateVersion, metadata.latestAppVersion);
|
||||
logUpdateGateDebug("den-update-check-app-version-response", {
|
||||
updateVersion,
|
||||
minAppVersion: metadata.minAppVersion,
|
||||
latestAppVersion: metadata.latestAppVersion,
|
||||
comparison,
|
||||
});
|
||||
if (comparison === null) {
|
||||
logUpdateGateFailure("den-update-check-invalid-version-comparison", {
|
||||
updateVersion,
|
||||
latestAppVersion: metadata.latestAppVersion,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
logUpdateGateDebug("den-update-check-blocked-by-server-max", {
|
||||
updateVersion,
|
||||
latestAppVersion: metadata.latestAppVersion,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
logUpdateGateDebug("den-update-check-allowed-no-token", { updateVersion });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const desktopConfig = await client.getDesktopConfig();
|
||||
const allowed = isUpdateAllowedByDesktopConfig(updateVersion, desktopConfig);
|
||||
logUpdateGateDebug("den-update-check-desktop-config-response", {
|
||||
updateVersion,
|
||||
allowedDesktopVersions: desktopConfig.allowedDesktopVersions ?? null,
|
||||
allowed,
|
||||
});
|
||||
return allowed;
|
||||
} catch (error) {
|
||||
logUpdateGateFailure("den-update-check-desktop-config-fetch-failed", {
|
||||
updateVersion,
|
||||
error: error instanceof Error ? error.message : safeStringify(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logUpdateGateFailure("den-update-check-app-version-fetch-failed", {
|
||||
updateVersion,
|
||||
error: error instanceof Error ? error.message : safeStringify(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSystemState(options: {
|
||||
client: Accessor<Client | null>;
|
||||
sessions: Accessor<Session[]>;
|
||||
sessionStatusById: Accessor<Record<string, string>>;
|
||||
refreshPlugins: (scopeOverride?: PluginScope) => Promise<void>;
|
||||
refreshSkills: (options?: { force?: boolean }) => Promise<void>;
|
||||
refreshMcpServers?: () => Promise<void>;
|
||||
reloadWorkspaceEngine?: () => Promise<boolean>;
|
||||
canReloadWorkspaceEngine?: () => boolean;
|
||||
setProviders: (value: ProviderListItem[]) => void;
|
||||
setProviderDefaults: (value: Record<string, string>) => void;
|
||||
setProviderConnectedIds: (value: string[]) => void;
|
||||
setError: (value: string | null) => void;
|
||||
}) {
|
||||
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);
|
||||
const [reloadTrigger, setReloadTrigger] = createSignal<ReloadTrigger | null>(null);
|
||||
const [reloadBusy, setReloadBusy] = createSignal(false);
|
||||
const [reloadError, setReloadError] = createSignal<string | null>(null);
|
||||
|
||||
const [cacheRepairBusy, setCacheRepairBusy] = createSignal(false);
|
||||
const [cacheRepairResult, setCacheRepairResult] = createSignal<string | null>(null);
|
||||
const [dockerCleanupBusy, setDockerCleanupBusy] = createSignal(false);
|
||||
const [dockerCleanupResult, setDockerCleanupResult] = createSignal<string | null>(null);
|
||||
|
||||
const updater = createUpdaterState();
|
||||
const {
|
||||
updateAutoCheck,
|
||||
setUpdateAutoCheck,
|
||||
updateAutoDownload,
|
||||
setUpdateAutoDownload,
|
||||
updateStatus,
|
||||
setUpdateStatus,
|
||||
pendingUpdate,
|
||||
setPendingUpdate,
|
||||
updateEnv,
|
||||
setUpdateEnv,
|
||||
} = updater;
|
||||
|
||||
const [resetModalOpen, setResetModalOpen] = createSignal(false);
|
||||
const [resetModalMode, setResetModalMode] = createSignal<ResetOpenworkMode>("onboarding");
|
||||
const [resetModalText, setResetModalText] = createSignal("");
|
||||
const [resetModalBusy, setResetModalBusy] = createSignal(false);
|
||||
|
||||
const resetModalTextValue = resetModalText;
|
||||
|
||||
const anyActiveRuns = createMemo(() => {
|
||||
const statuses = options.sessionStatusById();
|
||||
return options.sessions().some((s) => isActiveSessionStatus(statuses[s.id]));
|
||||
});
|
||||
|
||||
function clearOpenworkLocalStorage(mode: ResetOpenworkMode) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
if (mode === "all") {
|
||||
window.localStorage.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(window.localStorage);
|
||||
for (const key of keys) {
|
||||
if (key.includes("openwork")) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
// Legacy compatibility key
|
||||
window.localStorage.removeItem("openwork_mode_pref");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function openResetModal(mode: ResetOpenworkMode) {
|
||||
if (anyActiveRuns()) {
|
||||
options.setError(t("system.stop_active_runs_before_reset"));
|
||||
return;
|
||||
}
|
||||
|
||||
options.setError(null);
|
||||
setResetModalMode(mode);
|
||||
setResetModalText("");
|
||||
setResetModalOpen(true);
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
if (resetModalBusy()) return;
|
||||
|
||||
if (anyActiveRuns()) {
|
||||
options.setError(t("system.stop_active_runs_before_reset"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (resetModalTextValue().trim().toUpperCase() !== "RESET") return;
|
||||
|
||||
setResetModalBusy(true);
|
||||
options.setError(null);
|
||||
|
||||
try {
|
||||
if (isTauriRuntime()) {
|
||||
await resetOpenworkState(resetModalMode());
|
||||
}
|
||||
|
||||
clearOpenworkLocalStorage(resetModalMode());
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
await relaunch();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
options.setError(addOpencodeCacheHint(message));
|
||||
setResetModalBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function markReloadRequired(reason: ReloadReason, trigger?: ReloadTrigger) {
|
||||
setReloadPending(true);
|
||||
setReloadLastTriggeredAt(Date.now());
|
||||
setReloadReasons((current) => (current.includes(reason) ? current : [...current, reason]));
|
||||
if (trigger) {
|
||||
setReloadTrigger(trigger);
|
||||
} else {
|
||||
setReloadTrigger({
|
||||
type:
|
||||
reason === "plugins"
|
||||
? "plugin"
|
||||
: reason === "skills"
|
||||
? "skill"
|
||||
: reason === "agents"
|
||||
? "agent"
|
||||
: reason === "commands"
|
||||
? "command"
|
||||
: reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearReloadRequired() {
|
||||
setReloadPending(false);
|
||||
setReloadReasons([]);
|
||||
setReloadError(null);
|
||||
setReloadTrigger(null);
|
||||
}
|
||||
|
||||
const reloadCopy = createMemo(() => {
|
||||
const title = t("system.reload_required");
|
||||
const reasons = reloadReasons();
|
||||
|
||||
const bodyKey =
|
||||
reasons.length === 1 && reasons[0] === "plugins" ? "system.reload_body_plugins"
|
||||
: reasons.length === 1 && reasons[0] === "skills" ? "system.reload_body_skills"
|
||||
: reasons.length === 1 && reasons[0] === "agents" ? "system.reload_body_agents"
|
||||
: reasons.length === 1 && reasons[0] === "commands" ? "system.reload_body_commands"
|
||||
: reasons.length === 1 && reasons[0] === "config" ? "system.reload_body_config"
|
||||
: reasons.length === 1 && reasons[0] === "mcp" ? "system.reload_body_mcp"
|
||||
: reasons.length > 0 ? "system.reload_body_mixed"
|
||||
: "system.reload_body_default";
|
||||
|
||||
return { title, body: t(bodyKey) };
|
||||
});
|
||||
|
||||
const canReloadEngine = createMemo(() => {
|
||||
if (!reloadPending()) return false;
|
||||
if (reloadBusy()) return false;
|
||||
const override = options.canReloadWorkspaceEngine?.();
|
||||
if (override === true) return true;
|
||||
if (override === false) return false;
|
||||
if (!options.client()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Keep this mounted so the reload banner UX remains in the app.
|
||||
createEffect(() => {
|
||||
reloadPending();
|
||||
});
|
||||
|
||||
async function reloadEngineInstance() {
|
||||
const initialClient = options.client();
|
||||
if (!initialClient) return;
|
||||
|
||||
const override = options.canReloadWorkspaceEngine?.();
|
||||
if (override === false) {
|
||||
setReloadError(t("system.reload_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
// if (anyActiveRuns()) {
|
||||
// setReloadError("Waiting for active tasks to complete before reloading.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
setReloadBusy(true);
|
||||
setReloadError(null);
|
||||
|
||||
try {
|
||||
if (options.reloadWorkspaceEngine) {
|
||||
const ok = await options.reloadWorkspaceEngine();
|
||||
if (ok === false) {
|
||||
setReloadError(t("system.reload_failed"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
unwrap(await initialClient.instance.dispose());
|
||||
}
|
||||
|
||||
const nextClient = options.client();
|
||||
if (!nextClient) {
|
||||
throw new Error("OpenCode client unavailable after reload.");
|
||||
}
|
||||
|
||||
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 = 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()) 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({});
|
||||
options.setProviderConnectedIds([]);
|
||||
}
|
||||
}
|
||||
|
||||
await options.refreshPlugins("project").catch(() => undefined);
|
||||
await options.refreshSkills({ force: true }).catch(() => undefined);
|
||||
await options.refreshMcpServers?.().catch(() => undefined);
|
||||
|
||||
clearReloadRequired();
|
||||
} catch (e) {
|
||||
setReloadError(e instanceof Error ? e.message : safeStringify(e));
|
||||
} finally {
|
||||
setReloadBusy(false);
|
||||
setReloadLastFinishedAt(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadWorkspaceEngine() {
|
||||
await reloadEngineInstance();
|
||||
}
|
||||
|
||||
async function repairOpencodeCache() {
|
||||
if (!isTauriRuntime()) {
|
||||
setCacheRepairResult(t("system.cache_repair_requires_desktop"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (cacheRepairBusy()) return;
|
||||
|
||||
setCacheRepairBusy(true);
|
||||
setCacheRepairResult(null);
|
||||
options.setError(null);
|
||||
|
||||
try {
|
||||
const result = await resetOpencodeCache();
|
||||
if (result.errors.length) {
|
||||
setCacheRepairResult(result.errors[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.removed.length) {
|
||||
setCacheRepairResult(t("settings.cache_repaired"));
|
||||
} else {
|
||||
setCacheRepairResult(t("settings.cache_nothing_to_repair"));
|
||||
}
|
||||
} catch (e) {
|
||||
setCacheRepairResult(e instanceof Error ? e.message : safeStringify(e));
|
||||
} finally {
|
||||
setCacheRepairBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOpenworkDockerContainers() {
|
||||
if (!isTauriRuntime()) {
|
||||
setDockerCleanupResult(t("system.docker_cleanup_requires_desktop"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (dockerCleanupBusy()) return;
|
||||
|
||||
setDockerCleanupBusy(true);
|
||||
setDockerCleanupResult(null);
|
||||
options.setError(null);
|
||||
|
||||
try {
|
||||
const result = await sandboxCleanupOpenworkContainers();
|
||||
if (!result.candidates.length) {
|
||||
setDockerCleanupResult("No OpenWork Docker containers found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const removedCount = result.removed.length;
|
||||
if (result.errors.length) {
|
||||
const first = result.errors[0];
|
||||
setDockerCleanupResult(
|
||||
`Removed ${removedCount}/${result.candidates.length} containers. ${first}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setDockerCleanupResult(`Removed ${removedCount} OpenWork Docker container(s).`);
|
||||
} catch (e) {
|
||||
setDockerCleanupResult(e instanceof Error ? e.message : safeStringify(e));
|
||||
} finally {
|
||||
setDockerCleanupBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
setUpdateStatus({
|
||||
state: "error",
|
||||
lastCheckedAt:
|
||||
updateStatus().state === "idle"
|
||||
? (updateStatus() as { state: "idle"; lastCheckedAt: number | null }).lastCheckedAt
|
||||
: null,
|
||||
message: env.reason ?? t("system.updates_not_supported"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = updateStatus();
|
||||
setUpdateStatus({ state: "checking", startedAt: Date.now() });
|
||||
|
||||
try {
|
||||
const update = (await check({ timeout: 8_000 })) as unknown as UpdateHandle | null;
|
||||
const checkedAt = Date.now();
|
||||
|
||||
logUpdateGateDebug("tauri-update-check-result", update
|
||||
? {
|
||||
available: update.available,
|
||||
currentVersion: update.currentVersion,
|
||||
version: update.version,
|
||||
date: update.date ?? null,
|
||||
}
|
||||
: { available: false });
|
||||
|
||||
if (!update) {
|
||||
setPendingUpdate(null);
|
||||
setUpdateStatus({ state: "idle", lastCheckedAt: checkedAt });
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = typeof update.body === "string" ? update.body : undefined;
|
||||
|
||||
if (!(await isUpdateSupportedByDen(update.version))) {
|
||||
logUpdateGateDebug("tauri-update-check-suppressed-by-den", {
|
||||
version: update.version,
|
||||
});
|
||||
setPendingUpdate(null);
|
||||
setUpdateStatus({ state: "idle", lastCheckedAt: checkedAt });
|
||||
return;
|
||||
}
|
||||
|
||||
logUpdateGateDebug("tauri-update-check-allowed-by-den", {
|
||||
version: update.version,
|
||||
});
|
||||
setPendingUpdate({ update, version: update.version, notes });
|
||||
setUpdateStatus({
|
||||
state: "available",
|
||||
lastCheckedAt: checkedAt,
|
||||
version: update.version,
|
||||
date: update.date,
|
||||
notes,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
|
||||
if (optionsCheck?.quiet) {
|
||||
setUpdateStatus(prev);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingUpdate(null);
|
||||
setUpdateStatus({ state: "error", lastCheckedAt: null, message });
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUpdate() {
|
||||
const pending = pendingUpdate();
|
||||
if (!pending) return;
|
||||
|
||||
const state = updateStatus();
|
||||
if (state.state === "downloading" || state.state === "ready") return;
|
||||
|
||||
options.setError(null);
|
||||
const lastCheckedAt = state.state === "available" ? state.lastCheckedAt : Date.now();
|
||||
|
||||
setUpdateStatus({
|
||||
state: "downloading",
|
||||
lastCheckedAt,
|
||||
version: pending.version,
|
||||
totalBytes: null,
|
||||
downloadedBytes: 0,
|
||||
notes: pending.notes,
|
||||
});
|
||||
|
||||
let accumulatedBytes = 0;
|
||||
let totalBytes: number | null = null;
|
||||
|
||||
const throttledUpdateProgress = throttle(() => {
|
||||
setUpdateStatus((current) => {
|
||||
if (current.state !== "downloading") return current;
|
||||
return {
|
||||
...current,
|
||||
totalBytes,
|
||||
downloadedBytes: accumulatedBytes,
|
||||
};
|
||||
});
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
await pending.update.download((event: any) => {
|
||||
if (!event || typeof event !== "object") return;
|
||||
const record = event as Record<string, any>;
|
||||
|
||||
if (record.event === "Started") {
|
||||
const newTotal =
|
||||
record.data && typeof record.data.contentLength === "number"
|
||||
? record.data.contentLength
|
||||
: null;
|
||||
totalBytes = newTotal;
|
||||
throttledUpdateProgress();
|
||||
}
|
||||
|
||||
if (record.event === "Progress") {
|
||||
const chunk =
|
||||
record.data && typeof record.data.chunkLength === "number"
|
||||
? record.data.chunkLength
|
||||
: 0;
|
||||
accumulatedBytes += chunk;
|
||||
throttledUpdateProgress();
|
||||
}
|
||||
});
|
||||
|
||||
setUpdateStatus({
|
||||
state: "ready",
|
||||
lastCheckedAt,
|
||||
version: pending.version,
|
||||
notes: pending.notes,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
setUpdateStatus({ state: "error", lastCheckedAt, message });
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdateAndRestart() {
|
||||
const pending = pendingUpdate();
|
||||
if (!pending) return;
|
||||
|
||||
if (anyActiveRuns()) {
|
||||
options.setError(t("system.stop_runs_before_update"));
|
||||
return;
|
||||
}
|
||||
|
||||
options.setError(null);
|
||||
try {
|
||||
await pending.update.install();
|
||||
await pending.update.close();
|
||||
await relaunch();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
setUpdateStatus({ state: "error", lastCheckedAt: null, message });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reloadPending,
|
||||
reloadReasons,
|
||||
reloadLastTriggeredAt,
|
||||
reloadLastFinishedAt,
|
||||
setReloadLastFinishedAt,
|
||||
reloadTrigger,
|
||||
reloadBusy,
|
||||
reloadError,
|
||||
reloadCopy,
|
||||
canReloadEngine,
|
||||
markReloadRequired,
|
||||
clearReloadRequired,
|
||||
reloadEngineInstance,
|
||||
reloadWorkspaceEngine,
|
||||
cacheRepairBusy,
|
||||
cacheRepairResult,
|
||||
repairOpencodeCache,
|
||||
dockerCleanupBusy,
|
||||
dockerCleanupResult,
|
||||
cleanupOpenworkDockerContainers,
|
||||
updateAutoCheck,
|
||||
setUpdateAutoCheck,
|
||||
updateAutoDownload,
|
||||
setUpdateAutoDownload,
|
||||
updateStatus,
|
||||
setUpdateStatus,
|
||||
pendingUpdate,
|
||||
setPendingUpdate,
|
||||
updateEnv,
|
||||
setUpdateEnv,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdateAndRestart,
|
||||
resetModalOpen,
|
||||
setResetModalOpen,
|
||||
resetModalMode,
|
||||
setResetModalMode,
|
||||
resetModalText: resetModalTextValue,
|
||||
setResetModalText,
|
||||
resetModalBusy,
|
||||
openResetModal,
|
||||
confirmReset,
|
||||
anyActiveRuns,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +154,17 @@ export type View = "settings" | "session" | "signin";
|
||||
|
||||
export type StartupPreference = "local" | "server";
|
||||
|
||||
/**
|
||||
* Release channel the desktop app is subscribed to.
|
||||
*
|
||||
* - "stable": default. Auto-updates from the rolling stable GitHub release.
|
||||
* - "alpha": macOS-only. Auto-updates from the rolling alpha release that
|
||||
* every merge to `dev` publishes to.
|
||||
*
|
||||
* See `apps/app/src/app/lib/release-channels.ts` for URL resolution.
|
||||
*/
|
||||
export type ReleaseChannel = "stable" | "alpha";
|
||||
|
||||
export type EngineRuntime = "direct" | "openwork-orchestrator";
|
||||
|
||||
export type OnboardingStep = "welcome" | "local" | "server" | "connecting";
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import { createResource, type Accessor } from "solid-js";
|
||||
import type { SetStoreFunction, Store } from "solid-js/store";
|
||||
|
||||
import { usePlatform, type AsyncStorage, type SyncStorage } from "../context/platform";
|
||||
|
||||
type InitType = Promise<string> | string | null;
|
||||
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>];
|
||||
|
||||
type PersistTarget = {
|
||||
storage?: string;
|
||||
key: string;
|
||||
legacy?: string[];
|
||||
migrate?: (value: unknown) => unknown;
|
||||
};
|
||||
|
||||
const LEGACY_STORAGE = "default.dat";
|
||||
const GLOBAL_STORAGE = "openwork.global.dat";
|
||||
|
||||
function snapshot(value: unknown) {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function merge(defaults: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return defaults;
|
||||
if (value === null) return value;
|
||||
|
||||
if (Array.isArray(defaults)) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (isRecord(defaults)) {
|
||||
if (!isRecord(value)) return defaults;
|
||||
|
||||
const result: Record<string, unknown> = { ...defaults };
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key in defaults) {
|
||||
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key]);
|
||||
} else {
|
||||
result[key] = (value as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parse(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function checksum(input: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function workspaceStorage(dir: string) {
|
||||
const head = dir.slice(0, 12) || "workspace";
|
||||
const sum = checksum(dir);
|
||||
return `openwork.workspace.${head}.${sum}.dat`;
|
||||
}
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`;
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(base + key),
|
||||
setItem: (key, value) => localStorage.setItem(base + key, value),
|
||||
removeItem: (key) => localStorage.removeItem(base + key),
|
||||
};
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy };
|
||||
},
|
||||
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy };
|
||||
},
|
||||
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy };
|
||||
},
|
||||
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
|
||||
if (session) return Persist.session(dir, session, key, legacy);
|
||||
return Persist.workspace(dir, key, legacy);
|
||||
},
|
||||
};
|
||||
|
||||
export function persisted<T>(
|
||||
target: string | PersistTarget,
|
||||
store: [Store<T>, SetStoreFunction<T>],
|
||||
): PersistedWithReady<T> {
|
||||
const platform = usePlatform();
|
||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target;
|
||||
|
||||
const defaults = snapshot(store[0]);
|
||||
const legacy = config.legacy ?? [];
|
||||
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage;
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (isDesktop) return platform.storage?.(config.storage);
|
||||
if (!config.storage) return localStorage;
|
||||
return localStorageWithPrefix(config.storage);
|
||||
})();
|
||||
|
||||
const legacyStorage = (() => {
|
||||
if (!isDesktop) return localStorage;
|
||||
if (!config.storage) return platform.storage?.();
|
||||
return platform.storage?.(LEGACY_STORAGE);
|
||||
})();
|
||||
|
||||
const storage = (() => {
|
||||
if (!isDesktop) {
|
||||
const current = currentStorage as SyncStorage;
|
||||
const legacyStore = legacyStorage as SyncStorage;
|
||||
|
||||
const api: SyncStorage = {
|
||||
getItem: (key) => {
|
||||
const raw = current.getItem(key);
|
||||
if (raw !== null) {
|
||||
const parsed = parse(raw);
|
||||
if (parsed === undefined) return raw;
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed;
|
||||
const merged = merge(defaults, migrated);
|
||||
const next = JSON.stringify(merged);
|
||||
if (raw !== next) current.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
for (const legacyKey of legacy) {
|
||||
const legacyRaw = legacyStore.getItem(legacyKey);
|
||||
if (legacyRaw === null) continue;
|
||||
|
||||
current.setItem(key, legacyRaw);
|
||||
legacyStore.removeItem(legacyKey);
|
||||
|
||||
const parsed = parse(legacyRaw);
|
||||
if (parsed === undefined) return legacyRaw;
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed;
|
||||
const merged = merge(defaults, migrated);
|
||||
const next = JSON.stringify(merged);
|
||||
if (legacyRaw !== next) current.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
current.setItem(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
current.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
const current = currentStorage as AsyncStorage;
|
||||
const legacyStore = legacyStorage as AsyncStorage | undefined;
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
const raw = await current.getItem(key);
|
||||
if (raw !== null) {
|
||||
const parsed = parse(raw);
|
||||
if (parsed === undefined) return raw;
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed;
|
||||
const merged = merge(defaults, migrated);
|
||||
const next = JSON.stringify(merged);
|
||||
if (raw !== next) await current.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!legacyStore) return null;
|
||||
|
||||
for (const legacyKey of legacy) {
|
||||
const legacyRaw = await legacyStore.getItem(legacyKey);
|
||||
if (legacyRaw === null) continue;
|
||||
|
||||
await current.setItem(key, legacyRaw);
|
||||
await legacyStore.removeItem(legacyKey);
|
||||
|
||||
const parsed = parse(legacyRaw);
|
||||
if (parsed === undefined) return legacyRaw;
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed;
|
||||
const merged = merge(defaults, migrated);
|
||||
const next = JSON.stringify(merged);
|
||||
if (legacyRaw !== next) await current.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
await current.setItem(key, value);
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await current.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
})();
|
||||
|
||||
const [state, setState, init] = makePersisted(store, { name: config.key, storage });
|
||||
|
||||
const isAsync = init instanceof Promise;
|
||||
const [ready] = createResource(
|
||||
() => init,
|
||||
async (initValue) => {
|
||||
if (initValue instanceof Promise) await initValue;
|
||||
return true;
|
||||
},
|
||||
{ initialValue: !isAsync },
|
||||
);
|
||||
|
||||
return [state, setState, init, () => ready() === true];
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { X } from "lucide-solid";
|
||||
|
||||
import { currentLocale, t } from "../../i18n";
|
||||
import {
|
||||
modalHeaderButtonClass,
|
||||
modalHeaderClass,
|
||||
modalOverlayClass,
|
||||
modalShellClass,
|
||||
modalTitleClass,
|
||||
modalSubtitleClass,
|
||||
modalBodyClass,
|
||||
pillGhostClass,
|
||||
pillPrimaryClass,
|
||||
errorBannerClass,
|
||||
} from "./modal-styles";
|
||||
import RemoteWorkspaceFields from "./remote-workspace-fields";
|
||||
import type { CreateRemoteWorkspaceModalProps } from "./types";
|
||||
|
||||
export default function CreateRemoteWorkspaceModal(props: CreateRemoteWorkspaceModalProps) {
|
||||
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;
|
||||
});
|
||||
|
||||
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={`${modalShellClass} max-w-[560px]`}>
|
||||
<div class={modalHeaderClass}>
|
||||
<div class="min-w-0">
|
||||
<h3 class={modalTitleClass}>{title()}</h3>
|
||||
<p class={modalSubtitleClass}>{subtitle()}</p>
|
||||
</div>
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
disabled={submitting()}
|
||||
class={modalHeaderButtonClass}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={modalBodyClass}>
|
||||
<RemoteWorkspaceFields
|
||||
hostUrl={openworkHostUrl()}
|
||||
onHostUrlInput={setOpenworkHostUrl}
|
||||
token={openworkToken()}
|
||||
tokenVisible={openworkTokenVisible()}
|
||||
onTokenInput={setOpenworkToken}
|
||||
onToggleTokenVisible={() => setOpenworkTokenVisible((prev) => !prev)}
|
||||
displayName={displayName()}
|
||||
onDisplayNameInput={setDisplayName}
|
||||
directory={directory()}
|
||||
onDirectoryInput={setDirectory}
|
||||
showDirectory={true}
|
||||
submitting={submitting()}
|
||||
hostInputRef={inputRef}
|
||||
title="Remote server details"
|
||||
description="Use the URL your OpenWork server shared with you. Add a token only if the server needs one."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 border-t border-dls-border px-6 py-5">
|
||||
<Show when={props.error}>
|
||||
<div class={errorBannerClass}>{props.error}</div>
|
||||
</Show>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={submitting()}
|
||||
class={pillGhostClass}
|
||||
>
|
||||
{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={pillPrimaryClass}
|
||||
>
|
||||
{confirmLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={props.open || isInline()}>
|
||||
<div class={isInline() ? "w-full" : modalOverlayClass}>{content}</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { For, Show } from "solid-js";
|
||||
|
||||
import { Boxes, FolderPlus, Loader2, XCircle } from "lucide-solid";
|
||||
|
||||
import type { DenTemplate } from "../lib/den";
|
||||
import type { WorkspacePreset } from "../types";
|
||||
import {
|
||||
errorBannerClass,
|
||||
iconTileClass,
|
||||
modalBodyClass,
|
||||
modalFooterClass,
|
||||
pillGhostClass,
|
||||
pillPrimaryClass,
|
||||
pillSecondaryClass,
|
||||
sectionBodyClass,
|
||||
sectionTitleClass,
|
||||
softCardClass,
|
||||
surfaceCardClass,
|
||||
tagClass,
|
||||
warningBannerClass,
|
||||
} from "./modal-styles";
|
||||
|
||||
export default function CreateWorkspaceLocalPanel(props: {
|
||||
translate: (key: string) => string;
|
||||
selectedFolder: string | null;
|
||||
hasSelectedFolder: boolean;
|
||||
pickingFolder: boolean;
|
||||
onPickFolder: () => void;
|
||||
submitting: boolean;
|
||||
selectedTemplateId: string | null;
|
||||
setSelectedTemplateId: (next: string | null | ((current: string | null) => string | null)) => void;
|
||||
showTemplateSection: boolean;
|
||||
cloudWorkspaceTemplates: DenTemplate[];
|
||||
templateCreatorLabel: (template: DenTemplate) => string;
|
||||
formatTemplateTimestamp: (value: string | null) => string;
|
||||
templateError: string | null;
|
||||
templateCacheBusy: boolean;
|
||||
templateCacheError: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
confirmLabel?: string;
|
||||
workerLabel?: string;
|
||||
onConfirmWorker?: (preset: WorkspacePreset, folder: string | null) => void;
|
||||
preset: WorkspacePreset;
|
||||
workerSubmitting: boolean;
|
||||
workerDisabled: boolean;
|
||||
workerDisabledReason: string | null;
|
||||
workerCtaLabel?: string;
|
||||
workerCtaDescription?: string;
|
||||
onWorkerCta?: () => void;
|
||||
workerRetryLabel?: string;
|
||||
onWorkerRetry?: () => void;
|
||||
workerDebugLines: string[];
|
||||
progress: {
|
||||
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;
|
||||
elapsedSeconds: number;
|
||||
showProgressDetails: boolean;
|
||||
onToggleProgressDetails: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div class={`${modalBodyClass} transition-opacity duration-300 ${props.submitting ? "pointer-events-none opacity-40" : "opacity-100"}`}>
|
||||
<div class="space-y-4">
|
||||
<div class={surfaceCardClass}>
|
||||
<div class={sectionTitleClass}>Workspace folder</div>
|
||||
<div class={sectionBodyClass}>Choose where this workspace should live on your device.</div>
|
||||
<div class="mt-4 rounded-[20px] border border-dls-border bg-dls-hover px-4 py-3">
|
||||
<Show when={props.hasSelectedFolder} fallback={<span class="text-[14px] text-dls-secondary">No folder selected yet.</span>}>
|
||||
<span class="block truncate font-mono text-[12px] text-dls-text">{props.selectedFolder}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onPickFolder}
|
||||
disabled={props.pickingFolder || props.submitting}
|
||||
class={pillSecondaryClass}
|
||||
>
|
||||
<Show when={props.pickingFolder} fallback={<FolderPlus size={14} />}>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
</Show>
|
||||
{props.hasSelectedFolder ? props.translate("dashboard.change") : "Select folder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.showTemplateSection}>
|
||||
<div class={surfaceCardClass}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium tracking-[-0.2px] text-dls-text">
|
||||
<Boxes size={16} class="text-dls-secondary" />
|
||||
Team templates
|
||||
</div>
|
||||
<div class="mt-1 text-[13px] leading-relaxed text-dls-secondary">
|
||||
Choose a starting point, or leave blank to create an empty workspace.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.templateCacheBusy}>
|
||||
<div class={tagClass}>
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
Syncing
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.templateError || props.templateCacheError}>
|
||||
{(value) => <div class={`mt-4 ${errorBannerClass}`}>{value()}</div>}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={props.cloudWorkspaceTemplates.length > 0}
|
||||
fallback={
|
||||
<div class={`mt-4 ${softCardClass} text-[14px] text-dls-secondary`}>
|
||||
No shared workspace templates found for this org yet.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mt-4 space-y-3">
|
||||
<For each={props.cloudWorkspaceTemplates}>
|
||||
{(template) => {
|
||||
const selected = () => props.selectedTemplateId === template.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`${surfaceCardClass} w-full transition-all duration-150 hover:border-dls-border hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)] ${selected() ? "border-[rgba(var(--dls-accent-rgb),0.2)] bg-[rgba(var(--dls-accent-rgb),0.06)] shadow-[inset_0_0_0_1px_rgba(var(--dls-accent-rgb),0.08)]" : ""}`.trim()}
|
||||
onClick={() =>
|
||||
props.setSelectedTemplateId((current) =>
|
||||
current === template.id ? null : template.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="truncate text-[14px] font-medium text-dls-text">{template.name}</div>
|
||||
<Show when={selected()}>
|
||||
<span class={tagClass}>Selected</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-1 text-[12px] text-dls-secondary">
|
||||
{props.templateCreatorLabel(template)} · {props.formatTemplateTimestamp(template.updatedAt ?? template.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div class={`mt-1 h-4 w-4 shrink-0 rounded-full border ${selected() ? "border-[var(--dls-accent)] bg-[var(--dls-accent)]" : "border-dls-border bg-dls-surface"}`.trim()} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={modalFooterClass}>
|
||||
<Show when={props.submitting && props.progress}>
|
||||
{(progress) => (
|
||||
<div class={softCardClass}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-[12px] font-semibold text-dls-text">
|
||||
<Show when={!progress().error} fallback={<XCircle size={14} class="text-red-11" />}>
|
||||
<Loader2 size={14} class="animate-spin text-dls-accent" />
|
||||
</Show>
|
||||
Sandbox setup
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[14px] leading-snug text-dls-text">{progress().stage}</div>
|
||||
<div class="mt-1 font-mono text-[10px] uppercase tracking-wider text-dls-secondary">{props.elapsedSeconds}s</div>
|
||||
</div>
|
||||
<button type="button" class={pillGhostClass} onClick={props.onToggleProgressDetails}>
|
||||
{props.showProgressDetails ? "Hide logs" : "Show logs"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={progress().error}>
|
||||
{(err) => <div class={`mt-3 ${errorBannerClass}`}>{err()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="mt-4 grid gap-2.5">
|
||||
<For each={progress().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-dls-accent" />;
|
||||
if (step.status === "error") return <XCircle size={16} class="text-red-10" />;
|
||||
return <div class="h-4 w-4 rounded-full border-2 border-dls-border" />;
|
||||
};
|
||||
|
||||
const textClass = () => {
|
||||
if (step.status === "done") return "text-dls-text font-medium";
|
||||
if (step.status === "active") return "text-dls-text font-semibold";
|
||||
if (step.status === "error") return "text-red-11 font-medium";
|
||||
return "text-dls-secondary";
|
||||
};
|
||||
|
||||
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-[12px] ${textClass()} transition-colors duration-200`.trim()}>{step.label}</div>
|
||||
<Show when={(step.detail ?? "").trim()}>
|
||||
<div class={`${tagClass} max-w-[120px] truncate font-mono`}>{step.detail}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.showProgressDetails && (progress().logs?.length ?? 0) > 0}>
|
||||
<div class={`mt-3 ${softCardClass}`}>
|
||||
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-dls-secondary">Live logs</div>
|
||||
<div class="max-h-[120px] space-y-0.5 overflow-y-auto">
|
||||
<For each={progress().logs.slice(-10)}>
|
||||
{(line) => <div class="break-all font-mono text-[10px] leading-tight text-dls-text">{line}</div>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={props.onConfirmWorker && props.workerDisabled && props.workerDisabledReason}>
|
||||
<div class={warningBannerClass}>
|
||||
<div class="font-semibold text-amber-12">{props.translate("dashboard.sandbox_get_ready_title")}</div>
|
||||
<div class="mt-1 leading-relaxed">{props.workerDisabledReason || props.workerCtaDescription}</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Show when={props.onWorkerCta && props.workerCtaLabel?.trim()}>
|
||||
<button type="button" class={pillSecondaryClass} onClick={props.onWorkerCta} disabled={props.submitting}>
|
||||
{props.workerCtaLabel}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onWorkerRetry && props.workerRetryLabel?.trim()}>
|
||||
<button type="button" class={pillGhostClass} onClick={props.onWorkerRetry} disabled={props.submitting}>
|
||||
{props.workerRetryLabel}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.workerDebugLines.length > 0}>
|
||||
<details class={`mt-3 ${softCardClass} text-[11px] text-dls-text`}>
|
||||
<summary class="cursor-pointer text-[12px] font-semibold text-dls-text">Docker debug details</summary>
|
||||
<div class="mt-2 space-y-1 break-words font-mono">
|
||||
<For each={props.workerDebugLines}>{(line) => <div>{line}</div>}</For>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onClick={props.onClose} disabled={props.submitting} class={pillGhostClass}>
|
||||
{props.translate("common.cancel")}
|
||||
</button>
|
||||
<Show when={props.onConfirmWorker}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onConfirmWorker?.(props.preset, props.selectedFolder)}
|
||||
disabled={!props.selectedFolder || props.submitting || props.workerSubmitting || props.workerDisabled}
|
||||
title={!props.selectedFolder ? props.translate("dashboard.choose_folder_continue") : props.workerDisabledReason || undefined}
|
||||
class={pillSecondaryClass}
|
||||
>
|
||||
<Show when={props.workerSubmitting} fallback={props.workerLabel ?? props.translate("dashboard.create_sandbox_confirm")}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
{props.translate("dashboard.sandbox_checking_docker")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.onSubmit()}
|
||||
disabled={!props.selectedFolder || props.submitting}
|
||||
title={!props.selectedFolder ? props.translate("dashboard.choose_folder_continue") : undefined}
|
||||
class={pillPrimaryClass}
|
||||
>
|
||||
<Show when={props.submitting} fallback={props.confirmLabel ?? props.translate("dashboard.create_workspace_confirm")}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Creating…
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,653 +0,0 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
import { ArrowLeft, Cloud, FolderPlus, Globe, Loader2, X } from "lucide-solid";
|
||||
|
||||
import { currentLocale, t } from "../../i18n";
|
||||
import { usePlatform } from "../context/platform";
|
||||
import {
|
||||
buildDenAuthUrl,
|
||||
createDenClient,
|
||||
type DenOrgSummary,
|
||||
type DenTemplate,
|
||||
type DenWorkerSummary,
|
||||
readDenSettings,
|
||||
resolveDenBaseUrls,
|
||||
writeDenSettings,
|
||||
} from "../lib/den";
|
||||
import {
|
||||
loadDenTemplateCache,
|
||||
readDenTemplateCacheSnapshot,
|
||||
} from "../lib/den-template-cache";
|
||||
import type { WorkspacePreset } from "../types";
|
||||
import CreateWorkspaceLocalPanel from "./create-workspace-local-panel";
|
||||
import CreateWorkspaceSharedPanel from "./create-workspace-shared-panel";
|
||||
import {
|
||||
modalBodyClass,
|
||||
modalHeaderButtonClass,
|
||||
modalHeaderClass,
|
||||
modalOverlayClass,
|
||||
modalShellClass,
|
||||
modalSubtitleClass,
|
||||
modalTitleClass,
|
||||
pillGhostClass,
|
||||
pillPrimaryClass,
|
||||
tagClass,
|
||||
} from "./modal-styles";
|
||||
import WorkspaceOptionCard from "./option-card";
|
||||
import RemoteWorkspaceFields from "./remote-workspace-fields";
|
||||
import type {
|
||||
CreateWorkspaceModalProps,
|
||||
CreateWorkspaceScreen,
|
||||
RemoteWorkspaceInput,
|
||||
} from "./types";
|
||||
|
||||
function workerStatusMeta(status: string, translate: (key: string) => string) {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "healthy":
|
||||
return { label: translate("dashboard.worker_status_ready"), tone: "ready" as const, canOpen: true };
|
||||
case "provisioning":
|
||||
case "starting":
|
||||
return { label: translate("dashboard.worker_status_starting"), tone: "warning" as const, canOpen: false };
|
||||
case "failed":
|
||||
case "error":
|
||||
return { label: translate("dashboard.worker_status_attention"), tone: "error" as const, canOpen: false };
|
||||
case "stopped":
|
||||
return { label: translate("dashboard.worker_status_stopped"), tone: "neutral" as const, canOpen: false };
|
||||
default:
|
||||
return {
|
||||
label: normalized
|
||||
? `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`
|
||||
: translate("common.unknown"),
|
||||
tone: "neutral" as const,
|
||||
canOpen: normalized === "ready",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatTemplateTimestamp(value: string | null, translate: (key: string) => string) {
|
||||
if (!value) return translate("dashboard.recently_updated");
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return translate("dashboard.recently_updated");
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function templateCreatorLabel(template: DenTemplate, translate: (key: string) => string) {
|
||||
const creator = template.creator;
|
||||
if (!creator) return translate("dashboard.unknown_creator");
|
||||
return creator.name?.trim() || creator.email?.trim() || translate("dashboard.unknown_creator");
|
||||
}
|
||||
|
||||
function workerSecondaryLine(worker: DenWorkerSummary, translate: (key: string) => string) {
|
||||
const parts = [worker.provider?.trim() || translate("dashboard.cloud_worker")];
|
||||
if (worker.instanceUrl?.trim()) parts.push(worker.instanceUrl.trim());
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export default function CreateWorkspaceModal(props: CreateWorkspaceModalProps) {
|
||||
let remoteUrlRef: HTMLInputElement | undefined;
|
||||
const translate = (key: string, params?: Record<string, string | number>) => t(key, currentLocale(), params);
|
||||
const platform = usePlatform();
|
||||
|
||||
const [screen, setScreen] = createSignal<CreateWorkspaceScreen>("chooser");
|
||||
const [preset] = 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());
|
||||
const [cloudSettings, setCloudSettings] = createSignal(readDenSettings());
|
||||
const [selectedTemplateId, setSelectedTemplateId] = createSignal<string | null>(null);
|
||||
const [templateError, setTemplateError] = createSignal<string | null>(null);
|
||||
const [remoteUrl, setRemoteUrl] = createSignal("");
|
||||
const [remoteToken, setRemoteToken] = createSignal("");
|
||||
const [remoteDisplayName, setRemoteDisplayName] = createSignal("");
|
||||
const [remoteTokenVisible, setRemoteTokenVisible] = createSignal(false);
|
||||
const [orgs, setOrgs] = createSignal<DenOrgSummary[]>([]);
|
||||
const [activeOrgId, setActiveOrgId] = createSignal("");
|
||||
const [orgsBusy, setOrgsBusy] = createSignal(false);
|
||||
const [orgsError, setOrgsError] = createSignal<string | null>(null);
|
||||
const [workers, setWorkers] = createSignal<DenWorkerSummary[]>([]);
|
||||
const [workersBusy, setWorkersBusy] = createSignal(false);
|
||||
const [workersError, setWorkersError] = createSignal<string | null>(null);
|
||||
const [openingWorkerId, setOpeningWorkerId] = createSignal<string | null>(null);
|
||||
const [workerSearch, setWorkerSearch] = createSignal("");
|
||||
|
||||
const showClose = () => props.showClose ?? true;
|
||||
const isInline = () => props.inline ?? false;
|
||||
const submitting = () => props.submitting ?? false;
|
||||
const remoteSubmitting = () => props.remoteSubmitting ?? 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 workerDebugLines = createMemo(() =>
|
||||
(props.workerDebugLines ?? []).map((line) => line.trim()).filter(Boolean),
|
||||
);
|
||||
const hasSelectedFolder = createMemo(() => Boolean(selectedFolder()?.trim()));
|
||||
const remoteError = createMemo(() => (props.remoteError ?? "").trim() || null);
|
||||
const isSignedIn = createMemo(() => Boolean(cloudSettings().authToken?.trim()));
|
||||
const denClient = createMemo(
|
||||
() => createDenClient({
|
||||
baseUrl: cloudSettings().baseUrl,
|
||||
apiBaseUrl: cloudSettings().apiBaseUrl,
|
||||
token: cloudSettings().authToken ?? "",
|
||||
}),
|
||||
);
|
||||
const templateCacheSnapshot = createMemo(() =>
|
||||
readDenTemplateCacheSnapshot({
|
||||
baseUrl: cloudSettings().baseUrl,
|
||||
token: cloudSettings().authToken,
|
||||
orgSlug: cloudSettings().activeOrgSlug,
|
||||
}),
|
||||
);
|
||||
const cloudWorkspaceTemplates = createMemo(() =>
|
||||
templateCacheSnapshot().templates.filter((template) => {
|
||||
const payload = template.templateData;
|
||||
return Boolean(
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
(payload as { type?: unknown }).type === "workspace-profile",
|
||||
);
|
||||
}),
|
||||
);
|
||||
const showTemplateSection = createMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
props.onConfirmTemplate &&
|
||||
cloudSettings().authToken?.trim() &&
|
||||
cloudSettings().activeOrgSlug?.trim(),
|
||||
),
|
||||
);
|
||||
const selectedTemplate = createMemo(
|
||||
() => cloudWorkspaceTemplates().find((template) => template.id === selectedTemplateId()) ?? null,
|
||||
);
|
||||
const elapsedSeconds = createMemo(() => {
|
||||
const current = progress();
|
||||
if (!current?.startedAt) return 0;
|
||||
return Math.max(0, Math.floor((now() - current.startedAt) / 1000));
|
||||
});
|
||||
const filteredWorkers = createMemo(() => {
|
||||
const query = workerSearch().trim().toLowerCase();
|
||||
if (!query) return workers();
|
||||
return workers().filter((worker) => {
|
||||
const haystack = [worker.workerName, worker.provider, worker.instanceUrl, worker.status]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const modalWidthClass = createMemo(() =>
|
||||
screen() === "shared" ? "max-w-[640px]" : "max-w-[560px]",
|
||||
);
|
||||
|
||||
const headerTitle = createMemo(() => {
|
||||
switch (screen()) {
|
||||
case "local":
|
||||
return translate("dashboard.create_local_workspace_title");
|
||||
case "remote":
|
||||
return translate("dashboard.create_remote_custom_title");
|
||||
case "shared":
|
||||
return translate("dashboard.create_shared_title");
|
||||
default:
|
||||
return props.title ?? translate("dashboard.create_workspace_title");
|
||||
}
|
||||
});
|
||||
|
||||
const headerSubtitle = createMemo(() => {
|
||||
switch (screen()) {
|
||||
case "local":
|
||||
return translate("dashboard.create_local_workspace_subtitle");
|
||||
case "remote":
|
||||
return translate("dashboard.create_remote_custom_subtitle");
|
||||
case "shared":
|
||||
return isSignedIn()
|
||||
? translate("dashboard.create_shared_subtitle_signed_in")
|
||||
: translate("dashboard.create_shared_subtitle_signed_out");
|
||||
default:
|
||||
return props.subtitle ?? translate("dashboard.create_workspace_subtitle");
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
const settings = readDenSettings();
|
||||
setScreen("chooser");
|
||||
setCloudSettings(settings);
|
||||
setSelectedTemplateId(null);
|
||||
setTemplateError(null);
|
||||
setRemoteUrl("");
|
||||
setRemoteToken("");
|
||||
setRemoteDisplayName("");
|
||||
setRemoteTokenVisible(false);
|
||||
setWorkerSearch("");
|
||||
setOrgs([]);
|
||||
setWorkers([]);
|
||||
setOrgsError(null);
|
||||
setWorkersError(null);
|
||||
setActiveOrgId(settings.activeOrgId?.trim() ?? "");
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open && !isInline()) return;
|
||||
const handler = () => {
|
||||
const settings = readDenSettings();
|
||||
setCloudSettings(settings);
|
||||
setActiveOrgId(settings.activeOrgId?.trim() ?? "");
|
||||
};
|
||||
window.addEventListener("openwork-den-session-updated", handler as EventListener);
|
||||
onCleanup(() =>
|
||||
window.removeEventListener("openwork-den-session-updated", handler as EventListener),
|
||||
);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!showTemplateSection() || (!props.open && !isInline())) return;
|
||||
void loadDenTemplateCache(
|
||||
{
|
||||
baseUrl: cloudSettings().baseUrl,
|
||||
token: cloudSettings().authToken,
|
||||
orgSlug: cloudSettings().activeOrgSlug,
|
||||
},
|
||||
{ force: true },
|
||||
).catch(() => undefined);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!submitting()) {
|
||||
setShowProgressDetails(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(() => setNow(Date.now()), 500);
|
||||
onCleanup(() => window.clearInterval(id));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
if (screen() === "remote") {
|
||||
requestAnimationFrame(() => remoteUrlRef?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open || screen() !== "shared" || !isSignedIn()) return;
|
||||
void refreshOrgs();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open || screen() !== "shared" || !isSignedIn()) return;
|
||||
const orgId = activeOrgId().trim();
|
||||
if (!orgId) return;
|
||||
void refreshWorkers(orgId);
|
||||
});
|
||||
|
||||
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 applyActiveOrg = (nextOrg: DenOrgSummary | null) => {
|
||||
setActiveOrgId(nextOrg?.id ?? "");
|
||||
const nextSettings = {
|
||||
...cloudSettings(),
|
||||
activeOrgId: nextOrg?.id ?? null,
|
||||
activeOrgSlug: nextOrg?.slug ?? null,
|
||||
activeOrgName: nextOrg?.name ?? null,
|
||||
};
|
||||
writeDenSettings(nextSettings);
|
||||
setCloudSettings(nextSettings);
|
||||
};
|
||||
|
||||
const switchActiveOrg = async (orgId: string) => {
|
||||
const nextOrg = orgs().find((org) => org.id === orgId) ?? null;
|
||||
if (!nextOrg || orgId === activeOrgId().trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOrgsBusy(true);
|
||||
setOrgsError(null);
|
||||
try {
|
||||
await denClient().setActiveOrganization({ organizationId: orgId });
|
||||
applyActiveOrg(nextOrg);
|
||||
} catch (error) {
|
||||
setOrgsError(
|
||||
error instanceof Error ? error.message : translate("dashboard.error_load_orgs"),
|
||||
);
|
||||
} finally {
|
||||
setOrgsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOrgs = async () => {
|
||||
if (!isSignedIn()) return;
|
||||
setOrgsBusy(true);
|
||||
setOrgsError(null);
|
||||
try {
|
||||
const { orgs: nextOrgs, defaultOrgId } = await denClient().listOrgs();
|
||||
setOrgs(nextOrgs);
|
||||
const preferred = cloudSettings().activeOrgId?.trim();
|
||||
const nextActive =
|
||||
nextOrgs.find((org) => org.id === preferred) ??
|
||||
nextOrgs.find((org) => org.id === defaultOrgId) ??
|
||||
nextOrgs[0] ??
|
||||
null;
|
||||
applyActiveOrg(nextActive);
|
||||
} catch (error) {
|
||||
setOrgsError(
|
||||
error instanceof Error ? error.message : translate("dashboard.error_load_orgs"),
|
||||
);
|
||||
} finally {
|
||||
setOrgsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshWorkers = async (orgId = activeOrgId().trim()) => {
|
||||
if (!orgId || !isSignedIn()) return;
|
||||
setWorkersBusy(true);
|
||||
setWorkersError(null);
|
||||
try {
|
||||
const nextWorkers = await denClient().listWorkers(orgId);
|
||||
setWorkers(nextWorkers);
|
||||
} catch (error) {
|
||||
setWorkersError(
|
||||
error instanceof Error ? error.message : translate("dashboard.error_load_shared_workspaces"),
|
||||
);
|
||||
} finally {
|
||||
setWorkersBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCloudSignIn = () => {
|
||||
platform.openLink(buildDenAuthUrl(cloudSettings().baseUrl, "sign-in"));
|
||||
};
|
||||
|
||||
const openCloudDashboard = () => {
|
||||
platform.openLink(resolveDenBaseUrls(cloudSettings().baseUrl).baseUrl);
|
||||
};
|
||||
|
||||
const handleRemoteSubmit = async () => {
|
||||
if (!props.onConfirmRemote) return;
|
||||
await Promise.resolve(
|
||||
props.onConfirmRemote({
|
||||
openworkHostUrl: remoteUrl().trim(),
|
||||
openworkToken: remoteToken().trim() || null,
|
||||
directory: null,
|
||||
displayName: remoteDisplayName().trim() || null,
|
||||
closeModal: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenWorker = async (worker: DenWorkerSummary) => {
|
||||
if (!props.onConfirmRemote) return;
|
||||
const orgId = activeOrgId().trim();
|
||||
if (!orgId) {
|
||||
setWorkersError(translate("dashboard.error_choose_org"));
|
||||
return;
|
||||
}
|
||||
setOpeningWorkerId(worker.workerId);
|
||||
setWorkersError(null);
|
||||
try {
|
||||
const tokens = await denClient().getWorkerTokens(worker.workerId, orgId);
|
||||
const openworkUrl = tokens.openworkUrl?.trim() ?? "";
|
||||
const accessToken =
|
||||
tokens.ownerToken?.trim() || tokens.clientToken?.trim() || "";
|
||||
if (!openworkUrl || !accessToken) {
|
||||
throw new Error(translate("dashboard.error_workspace_not_ready"));
|
||||
}
|
||||
const ok = await Promise.resolve(
|
||||
props.onConfirmRemote({
|
||||
openworkHostUrl: openworkUrl,
|
||||
openworkToken: accessToken,
|
||||
openworkClientToken: tokens.clientToken?.trim() || null,
|
||||
openworkHostToken: tokens.hostToken?.trim() || null,
|
||||
directory: null,
|
||||
displayName: worker.workerName,
|
||||
closeModal: true,
|
||||
}),
|
||||
);
|
||||
if (ok === false) {
|
||||
throw new Error(translate("dashboard.error_connect_worker", { name: worker.workerName }));
|
||||
}
|
||||
} catch (error) {
|
||||
setWorkersError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate("dashboard.error_connect_worker", { name: worker.workerName }),
|
||||
);
|
||||
} finally {
|
||||
setOpeningWorkerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalSubmit = async () => {
|
||||
const template = selectedTemplate();
|
||||
if (template && props.onConfirmTemplate) {
|
||||
try {
|
||||
setTemplateError(null);
|
||||
await props.onConfirmTemplate(template, preset(), selectedFolder());
|
||||
} catch (error) {
|
||||
setTemplateError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate("dashboard.error_create_template", { name: template.name }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
props.onConfirm(preset(), selectedFolder());
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div class={`${modalShellClass} ${modalWidthClass()}`}>
|
||||
<div class={modalHeaderClass}>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<Show when={screen() !== "chooser"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScreen("chooser")}
|
||||
disabled={submitting() || remoteSubmitting()}
|
||||
class={modalHeaderButtonClass}
|
||||
aria-label={translate("dashboard.modal_back")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<h3 class={modalTitleClass}>{headerTitle()}</h3>
|
||||
<p class={modalSubtitleClass}>{headerSubtitle()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={showClose()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={submitting() || remoteSubmitting()}
|
||||
class={modalHeaderButtonClass}
|
||||
aria-label={translate("dashboard.modal_close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={screen() === "chooser"}>
|
||||
<div class={modalBodyClass}>
|
||||
<div class="space-y-3">
|
||||
<WorkspaceOptionCard
|
||||
title={translate("dashboard.create_local_workspace_title")}
|
||||
description={
|
||||
props.localDisabled
|
||||
? props.localDisabledReason?.trim() || translate("dashboard.chooser_local_desc")
|
||||
: translate("dashboard.chooser_local_desc")
|
||||
}
|
||||
icon={FolderPlus}
|
||||
onClick={() => setScreen("local")}
|
||||
disabled={props.localDisabled}
|
||||
endAdornment={props.localDisabled ? <span class={tagClass}>{translate("dashboard.desktop_badge")}</span> : undefined}
|
||||
/>
|
||||
<WorkspaceOptionCard
|
||||
title={translate("dashboard.create_remote_custom_title")}
|
||||
description={translate("dashboard.chooser_remote_desc")}
|
||||
icon={Globe}
|
||||
onClick={() => setScreen("remote")}
|
||||
/>
|
||||
<WorkspaceOptionCard
|
||||
title={translate("dashboard.create_shared_title")}
|
||||
description={translate("dashboard.chooser_shared_desc")}
|
||||
icon={Cloud}
|
||||
onClick={() => setScreen("shared")}
|
||||
/>
|
||||
|
||||
<Show when={props.onImportConfig}>
|
||||
<div class="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onImportConfig?.()}
|
||||
disabled={props.importingConfig}
|
||||
class={pillGhostClass}
|
||||
>
|
||||
<Show when={props.importingConfig} fallback={translate("dashboard.import_config")}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{translate("dashboard.importing")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={screen() === "local"}>
|
||||
<CreateWorkspaceLocalPanel
|
||||
translate={translate}
|
||||
selectedFolder={selectedFolder()}
|
||||
hasSelectedFolder={hasSelectedFolder()}
|
||||
pickingFolder={pickingFolder()}
|
||||
onPickFolder={() => void handlePickFolder()}
|
||||
submitting={submitting()}
|
||||
selectedTemplateId={selectedTemplateId()}
|
||||
setSelectedTemplateId={setSelectedTemplateId}
|
||||
showTemplateSection={showTemplateSection()}
|
||||
cloudWorkspaceTemplates={cloudWorkspaceTemplates()}
|
||||
templateCreatorLabel={(template) => templateCreatorLabel(template, translate)}
|
||||
formatTemplateTimestamp={(value) => formatTemplateTimestamp(value, translate)}
|
||||
templateError={templateError()}
|
||||
templateCacheBusy={templateCacheSnapshot().busy}
|
||||
templateCacheError={templateCacheSnapshot().error}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => void handleLocalSubmit()}
|
||||
confirmLabel={props.confirmLabel}
|
||||
workerLabel={props.workerLabel}
|
||||
onConfirmWorker={props.onConfirmWorker}
|
||||
preset={preset()}
|
||||
workerSubmitting={workerSubmitting()}
|
||||
workerDisabled={workerDisabled()}
|
||||
workerDisabledReason={workerDisabledReason()}
|
||||
workerCtaLabel={props.workerCtaLabel}
|
||||
workerCtaDescription={props.workerCtaDescription}
|
||||
onWorkerCta={props.onWorkerCta}
|
||||
workerRetryLabel={props.workerRetryLabel}
|
||||
onWorkerRetry={props.onWorkerRetry}
|
||||
workerDebugLines={workerDebugLines()}
|
||||
progress={progress()}
|
||||
elapsedSeconds={elapsedSeconds()}
|
||||
showProgressDetails={showProgressDetails()}
|
||||
onToggleProgressDetails={() => setShowProgressDetails((prev) => !prev)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={screen() === "remote"}>
|
||||
<>
|
||||
<div class={modalBodyClass}>
|
||||
<RemoteWorkspaceFields
|
||||
hostUrl={remoteUrl()}
|
||||
onHostUrlInput={setRemoteUrl}
|
||||
token={remoteToken()}
|
||||
tokenVisible={remoteTokenVisible()}
|
||||
onTokenInput={setRemoteToken}
|
||||
onToggleTokenVisible={() => setRemoteTokenVisible((prev) => !prev)}
|
||||
displayName={remoteDisplayName()}
|
||||
onDisplayNameInput={setRemoteDisplayName}
|
||||
submitting={remoteSubmitting()}
|
||||
hostInputRef={remoteUrlRef}
|
||||
title={translate("dashboard.remote_server_details_title")}
|
||||
description={translate("dashboard.remote_server_details_hint")}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-3 border-t border-dls-border px-6 py-5">
|
||||
<Show when={remoteError()}>{(value) => <div class="rounded-[20px] border border-red-7/20 bg-red-1/40 px-4 py-3 text-[13px] text-red-11">{value()}</div>}</Show>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class={pillGhostClass} onClick={props.onClose} disabled={remoteSubmitting()}>
|
||||
{translate("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={pillPrimaryClass}
|
||||
disabled={!remoteUrl().trim() || remoteSubmitting()}
|
||||
onClick={() => void handleRemoteSubmit()}
|
||||
>
|
||||
<Show when={remoteSubmitting()} fallback={translate("dashboard.connect_remote_button")}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
{translate("dashboard.connecting")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
|
||||
<Show when={screen() === "shared"}>
|
||||
<CreateWorkspaceSharedPanel
|
||||
signedIn={isSignedIn()}
|
||||
orgs={orgs()}
|
||||
activeOrgId={activeOrgId()}
|
||||
onActiveOrgChange={(orgId) => {
|
||||
void switchActiveOrg(orgId);
|
||||
}}
|
||||
orgsBusy={orgsBusy()}
|
||||
orgsError={orgsError()}
|
||||
workers={workers()}
|
||||
workersBusy={workersBusy()}
|
||||
workersError={workersError()}
|
||||
workerSearch={workerSearch()}
|
||||
onWorkerSearchInput={setWorkerSearch}
|
||||
filteredWorkers={filteredWorkers()}
|
||||
openingWorkerId={openingWorkerId()}
|
||||
workerStatusMeta={(status) => workerStatusMeta(status, translate)}
|
||||
workerSecondaryLine={(worker) => workerSecondaryLine(worker, translate)}
|
||||
onOpenWorker={(worker) => void handleOpenWorker(worker)}
|
||||
onOpenCloudSignIn={openCloudSignIn}
|
||||
onRefreshWorkers={() => void refreshWorkers()}
|
||||
onOpenCloudDashboard={openCloudDashboard}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={props.open || isInline()}>
|
||||
<div class={isInline() ? "w-full" : modalOverlayClass}>{content}</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export type { RemoteWorkspaceInput };
|
||||
@@ -1,200 +0,0 @@
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
|
||||
import { Boxes, Cloud, Loader2, RefreshCcw, Search } from "lucide-solid";
|
||||
|
||||
import type { DenOrgSummary, DenWorkerSummary } from "../lib/den";
|
||||
import {
|
||||
errorBannerClass,
|
||||
iconTileClass,
|
||||
inputClass,
|
||||
modalBodyClass,
|
||||
pillGhostClass,
|
||||
pillPrimaryClass,
|
||||
pillSecondaryClass,
|
||||
sectionBodyClass,
|
||||
sectionTitleClass,
|
||||
surfaceCardClass,
|
||||
tagClass,
|
||||
} from "./modal-styles";
|
||||
|
||||
type WorkerStatusMeta = {
|
||||
label: string;
|
||||
tone: "ready" | "warning" | "neutral" | "error";
|
||||
canOpen: boolean;
|
||||
};
|
||||
|
||||
const statusBadgeClass = (kind: WorkerStatusMeta["tone"]) => {
|
||||
switch (kind) {
|
||||
case "ready":
|
||||
return "border-emerald-7/30 bg-emerald-3/40 text-emerald-11";
|
||||
case "warning":
|
||||
return "border-amber-7/30 bg-amber-3/40 text-amber-11";
|
||||
case "error":
|
||||
return "border-red-7/30 bg-red-3/40 text-red-11";
|
||||
default:
|
||||
return "border-dls-border bg-dls-hover text-dls-secondary";
|
||||
}
|
||||
};
|
||||
|
||||
export default function CreateWorkspaceSharedPanel(props: {
|
||||
signedIn: boolean;
|
||||
orgs: DenOrgSummary[];
|
||||
activeOrgId: string;
|
||||
onActiveOrgChange: (orgId: string) => void;
|
||||
orgsBusy: boolean;
|
||||
orgsError: string | null;
|
||||
workers: DenWorkerSummary[];
|
||||
workersBusy: boolean;
|
||||
workersError: string | null;
|
||||
workerSearch: string;
|
||||
onWorkerSearchInput: (value: string) => void;
|
||||
filteredWorkers: DenWorkerSummary[];
|
||||
openingWorkerId: string | null;
|
||||
workerStatusMeta: (status: string) => WorkerStatusMeta;
|
||||
workerSecondaryLine: (worker: DenWorkerSummary) => string;
|
||||
onOpenWorker: (worker: DenWorkerSummary) => void;
|
||||
onOpenCloudSignIn: () => void;
|
||||
onRefreshWorkers: () => void;
|
||||
onOpenCloudDashboard: () => void;
|
||||
}) {
|
||||
const activeOrg = createMemo(
|
||||
() => props.orgs.find((org) => org.id === props.activeOrgId) ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={modalBodyClass}>
|
||||
<Show
|
||||
when={props.signedIn}
|
||||
fallback={
|
||||
<div class="flex min-h-[320px] items-center justify-center">
|
||||
<div class={`${surfaceCardClass} w-full max-w-[420px] p-8 text-center`}>
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-hover text-dls-text">
|
||||
<Cloud size={24} />
|
||||
</div>
|
||||
<div class="mt-5 text-[20px] font-semibold tracking-[-0.3px] text-dls-text">Sign in to OpenWork Cloud</div>
|
||||
<div class="mt-2 text-[14px] leading-6 text-dls-secondary">Access remote workers shared with your organization.</div>
|
||||
<div class="mt-6 flex justify-center">
|
||||
<button type="button" class={pillPrimaryClass} onClick={props.onOpenCloudSignIn}>
|
||||
Continue with Cloud
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 text-[12px] text-dls-secondary">You’ll pick a team and connect to an existing workspace next.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class={surfaceCardClass}>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class={sectionTitleClass}>Shared workspaces</div>
|
||||
<div class={sectionBodyClass}>Choose your organization, then connect to a cloud worker in one step.</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<select
|
||||
value={props.activeOrgId}
|
||||
onChange={(event) => props.onActiveOrgChange(event.currentTarget.value)}
|
||||
disabled={props.orgsBusy || props.orgs.length === 0}
|
||||
class={`${inputClass} h-11 min-w-[180px] py-2 font-medium`}
|
||||
>
|
||||
<For each={props.orgs}>
|
||||
{(org) => <option value={org.id}>{org.name}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class={pillSecondaryClass}
|
||||
onClick={props.onRefreshWorkers}
|
||||
disabled={props.workersBusy || !props.activeOrgId.trim()}
|
||||
title={activeOrg()?.name ?? undefined}
|
||||
>
|
||||
<RefreshCcw size={13} class={props.workersBusy ? "animate-spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="flex items-center gap-3 rounded-xl border border-dls-border bg-dls-surface px-4 py-3">
|
||||
<Search size={15} class="shrink-0 text-dls-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={props.workerSearch}
|
||||
onInput={(event) => props.onWorkerSearchInput(event.currentTarget.value)}
|
||||
placeholder="Search shared workspaces"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-[14px] text-dls-text outline-none placeholder:text-dls-secondary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.orgsError}>{(value) => <div class={errorBannerClass}>{value()}</div>}</Show>
|
||||
<Show when={props.workersError}>{(value) => <div class={errorBannerClass}>{value()}</div>}</Show>
|
||||
|
||||
<Show when={props.workersBusy && props.workers.length === 0}>
|
||||
<div class={`${surfaceCardClass} text-[14px] text-dls-secondary`}>Loading shared workspaces…</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.workersBusy && props.filteredWorkers.length === 0}>
|
||||
<div class={`${surfaceCardClass} text-[14px] text-dls-secondary`}>
|
||||
{props.workerSearch.trim()
|
||||
? "No shared workspaces match that search."
|
||||
: "No shared workspaces available yet."}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={props.filteredWorkers}>
|
||||
{(worker) => {
|
||||
const status = createMemo(() => props.workerStatusMeta(worker.status));
|
||||
return (
|
||||
<div class={`${surfaceCardClass} transition-all duration-150 hover:border-dls-border hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)]`}>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class={iconTileClass}>
|
||||
<Boxes size={18} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="truncate text-[14px] font-medium text-dls-text">{worker.workerName}</div>
|
||||
<span class={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] ${statusBadgeClass(status().tone)}`.trim()}>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current opacity-80" />
|
||||
{status().label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[12px] text-dls-secondary">{props.workerSecondaryLine(worker)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={pillSecondaryClass}
|
||||
disabled={props.openingWorkerId !== null || !status().canOpen}
|
||||
title={!status().canOpen ? "This workspace is not ready to connect yet." : undefined}
|
||||
onClick={() => props.onOpenWorker(worker)}
|
||||
>
|
||||
<Show when={props.openingWorkerId === worker.workerId} fallback="Connect">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 size={13} class="animate-spin" />
|
||||
Connecting
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.workersBusy && props.workers.length > 0}>
|
||||
<div class="text-[12px] text-dls-secondary">Refreshing workspaces…</div>
|
||||
</Show>
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="button" class={pillGhostClass} onClick={props.onOpenCloudDashboard}>
|
||||
Open cloud dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export { default as CreateWorkspaceModal } from "./create-workspace-modal";
|
||||
export { default as CreateRemoteWorkspaceModal } from "./create-remote-workspace-modal";
|
||||
export { default as ShareWorkspaceModal } from "./share-workspace-modal";
|
||||
|
||||
export type {
|
||||
CreateRemoteWorkspaceModalProps,
|
||||
CreateWorkspaceModalProps,
|
||||
RemoteWorkspaceInput,
|
||||
ShareField,
|
||||
ShareWorkspaceModalProps,
|
||||
} from "./types";
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import { ChevronRight } from "lucide-solid";
|
||||
|
||||
import {
|
||||
iconTileClass,
|
||||
interactiveCardClass,
|
||||
sectionBodyClass,
|
||||
sectionTitleClass,
|
||||
} from "./modal-styles";
|
||||
|
||||
export default function WorkspaceOptionCard(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: (props: { size?: number; class?: string }) => JSX.Element;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
endAdornment?: JSX.Element;
|
||||
}) {
|
||||
const Icon = props.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onClick?.()}
|
||||
disabled={props.disabled}
|
||||
class={`${interactiveCardClass} group flex w-full items-center gap-4 disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<div class={iconTileClass}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class={sectionTitleClass}>{props.title}</div>
|
||||
<div class={sectionBodyClass}>{props.description}</div>
|
||||
</div>
|
||||
{props.endAdornment ?? (
|
||||
<ChevronRight size={18} class="shrink-0 text-dls-secondary transition-transform group-hover:translate-x-0.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
|
||||
import { Globe } from "lucide-solid";
|
||||
|
||||
import {
|
||||
iconTileClass,
|
||||
inputClass,
|
||||
inputHintClass,
|
||||
inputLabelClass,
|
||||
pillSecondaryClass,
|
||||
surfaceCardClass,
|
||||
} from "./modal-styles";
|
||||
|
||||
export default function RemoteWorkspaceFields(props: {
|
||||
hostUrl: string;
|
||||
onHostUrlInput: (value: string) => void;
|
||||
token: string;
|
||||
tokenVisible: boolean;
|
||||
onTokenInput: (value: string) => void;
|
||||
onToggleTokenVisible: () => void;
|
||||
displayName: string;
|
||||
onDisplayNameInput: (value: string) => void;
|
||||
directory?: string;
|
||||
onDirectoryInput?: (value: string) => void;
|
||||
showDirectory?: boolean;
|
||||
submitting?: boolean;
|
||||
hostInputRef?: HTMLInputElement | undefined;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div class={surfaceCardClass}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class={iconTileClass}>
|
||||
<Globe size={17} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[15px] font-medium tracking-[-0.2px] text-dls-text">{props.title}</div>
|
||||
<div class="mt-1 text-[13px] leading-relaxed text-dls-secondary">{props.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4">
|
||||
<label class="grid gap-2">
|
||||
<span class={inputLabelClass}>Worker URL</span>
|
||||
<input
|
||||
ref={props.hostInputRef}
|
||||
type="url"
|
||||
value={props.hostUrl}
|
||||
onInput={(event) => props.onHostUrlInput(event.currentTarget.value)}
|
||||
placeholder="https://worker.example.com"
|
||||
disabled={props.submitting}
|
||||
class={inputClass}
|
||||
/>
|
||||
<span class={inputHintClass}>Paste the OpenWork worker URL you want to connect to.</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2">
|
||||
<span class={inputLabelClass}>Access token</span>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-dls-border bg-dls-surface p-1.5">
|
||||
<input
|
||||
type={props.tokenVisible ? "text" : "password"}
|
||||
value={props.token}
|
||||
onInput={(event) => props.onTokenInput(event.currentTarget.value)}
|
||||
placeholder="Optional"
|
||||
disabled={props.submitting}
|
||||
class="min-w-0 flex-1 border-none bg-transparent px-2 py-1.5 text-[14px] text-dls-text outline-none placeholder:text-dls-secondary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class={pillSecondaryClass}
|
||||
onClick={props.onToggleTokenVisible}
|
||||
disabled={props.submitting}
|
||||
>
|
||||
{props.tokenVisible ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<span class={inputHintClass}>Add a token only if the worker requires one.</span>
|
||||
</label>
|
||||
|
||||
<Show when={props.showDirectory}>
|
||||
<label class="grid gap-2">
|
||||
<span class={inputLabelClass}>Remote directory</span>
|
||||
<input
|
||||
type="text"
|
||||
value={props.directory ?? ""}
|
||||
onInput={(event) => props.onDirectoryInput?.(event.currentTarget.value)}
|
||||
placeholder="Optional"
|
||||
disabled={props.submitting}
|
||||
class={inputClass}
|
||||
/>
|
||||
<span class={inputHintClass}>Optionally target a directory within that remote worker.</span>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<label class="grid gap-2">
|
||||
<span class={inputLabelClass}>Display name <span class="font-normal text-dls-secondary">(optional)</span></span>
|
||||
<input
|
||||
type="text"
|
||||
value={props.displayName}
|
||||
onInput={(event) => props.onDisplayNameInput(event.currentTarget.value)}
|
||||
placeholder="Worker name"
|
||||
disabled={props.submitting}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user