From 2e440d47211f93fb06243dd67b6ceb89d4900c74 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 22:25:28 -0700 Subject: [PATCH] Task/react port cutover react only workspace fixes (#1470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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//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//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 ) 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 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 ) -> 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 , 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 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, is a pure pass-through (no mounted, no overhead) and 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 ? : . 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:' for message blocks, 'cluster:' 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>. 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 setAuthorizedFolderDraft(event.currentTarget.value)} - onPaste={(event) => { - event.preventDefault(); - }} - placeholder={t("context_panel.input_placeholder")} - disabled={ - authorizedFoldersLoading() || - authorizedFoldersSaving() || - !canWriteConfig() - } - /> - - - - - - - - - - - - ); -} diff --git a/apps/app/src/app/app-settings/feature-flags-preferences.ts b/apps/app/src/app/app-settings/feature-flags-preferences.ts deleted file mode 100644 index 7e5ddda8..00000000 --- a/apps/app/src/app/app-settings/feature-flags-preferences.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/app/src/app/app-settings/model-controls-provider.tsx b/apps/app/src/app/app-settings/model-controls-provider.tsx deleted file mode 100644 index b55c76ac..00000000 --- a/apps/app/src/app/app-settings/model-controls-provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { ModelControlsStore } from "./model-controls-store"; - -const ModelControlsContext = createContext(); - -export function ModelControlsProvider(props: ParentProps<{ store: ModelControlsStore }>) { - return ( - - {props.children} - - ); -} - -export function useModelControls() { - const context = useContext(ModelControlsContext); - if (!context) { - throw new Error("useModelControls must be used within a ModelControlsProvider"); - } - return context; -} diff --git a/apps/app/src/app/app-settings/model-controls-store.ts b/apps/app/src/app/app-settings/model-controls-store.ts deleted file mode 100644 index 9d76247d..00000000 --- a/apps/app/src/app/app-settings/model-controls-store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Accessor } from "solid-js"; - -export type ModelBehaviorOption = { value: string | null; label: string }; - -export type ModelControlsStore = ReturnType; - -export function createModelControlsStore(options: { - selectedSessionModelLabel: Accessor; - openSessionModelPicker: (options?: { returnFocusTarget?: "none" | "composer" }) => void; - sessionModelVariantLabel: Accessor; - sessionModelVariant: Accessor; - sessionModelBehaviorOptions: Accessor; - setSessionModelVariant: (value: string | null) => void; - defaultModelLabel: Accessor; - defaultModelRef: Accessor; - openDefaultModelPicker: () => void; - autoCompactContext: Accessor; - toggleAutoCompactContext: () => void; - autoCompactContextBusy: Accessor; - defaultModelVariantLabel: Accessor; - editDefaultModelVariant: () => void; -}) { - return options; -} diff --git a/apps/app/src/app/app-settings/session-display-preferences.ts b/apps/app/src/app/app-settings/session-display-preferences.ts deleted file mode 100644 index ad17360a..00000000 --- a/apps/app/src/app/app-settings/session-display-preferences.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx deleted file mode 100644 index 715836b4..00000000 --- a/apps/app/src/app/app.tsx +++ /dev/null @@ -1,2931 +0,0 @@ -import { - Match, - Show, - Switch, - createEffect, - createMemo, - createSignal, - onCleanup, - onMount, - untrack, -} from "solid-js"; - -import { useLocation, useNavigate } from "@solidjs/router"; - -import { getVersion } from "@tauri-apps/api/app"; -import { getCurrentWebview } from "@tauri-apps/api/webview"; -import ModelPickerModal from "./components/model-picker-modal"; -import ConfirmModal from "./components/confirm-modal"; -import ResetModal from "./components/reset-modal"; -import SkillDestinationModal from "./bundles/skill-destination-modal"; -import BundleImportModal from "./bundles/import-modal"; -import BundleStartModal from "./bundles/start-modal"; -import { useDenAuth } from "./cloud/den-auth-provider"; -import { useDesktopConfig } from "./cloud/desktop-config-provider"; -import { - isDesktopProviderBlocked, - runDesktopAppRestrictionSyncEffects, -} from "./cloud/desktop-app-restrictions"; -import RestrictionNoticeModal from "./components/restriction-notice-modal"; -import ForcedSigninPage from "./cloud/forced-signin-page"; -import RenameWorkspaceModal from "./components/rename-workspace-modal"; -import ConnectionsModals from "./connections/modals"; -import { OpenworkServerProvider } from "./connections/openwork-server-provider"; -import { createOpenworkServerStore } from "./connections/openwork-server-store"; -import { ConnectionsProvider } from "./connections/provider"; -import { ExtensionsProvider } from "./extensions/provider"; -import { AutomationsProvider } from "./automations/provider"; -import { SessionActionsProvider } from "./session/actions-provider"; -import { createSessionActionsStore } from "./session/actions-store"; -import { createDeepLinksController } from "./shell/deep-links"; -import SettingsShell from "./shell/settings-shell"; -import TopRightNotifications from "./shell/top-right-notifications"; -import { createStatusToastsStore, StatusToastsProvider } from "./shell/status-toasts"; -import { - CreateRemoteWorkspaceModal, - CreateWorkspaceModal, -} from "./workspace"; -import SessionView from "./pages/session"; -import { clearDevLogs } from "./lib/dev-log"; -import { clearPerfLogs } from "./lib/perf-log"; -import { deepLinkBridgeEvent, drainPendingDeepLinks, type DeepLinkBridgeDetail } from "./lib/deep-link-bridge"; -import { - HIDE_TITLEBAR_PREF_KEY, - SUGGESTED_PLUGINS, -} from "./constants"; -import { readDenBootstrapConfig } from "./lib/den"; -import type { - Client, - StartupPreference, - EngineRuntime, - OnboardingStep, - ReloadReason, - ReloadTrigger, - SettingsTab, - View, - WorkspaceDisplay, - WorkspaceSessionGroup, - ProviderListItem, - OpencodeConnectStatus, -} from "./types"; -import { - clearStartupPreference, - deriveArtifacts, - deriveWorkingFiles, - isTauriRuntime, - normalizeDirectoryPath, -} from "./utils"; -import { currentLocale, setLocale, t } from "../i18n"; -import { - isWindowsPlatform, - lastUserModelFromMessages, - readStartupPreference, - safeStringify, -} from "./utils"; -import { - applyThemeMode, - getInitialThemeMode, - persistThemeMode, - subscribeToSystemTheme, - type ThemeMode, -} from "./theme"; -import { createSystemState } from "./system-state"; -import { createSessionStore } from "./context/session"; -import { - createModelConfigStore, -} from "./context/model-config"; -import { createProvidersStore } from "./context/providers"; -import { ModelControlsProvider } from "./app-settings/model-controls-provider"; -import { createModelControlsStore } from "./app-settings/model-controls-store"; -import { useFeatureFlagsPreferences } from "./app-settings/feature-flags-preferences"; -import { useSessionDisplayPreferences } from "./app-settings/session-display-preferences"; -import { - shouldRedirectMissingSessionAfterScopedLoad, -} from "./lib/session-scope"; -import { createExtensionsStore } from "./context/extensions"; -import { createConnectionsStore } from "./connections/store"; -import { createAutomationsStore } from "./context/automations"; -import { createSidebarSessionsStore } from "./context/sidebar-sessions"; -import { useGlobalSync } from "./context/global-sync"; -import { createWorkspaceStore } from "./context/workspace"; -import { - updaterEnvironment, - setWindowDecorations, -} from "./lib/tauri"; -import { - FONT_ZOOM_STEP, - applyWebviewZoom, - applyFontZoom, - normalizeFontZoom, - parseFontZoomShortcut, - persistFontZoom, - readStoredFontZoom, -} from "./lib/font-zoom"; -import { - buildOpenworkWorkspaceBaseUrl, - parseOpenworkWorkspaceIdFromUrl, - readOpenworkConnectInviteFromSearch, - stripOpenworkConnectInviteFromUrl, - hydrateOpenworkServerSettingsFromEnv, - normalizeOpenworkServerUrl, - readOpenworkServerSettings, - writeOpenworkServerSettings, - type OpenworkServerSettings, -} from "./lib/openwork-server"; -import { ReactIsland } from "../react/island"; -import { reactSessionEnabled } from "../react/feature-flag"; -import { ReactSessionRuntime } from "../react/session/runtime-sync.react"; -import { - parseBundleDeepLink, - stripBundleQuery, -} from "./bundles"; -import { createBundlesStore } from "./bundles/store"; -import { - classifyStartupBranch, - pushStartupTraceEvent, - type BootPhase, - type StartupBranch, - type StartupTraceEvent, -} from "./lib/startup-boot"; - -type SettingsReturnTarget = { - view: View; - tab: SettingsTab; - sessionId: string | null; -}; - -type PendingInitialSessionSelection = { - workspaceId: string; - title: string | null; - readyAt: number; -}; - -type RestrictionNotice = { - title: string; - message: string; -}; - -const STARTUP_SESSION_SNAPSHOT_KEY = "openwork.startupSessionSnapshot.v1"; -const STARTUP_SESSION_SNAPSHOT_VERSION = 1; -const STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE = 12; -const PROVIDER_RESTRICTION_MESSAGE = "Your administrator has restricted which providers and models are allowed. Please reach out to them to add new providers and models."; - -type StartupSessionSnapshotEntry = { - id: string; - title: string; - parentID?: string | null; - directory?: string | null; - time?: { - updated?: number | null; - created?: number | null; - }; -}; - -type StartupSessionSnapshot = { - version: number; - updatedAt: number; - sessionsByWorkspaceId: Record; -}; - -export default function App() { - const denAuth = useDenAuth(); - const desktopConfig = useDesktopConfig(); - const { resetSessionDisplayPreferences } = useSessionDisplayPreferences(); - const { microsandboxCreateSandboxEnabled } = useFeatureFlagsPreferences(); - const envOpenworkWorkspaceId = - typeof import.meta.env?.VITE_OPENWORK_WORKSPACE_ID === "string" - ? import.meta.env.VITE_OPENWORK_WORKSPACE_ID.trim() || null - : null; - - const location = useLocation(); - const navigate = useNavigate(); - - const [creatingSession, setCreatingSession] = createSignal(false); - const currentView = createMemo(() => { - const path = location.pathname.toLowerCase(); - if (path.startsWith("/signin")) return "signin"; - if (path.startsWith("/session")) return "session"; - return "settings"; - }); - const forceSigninEnabled = createMemo(() => readDenBootstrapConfig().requireSignin); - const blockingSigninPending = createMemo( - () => forceSigninEnabled() && denAuth.status() === "checking", - ); - - const [settingsTab, setSettingsTabState] = createSignal("general"); - const [pendingInitialSessionSelection, setPendingInitialSessionSelection] = - createSignal(null); - const [restrictionNotice, setRestrictionNotice] = createSignal(null); - - const goToSettings = (nextTab: SettingsTab, options?: { replace?: boolean }) => { - setSettingsTabState(nextTab); - navigate(`/settings/${nextTab}`, options); - }; - - const setSettingsTab = (nextTab: SettingsTab) => { - if (currentView() === "settings") { - goToSettings(nextTab); - return; - } - setSettingsTabState(nextTab); - }; - - const openCreateWorkspace = () => { - if (desktopConfig.checkRestriction({ restriction: "blockMultipleWorkspaces" })) { - setRestrictionNotice({ - title: "Additional workspaces are restricted", - message: "Your organization administrator has restricted access to adding additional workspaces.", - }); - return; - } - - workspaceStore.setCreateWorkspaceOpen(true); - }; - - const providerConnectionsRestricted = createMemo(() => - desktopConfig.checkRestriction({ restriction: "disallowNonCloudModels" }), - ); - - const setView = (next: View, sessionId?: string) => { - if (next === "signin") { - navigate("/signin"); - return; - } - if (next === "settings" && creatingSession()) { - return; - } - if (next === "session") { - if (sessionId) { - goToSession(sessionId); - return; - } - navigate("/session"); - return; - } - goToSettings(settingsTab()); - }; - - const goToSession = (sessionId: string, options?: { replace?: boolean }) => { - const trimmed = sessionId.trim(); - if (!trimmed) { - navigate("/session", options); - return; - } - navigate(`/session/${trimmed}`, options); - }; - - const [startupPreference, setStartupPreference] = createSignal( - readStartupPreference(), - ); - const [onboardingStep, setOnboardingStep] = - createSignal("welcome"); - const [rememberStartupChoice, setRememberStartupChoice] = createSignal(false); - const [themeMode, setThemeMode] = createSignal(getInitialThemeMode()); - - const [engineSource, setEngineSource] = createSignal<"path" | "sidecar" | "custom">( - isTauriRuntime() ? "sidecar" : "path" - ); - - const [engineCustomBinPath, setEngineCustomBinPath] = createSignal(""); - - const [engineRuntime, setEngineRuntime] = createSignal("openwork-orchestrator"); - const [opencodeEnableExa, setOpencodeEnableExa] = createSignal(false); - - const [baseUrl, setBaseUrl] = createSignal("http://127.0.0.1:4096"); - const [clientDirectory, setClientDirectory] = createSignal(""); - - createEffect(() => { - if (typeof window === "undefined") return; - hydrateOpenworkServerSettingsFromEnv(); - - const stored = readOpenworkServerSettings(); - const invite = readOpenworkConnectInviteFromSearch(window.location.search); - const bundleInvite = parseBundleDeepLink(window.location.href); - - if (!invite) { - setOpenworkServerSettings(stored); - } else { - const merged: OpenworkServerSettings = { - ...stored, - urlOverride: invite.url, - token: invite.token ?? stored.token, - }; - - const next = writeOpenworkServerSettings(merged); - setOpenworkServerSettings(next); - - if (invite.startup === "server" && untrack(onboardingStep) === "welcome") { - setStartupPreference("server"); - setOnboardingStep("server"); - } - } - - if (bundleInvite?.bundleUrl) { - bundlesStore.queueBundleLink(window.location.href); - } - - if (invite?.autoConnect) { - deepLinks.queueRemoteConnectDefaults({ - openworkHostUrl: invite.url, - openworkToken: invite.token ?? null, - directory: null, - displayName: null, - autoConnect: true, - }); - } - - const cleanedConnect = stripOpenworkConnectInviteFromUrl(window.location.href); - const cleaned = stripBundleQuery(cleanedConnect) ?? cleanedConnect; - if (cleaned !== window.location.href) { - window.history.replaceState(window.history.state ?? null, "", cleaned); - } - }); - - createEffect(() => { - if (typeof document === "undefined") return; - const update = () => setDocumentVisible(document.visibilityState !== "hidden"); - update(); - document.addEventListener("visibilitychange", update); - onCleanup(() => document.removeEventListener("visibilitychange", update)); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!isTauriRuntime()) return; - - const applyAndPersistFontZoom = (value: number) => { - const next = normalizeFontZoom(value); - persistFontZoom(window.localStorage, next); - - try { - const webview = getCurrentWebview(); - void applyWebviewZoom(webview, next) - .then(() => { - document.documentElement.style.removeProperty("--openwork-font-size"); - }) - .catch(() => { - applyFontZoom(document.documentElement.style, next); - }); - } catch { - applyFontZoom(document.documentElement.style, next); - } - - return next; - }; - - let fontZoom = applyAndPersistFontZoom(readStoredFontZoom(window.localStorage) ?? 1); - - const handleZoomShortcut = (event: KeyboardEvent) => { - const action = parseFontZoomShortcut(event); - if (!action) return; - - if (action === "in") { - fontZoom = applyAndPersistFontZoom(fontZoom + FONT_ZOOM_STEP); - } else if (action === "out") { - fontZoom = applyAndPersistFontZoom(fontZoom - FONT_ZOOM_STEP); - } else { - fontZoom = applyAndPersistFontZoom(1); - } - - event.preventDefault(); - event.stopPropagation(); - }; - - window.addEventListener("keydown", handleZoomShortcut, true); - onCleanup(() => window.removeEventListener("keydown", handleZoomShortcut, true)); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!developerMode()) return; - if (!documentVisible()) return; - if (booting()) return; - if (workspaceStore?.connectingWorkspaceId?.()) return; - - let busy = false; - - const run = async () => { - if (busy) return; - busy = true; - try { - await workspaceStore.refreshEngine(); - } finally { - busy = false; - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - window.clearInterval(interval); - }); - }); - - const [client, setClient] = createSignal(null); - const [connectedVersion, setConnectedVersion] = createSignal( - null - ); - const [sseConnected, setSseConnected] = createSignal(false); - - const [busy, setBusy] = createSignal(false); - const [busyLabel, setBusyLabel] = createSignal(null); - const [busyStartedAt, setBusyStartedAt] = createSignal(null); - const [error, setError] = createSignal(null); - const [opencodeConnectStatus, setOpencodeConnectStatus] = createSignal(null); - const [booting, setBooting] = createSignal(true); - const [bootPhase, setBootPhase] = createSignal("nativeInit"); - const [startupBranch, setStartupBranch] = createSignal("unknown"); - const [startupTrace, setStartupTrace] = createSignal([]); - const [firstSidebarVisibleAt, setFirstSidebarVisibleAt] = createSignal(null); - const [firstSessionPaintAt, setFirstSessionPaintAt] = createSignal(null); - const [, setLastKnownConfigSnapshot] = createSignal(""); - const [developerMode, setDeveloperMode] = createSignal(false); - const [documentVisible, setDocumentVisible] = createSignal(true); - - const markStartupTrace = (phase: BootPhase, event: string, detail?: Record) => { - setStartupTrace((current) => - pushStartupTraceEvent(current, { - at: Date.now(), - phase, - event, - ...(detail ? { detail } : {}), - }), - ); - }; - - createEffect(() => { - const phase = bootPhase(); - const isBooting = phase !== "ready" && phase !== "error"; - setBooting(isBooting); - }); - - createEffect(() => { - if (bootPhase() === "ready" || bootPhase() === "error") return; - const message = error(); - if (!message) return; - setBootPhase("error"); - markStartupTrace("error", "startup-error", { message }); - }); - - createEffect(() => { - if (developerMode()) return; - clearDevLogs(); - clearPerfLogs(); - }); - - const [selectedSessionId, setSelectedSessionId] = createSignal(null); - const [prompt, setPrompt] = createSignal(""); - const [settingsReturnTarget, setSettingsReturnTarget] = createSignal({ - view: "settings", - tab: "general", - sessionId: null, - }); - const SESSION_BY_WORKSPACE_KEY = "openwork.workspace-last-session.v1"; - const readSessionByWorkspace = () => { - if (typeof window === "undefined") return {} as Record; - try { - const raw = window.localStorage.getItem(SESSION_BY_WORKSPACE_KEY); - if (!raw) return {} as Record; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") return {} as Record; - return parsed as Record; - } catch { - return {} as Record; - } - }; - const writeSessionByWorkspace = (map: Record) => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(SESSION_BY_WORKSPACE_KEY, JSON.stringify(map)); - } catch { - // ignore - } - }; - - const globalSync = useGlobalSync(); - const providers = createMemo(() => globalSync.data.provider.all ?? []); - const providerDefaults = createMemo(() => globalSync.data.provider.default ?? {}); - const providerConnectedIds = createMemo(() => globalSync.data.provider.connected ?? []); - const connectedProviderIdSet = createMemo( - () => new Set(providerConnectedIds().map((providerId) => providerId.trim())), - ); - const visibleProviders = createMemo(() => - providers().filter((provider) => - !isDesktopProviderBlocked({ - providerId: provider.id, - checkRestriction: desktopConfig.checkRestriction, - }) && - (!providerConnectionsRestricted() || connectedProviderIdSet().has(provider.id.trim())), - ), - ); - const visibleProviderConnectedIds = createMemo(() => - providerConnectedIds().filter((providerId) => - !isDesktopProviderBlocked({ - providerId, - checkRestriction: desktopConfig.checkRestriction, - })), - ); - const setProviders = (value: ProviderListItem[]) => { - globalSync.set("provider", "all", value); - }; - const setProviderDefaults = (value: Record) => { - globalSync.set("provider", "default", value); - }; - const setProviderConnectedIds = (value: string[]) => { - globalSync.set("provider", "connected", value); - }; - - let workspaceStore!: ReturnType; - let sessionStore!: ReturnType; - let openworkServerStore!: ReturnType; - - const modelConfig = createModelConfigStore({ - client, - selectedSessionId, - messages: () => sessionStore?.messages?.() ?? [], - providers, - providerDefaults, - providerConnectedIds, - selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", - selectedWorkspaceDisplay: () => - workspaceStore?.selectedWorkspaceDisplay?.() ?? ({ workspaceType: "local" } as WorkspaceDisplay), - selectedWorkspacePath: () => workspaceStore?.selectedWorkspacePath?.() ?? "", - openworkServerClient: () => openworkServerStore?.openworkServerClient?.() ?? null, - openworkServerStatus: () => openworkServerStore?.openworkServerStatus?.() ?? "disconnected", - openworkServerCapabilities: () => openworkServerStore?.openworkServerCapabilities?.() ?? null, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - checkDesktopAppRestriction: desktopConfig.checkRestriction, - focusSessionPromptSoon: () => focusSessionPromptSoon(), - setError, - setLastKnownConfigSnapshot, - markOpencodeConfigReloadRequired: () => - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }), - }); - - createEffect(() => { - const view = currentView(); - const currentTab = settingsTab(); - if (view === "settings" || view === "signin") return; - setSettingsReturnTarget({ - view, - tab: currentTab, - sessionId: selectedSessionId(), - }); - }); - - const restoreSettingsReturnTarget = () => { - const target = settingsReturnTarget(); - if (target.view === "session") { - if (target.sessionId) { - goToSession(target.sessionId); - return; - } - navigate("/session"); - return; - } - goToSettings(target.tab); - }; - - const toggleSettingsView = (nextTab: SettingsTab = "general") => { - const settingsOpen = currentView() === "settings"; - if (settingsOpen) { - restoreSettingsReturnTarget(); - return; - } - setSettingsTab(nextTab); - goToSettings(nextTab); - }; - - let markReloadRequiredHandler: ((reason: ReloadReason, trigger?: ReloadTrigger) => void) | undefined; - const markReloadRequired = (reason: ReloadReason, trigger?: ReloadTrigger) => { - markReloadRequiredHandler?.(reason, trigger); - }; - - sessionStore = createSessionStore({ - client, - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot().trim(), - selectedSessionId, - setSelectedSessionId, - setPrompt, - sessionModelState: modelConfig.sessionModelState, - setSessionModelState: modelConfig.setSessionModelState, - lastUserModelFromMessages, - developerMode, - setError, - setSseConnected, - markReloadRequired, - onHotReloadApplied: () => { - void refreshSkills({ force: true }); - void refreshPlugins(pluginScope()); - void refreshMcpServers(); - }, - }); - - const { - sessions, - loadedScopeRoot: loadedSessionScopeRoot, - sessionById, - sessionStatusById, - messageIdFromInfo, - selectedSession, - selectedSessionStatus, - selectedSessionErrorTurns, - selectedSessionCompactionState, - messages, - visibleMessages, - messagesBySessionId, - todos, - pendingPermissions, - permissionReplyBusy, - activeQuestion, - questionReplyBusy, - events, - activePermission, - loadSessions, - ensureSessionLoaded, - refreshPendingPermissions, - selectSession, - loadEarlierMessages, - renameSession, - respondPermission, - respondQuestion, - restorePromptFromUserMessage, - upsertLocalSession, - setBlueprintSeedMessagesBySessionId, - setSessions, - setSessionStatusById, - setMessages, - setTodos, - setPendingPermissions, - selectedSessionHasEarlierMessages, - selectedSessionLoadingEarlierMessages, - sessionLoadingById, - } = sessionStore; - - const ARTIFACT_SCAN_MESSAGE_WINDOW = 220; - const artifacts = createMemo(() => - deriveArtifacts(messages(), { maxMessages: ARTIFACT_SCAN_MESSAGE_WINDOW }), - ); - const workingFiles = createMemo(() => deriveWorkingFiles(artifacts())); - const activeSessionId = createMemo(() => { - const path = location.pathname.trim(); - const [, sessionSegment, idSegment] = path.split("/"); - if (sessionSegment?.toLowerCase() === "session") { - const routeId = (idSegment ?? "").trim(); - if (routeId) return routeId; - } - return selectedSessionId(); - }); - const activeSessionStatusById = createMemo(() => sessionStatusById()); - const activeTodos = createMemo(() => todos()); - const activeWorkingFiles = createMemo(() => workingFiles()); - const [startupSessionSnapshotByWorkspaceId, setStartupSessionSnapshotByWorkspaceId] = createSignal< - Record - >({}); - - const [sessionsLoaded, setSessionsLoaded] = createSignal(false); - const loadSessionsWithReady = async (scopeRoot?: string) => { - await loadSessions(scopeRoot); - setSessionsLoaded(true); - }; - - createEffect(() => { - if (typeof window === "undefined") return; - try { - const raw = window.localStorage.getItem(STARTUP_SESSION_SNAPSHOT_KEY); - if (!raw) return; - const parsed = JSON.parse(raw) as StartupSessionSnapshot; - if (!parsed || parsed.version !== STARTUP_SESSION_SNAPSHOT_VERSION) return; - if (!parsed.sessionsByWorkspaceId || typeof parsed.sessionsByWorkspaceId !== "object") return; - setStartupSessionSnapshotByWorkspaceId(parsed.sessionsByWorkspaceId); - } catch { - // ignore malformed snapshots - } - }); - - createEffect(() => { - if (!client()) { - setSessionsLoaded(false); - } - }); - - const ensureWorkspaceRuntime = async (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return false; - const ready = await workspaceStore.activateWorkspace(id); - if (ready) { - await refreshSidebarWorkspaceSessions(id).catch(() => undefined); - } - return ready; - }; - - function focusSessionPromptSoon() { - if (typeof window === "undefined" || currentView() !== "session") return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - window.dispatchEvent(new CustomEvent("openwork:focusPrompt")); - }); - }); - } - - async function respondPermissionAndRemember( - requestID: string, - reply: "once" | "always" | "reject" - ) { - // Intentional no-op: permission prompts grant session-scoped access only. - // Persistent workspace roots must be managed explicitly via workspace settings. - await respondPermission(requestID, reply); - } - - openworkServerStore = createOpenworkServerStore({ - startupPreference, - documentVisible, - developerMode, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - activeClient: client, - selectedWorkspaceDisplay: () => - workspaceStore?.selectedWorkspaceDisplay?.() ?? ({ workspaceType: "local" } as WorkspaceDisplay), - restartLocalServer, - createRemoteWorkspaceFlow: async (input) => - (await workspaceStore?.createRemoteWorkspaceFlow?.(input)) ?? false, - }); - - const { - openworkServerSettings, - setOpenworkServerSettings, - updateOpenworkServerSettings, - resetOpenworkServerSettings, - shareRemoteAccessBusy, - shareRemoteAccessError, - saveShareRemoteAccess, - openworkServerUrl, - openworkServerBaseUrl, - openworkServerAuth, - openworkServerClient, - openworkServerStatus, - openworkServerCapabilities, - openworkServerReady, - openworkServerWorkspaceReady, - resolvedOpenworkCapabilities, - openworkServerCanWriteSkills, - openworkServerCanWritePlugins, - openworkServerHostInfo, - openworkServerDiagnostics, - openworkReconnectBusy, - opencodeRouterInfoState, - orchestratorStatusState, - openworkAuditEntries, - openworkAuditStatus, - openworkAuditError, - testOpenworkServerConnection, - reconnectOpenworkServer, - ensureLocalOpenworkServerClient, - } = openworkServerStore; - - const extensionsStore = createExtensionsStore({ - client, - projectDir: () => workspaceProjectDir(), - selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", - selectedWorkspaceRoot: () => workspaceStore?.selectedWorkspaceRoot?.() ?? "", - workspaceType: () => workspaceStore?.selectedWorkspaceDisplay?.().workspaceType ?? "local", - openworkServer: openworkServerStore, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - setBusy, - setBusyLabel, - setBusyStartedAt, - setError, - markReloadRequired, - }); - - const { - skills, - skillsStatus, - pluginScope, - sidebarPluginList, - sidebarPluginStatus, - isPluginInstalledByName, - refreshSkills, - refreshHubSkills, - refreshPlugins, - addPlugin, - abortRefreshes, - } = extensionsStore; - - const connectionsStore = createConnectionsStore({ - client, - setClient, - projectDir: () => workspaceProjectDir(), - selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", - selectedWorkspaceRoot: () => workspaceStore?.selectedWorkspaceRoot?.() ?? "", - workspaceType: () => workspaceStore?.selectedWorkspaceDisplay?.().workspaceType ?? "local", - openworkServer: openworkServerStore, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - ensureRuntimeWorkspaceId: () => workspaceStore?.ensureRuntimeWorkspaceId?.(), - setProjectDir: (value: string) => workspaceStore?.setProjectDir?.(value), - developerMode, - markReloadRequired, - }); - - const { refreshMcpServers } = connectionsStore; - - const [hideTitlebar, setHideTitlebar] = createSignal(false); - const { - defaultModel, - selectedSessionModel, - selectedSessionModelLabel, - defaultModelLabel, - defaultModelRef, - defaultModelVariantLabel, - modelVariant, - sessionModelVariantLabel, - sessionModelBehaviorOptions, - setSessionModelVariant, - sanitizeModelVariantForRef, - resolveCodexReasoningEffort, - modelPickerOpen, - modelPickerQuery, - setModelPickerQuery, - modelPickerTarget, - modelPickerCurrent, - modelOptions, - filteredModelOptions, - openSessionModelPicker, - openDefaultModelPicker, - closeModelPicker, - applyModelSelection, - setModelPickerBehavior, - autoCompactContext, - toggleAutoCompactContext, - autoCompactContextSaving, - } = modelConfig; - - workspaceStore = createWorkspaceStore({ - startupPreference, - setStartupPreference, - onboardingStep, - setOnboardingStep, - rememberStartupChoice, - setRememberStartupChoice, - baseUrl, - setBaseUrl, - clientDirectory, - setClientDirectory, - client, - setClient, - setConnectedVersion, - setSseConnected, - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setError, - setBusy, - setBusyLabel, - setBusyStartedAt, - setOpencodeConnectStatus, - loadSessions: loadSessionsWithReady, - refreshPendingPermissions, - refreshWorkspaceSessions: (workspaceId: string) => refreshSidebarWorkspaceSessions(workspaceId), - sessions, - sessionsLoaded, - creatingSession, - readLastSessionByWorkspace: readSessionByWorkspace, - selectedSessionId, - selectSession, - setBlueprintSeedMessagesBySessionId, - setSelectedSessionId, - setMessages, - setTodos, - setPendingPermissions, - setSessionStatusById, - defaultModel, - modelVariant, - refreshSkills, - refreshPlugins, - engineSource, - engineCustomBinPath, - opencodeEnableExa, - setEngineSource, - setView, - setSettingsTab, - isWindowsPlatform, - openworkServer: openworkServerStore, - openworkEnvWorkspaceId: envOpenworkWorkspaceId, - onEngineStable: () => {}, - onBootPhaseChange: (phase, detail) => { - setBootPhase(phase); - markStartupTrace(phase, "phase-change", detail); - }, - onStartupBranch: (branch, detail) => { - setStartupBranch(branch); - markStartupTrace(bootPhase(), "branch", { branch, ...(detail ?? {}) }); - }, - onStartupTrace: (event, detail) => { - markStartupTrace(bootPhase(), event, detail); - }, - engineRuntime, - developerMode, - pendingInitialSessionSelection, - setPendingInitialSessionSelection, - useMicrosandboxCreateSandbox: microsandboxCreateSandboxEnabled, - }); - - createEffect(() => { - if (startupBranch() !== "unknown") return; - const active = workspaceStore.selectedWorkspaceInfo?.() ?? null; - const derived = classifyStartupBranch({ - workspaceCount: workspaceStore.workspaces().length, - activeWorkspaceType: active?.workspaceType ?? null, - startupPreference: startupPreference(), - engineHasBaseUrl: Boolean(workspaceStore.engine()?.baseUrl), - selectedWorkspacePath: workspaceStore.selectedWorkspacePath?.() ?? "", - }); - if (derived !== "unknown") { - setStartupBranch(derived); - markStartupTrace(bootPhase(), "branch-derived", { branch: derived }); - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!developerMode()) return; - const payload = { - phase: bootPhase(), - branch: startupBranch(), - events: startupTrace(), - }; - try { - (window as { __openworkStartupTrace?: typeof payload }).__openworkStartupTrace = payload; - console.log("[startup-trace]", payload); - } catch { - // ignore trace publishing failures - } - }); - - const { - providerAuthModalOpen, - providerAuthBusy, - providerAuthError, - providerAuthMethods, - providerAuthProviders, - providerAuthPreferredProviderId, - providerAuthWorkerType, - cloudOrgProviders, - importedCloudProviders, - startProviderAuth, - refreshProviders, - refreshCloudOrgProviders, - completeProviderAuthOAuth, - submitProviderApiKey, - connectCloudProvider, - removeCloudProvider, - disconnectProvider, - runCloudProviderSync, - ensureProjectProviderDisabledState, - openProviderAuthModal: openProviderAuthModalInternal, - closeProviderAuthModal, - } = createProvidersStore({ - client, - providers, - providerDefaults, - providerConnectedIds, - disabledProviders: () => globalSync.data.config.disabled_providers ?? [], - selectedWorkspaceDisplay: () => workspaceStore.selectedWorkspaceDisplay(), - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), - runtimeWorkspaceId: () => workspaceStore.runtimeWorkspaceId(), - checkDesktopAppRestriction: desktopConfig.checkRestriction, - openworkServer: openworkServerStore, - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setDisabledProviders: (value) => globalSync.set("config", "disabled_providers", value), - markOpencodeConfigReloadRequired: () => markOpencodeConfigReloadRequired(), - focusPromptSoon: focusSessionPromptSoon, - }); - - const openProviderAuthModal = async (optionsArg?: { - returnFocusTarget?: "none" | "composer"; - preferredProviderId?: string; - }) => { - if (providerConnectionsRestricted()) { - setRestrictionNotice({ - title: "Provider connections are restricted", - message: PROVIDER_RESTRICTION_MESSAGE, - }); - return; - } - - await openProviderAuthModalInternal(optionsArg); - }; - - let desktopRestrictionSyncKey = ""; - let desktopRestrictionSyncRunId = 0; - - createEffect(() => { - const workspaceId = workspaceStore.selectedWorkspaceId().trim(); - if (!workspaceId) { - desktopRestrictionSyncKey = ""; - return; - } - - const workspacePath = workspaceStore.selectedWorkspacePath().trim(); - const restrictionSnapshot = JSON.stringify(desktopConfig.config()); - const providerSnapshot = providers().map((provider) => provider.id).join(","); - const connectedSnapshot = providerConnectedIds().join(","); - const defaultModelSnapshot = modelConfig.defaultModelRef(); - const hasClient = Boolean(client()); - const nextKey = [ - workspaceId, - workspacePath, - restrictionSnapshot, - providerSnapshot, - connectedSnapshot, - defaultModelSnapshot, - hasClient ? "client" : "no-client", - ].join("::"); - - if (nextKey === desktopRestrictionSyncKey) { - return; - } - - desktopRestrictionSyncKey = nextKey; - const currentRun = ++desktopRestrictionSyncRunId; - - void runDesktopAppRestrictionSyncEffects({ - checkRestriction: desktopConfig.checkRestriction, - reconcileRestrictedModels: modelConfig.reconcileRestrictedModels, - ensureProjectProviderDisabledState, - onError: (error, details) => { - if (currentRun !== desktopRestrictionSyncRunId) { - return; - } - console.warn("[desktop-app-restrictions] effect failed", details, error); - }, - }); - }); - - const runtimeWorkspaceId = createMemo(() => workspaceStore.runtimeWorkspaceId()); - const activeWorkspaceServerConfig = createMemo(() => workspaceStore.runtimeWorkspaceConfig()); - const statusToastsStore = createStatusToastsStore(); - const bundlesStore = createBundlesStore({ - booting, - startupPreference, - openworkServer: openworkServerStore, - runtimeWorkspaceId, - workspaceStore, - setError, - error, - setView, - setSettingsTab, - refreshActiveWorkspaceServerConfig: workspaceStore.refreshRuntimeWorkspaceConfig, - refreshSkills, - refreshHubSkills, - markReloadRequired, - showStatusToast: statusToastsStore.showToast, - }); - - const deepLinks = createDeepLinksController({ - booting, - setError, - setView, - setSettingsTab, - goToSettings, - workspaceStore, - bundlesStore, - }); - - const sidebarSessionsStore = createSidebarSessionsStore({ - workspaces: () => workspaceStore.workspaces(), - engine: () => workspaceStore.engine(), - }); - - const { - workspaceGroups: rawSidebarWorkspaceGroups, - refreshWorkspaceSessions: refreshSidebarWorkspaceSessions, - } = sidebarSessionsStore; - - const sessionActionsStore = createSessionActionsStore({ - client, - baseUrl, - developerMode, - prompt, - setPrompt, - selectedSessionId, - selectedSession, - sessions, - messages, - setSessions, - sessionStatusById, - setSessionStatusById, - setBusy, - setBusyLabel, - setBusyStartedAt, - setCreatingSession, - setError, - selectWorkspace: workspaceStore.selectWorkspace, - workspaceRootForId: workspaceStore.workspaceRootForId, - selectedWorkspaceId: () => workspaceStore.selectedWorkspaceId(), - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), - runtimeWorkspaceRoot: () => workspaceStore.runtimeWorkspaceRoot(), - ensureWorkspaceRuntime, - selectSession, - refreshSidebarWorkspaceSessions, - abortRefreshes, - modelConfig, - selectedSessionModel: () => selectedSessionModel(), - modelVariant, - sanitizeModelVariantForRef: (ref, value) => sanitizeModelVariantForRef(ref, value), - resolveCodexReasoningEffort: (modelId, variant) => resolveCodexReasoningEffort(modelId, variant), - messageIdFromInfo, - restorePromptFromUserMessage, - upsertLocalSession, - readSessionByWorkspace, - writeSessionByWorkspace, - setSelectedSessionId, - locationPath: () => location.pathname, - navigate, - renameSession, - appendSessionErrorTurn: sessionStore.appendSessionErrorTurn, - }); - - const { - lastPromptSent, - selectedSessionAgent, - sessionRevertMessageId, - createSessionInWorkspace, - createSessionAndOpen, - sendPrompt, - abortSession, - retryLastPrompt, - compactCurrentSession, - undoLastUserMessage, - redoLastUserMessage, - renameSessionTitle, - deleteSessionById, - listAgents, - listCommands, - setSessionAgent, - searchWorkspaceFiles, - } = sessionActionsStore; - - const sidebarWorkspaceGroups = createMemo(() => { - const groups = rawSidebarWorkspaceGroups(); - const selectedWorkspaceId = workspaceStore.selectedWorkspaceId().trim(); - const connectingWorkspaceId = workspaceStore.connectingWorkspaceId()?.trim() ?? ""; - const dedupedGroups: typeof groups = []; - const dedupeKeyToIndex = new Map(); - for (const group of groups) { - const workspace = group.workspace; - if (workspace.workspaceType !== "remote") { - dedupedGroups.push(group); - continue; - } - const hostKey = - normalizeOpenworkServerUrl(workspace.openworkHostUrl?.trim() ?? "") ?? - normalizeOpenworkServerUrl(workspace.baseUrl?.trim() ?? "") ?? - ""; - const workspaceIdKey = - workspace.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || - ""; - const directoryKey = normalizeDirectoryPath(workspace.directory?.trim() ?? workspace.path?.trim() ?? ""); - const identityKey = workspaceIdKey ? `id:${workspaceIdKey}` : (directoryKey ? `dir:${directoryKey}` : ""); - if (!hostKey || !identityKey) { - dedupedGroups.push(group); - continue; - } - const dedupeKey = `${workspace.remoteType ?? ""}|${hostKey}|${identityKey}`; - const existingIndex = dedupeKeyToIndex.get(dedupeKey); - if (existingIndex === undefined) { - dedupeKeyToIndex.set(dedupeKey, dedupedGroups.length); - dedupedGroups.push(group); - continue; - } - const existingWorkspace = dedupedGroups[existingIndex].workspace; - const existingIsPriority = - existingWorkspace.id === selectedWorkspaceId || existingWorkspace.id === connectingWorkspaceId; - const currentIsPriority = - workspace.id === selectedWorkspaceId || workspace.id === connectingWorkspaceId; - if (currentIsPriority && !existingIsPriority) { - dedupedGroups[existingIndex] = group; - } - } - return dedupedGroups.map((group) => { - const workspace = group.workspace; - const groupSessions = group.sessions; - if (developerMode()) { - console.log("[sidebar-groups] workspace group", { - workspaceId: workspace.id, - workspaceName: workspace.name, - workspaceType: workspace.workspaceType, - workspacePath: workspace.path, - workspaceDirectory: workspace.directory, - sessionCount: groupSessions.length, - sessions: groupSessions.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - parentID: session.parentID, - })), - }); - } - return { - workspace, - sessions: groupSessions, - status: group.status, - error: group.error, - }; - }); - }); - - const hydratedSidebarWorkspaceGroups = createMemo(() => { - const liveGroups = sidebarWorkspaceGroups(); - if (liveGroups.some((group) => group.sessions.length > 0)) { - return liveGroups; - } - - const snapshotByWorkspaceId = startupSessionSnapshotByWorkspaceId(); - if (!snapshotByWorkspaceId || Object.keys(snapshotByWorkspaceId).length === 0) { - return liveGroups; - } - - return liveGroups.map((group) => { - if (group.sessions.length > 0) return group; - const cachedSessions = snapshotByWorkspaceId[group.workspace.id] ?? []; - if (!cachedSessions.length) return group; - return { - ...group, - sessions: cachedSessions, - }; - }); - }); - - const sidebarHydratedFromCache = createMemo(() => { - const liveGroups = sidebarWorkspaceGroups(); - const hydratedGroups = hydratedSidebarWorkspaceGroups(); - if (!hydratedGroups.length) return false; - if (liveGroups.length !== hydratedGroups.length) return false; - return hydratedGroups.some((group, index) => { - const liveGroup = liveGroups[index]; - if (!liveGroup) return false; - return liveGroup.sessions.length === 0 && group.sessions.length > 0; - }); - }); - - createEffect(() => { - if (firstSidebarVisibleAt()) return; - const anyRowsVisible = hydratedSidebarWorkspaceGroups().some((group) => group.sessions.length > 0); - if (!anyRowsVisible) return; - const at = Date.now(); - setFirstSidebarVisibleAt(at); - markStartupTrace(bootPhase(), "first-sidebar-visible", { - at, - source: sidebarHydratedFromCache() ? "cache" : "live", - }); - }); - - createEffect(() => { - if (firstSessionPaintAt()) return; - if (currentView() !== "session") return; - const selected = activeSessionId(); - if (!selected) return; - const hasVisibleSessionSurface = visibleMessages().length > 0 || sessionsLoaded(); - if (!hasVisibleSessionSurface) return; - const at = Date.now(); - setFirstSessionPaintAt(at); - markStartupTrace(bootPhase(), "first-session-paint", { at, sessionId: selected }); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!sessionsLoaded()) return; - - const groups = sidebarWorkspaceGroups(); - const sessionsByWorkspaceId: Record = {}; - for (const group of groups) { - if (!group.sessions.length) continue; - sessionsByWorkspaceId[group.workspace.id] = group.sessions - .slice(0, STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE) - .map((session) => ({ - id: session.id, - title: session.title, - parentID: session.parentID ?? null, - directory: session.directory ?? null, - time: session.time, - })); - } - if (Object.keys(sessionsByWorkspaceId).length === 0) return; - - const payload: StartupSessionSnapshot = { - version: STARTUP_SESSION_SNAPSHOT_VERSION, - updatedAt: Date.now(), - sessionsByWorkspaceId, - }; - - try { - window.localStorage.setItem(STARTUP_SESSION_SNAPSHOT_KEY, JSON.stringify(payload)); - setStartupSessionSnapshotByWorkspaceId(sessionsByWorkspaceId); - } catch { - // ignore storage write failures - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = workspaceStore.selectedWorkspaceId(); - const sessionId = selectedSessionId(); - if (!workspaceId || !sessionId) return; - const map = readSessionByWorkspace(); - if (map[workspaceId] === sessionId) return; - map[workspaceId] = sessionId; - writeSessionByWorkspace(map); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const pending = pendingInitialSessionSelection(); - if (!pending) return; - const delayMs = pending.readyAt - Date.now(); - if (delayMs <= 0) return; - const timer = window.setTimeout(() => { - setPendingInitialSessionSelection((current) => - current && current.workspaceId === pending.workspaceId && current.readyAt === pending.readyAt - ? { ...current } - : current, - ); - }, delayMs); - onCleanup(() => window.clearTimeout(timer)); - }); - - createEffect(() => { - const pending = pendingInitialSessionSelection(); - if (!pending) return; - const workspaceId = workspaceStore.selectedWorkspaceId().trim(); - if (!workspaceId || pending.workspaceId !== workspaceId) return; - const path = location.pathname.trim().toLowerCase(); - if (path.startsWith("/session/") || !!selectedSessionId()) { - setPendingInitialSessionSelection(null); - } - }); - - createEffect(() => { - // Only auto-select on bare /session. If the URL already includes /session/:id, - // let the route-driven selector own the fetch to avoid duplicate selection runs. - const pending = pendingInitialSessionSelection(); - const workspaceId = workspaceStore.selectedWorkspaceId().trim(); - if (pending && pending.workspaceId === workspaceId) { - if (Date.now() < pending.readyAt) return; - if (!sessionsLoaded()) return; - if (sessions().length === 0) return; - const workspaceRoot = normalizeDirectoryPath(workspaceStore.selectedWorkspaceRoot().trim()); - const normalizedTitle = pending.title?.trim().toLowerCase() ?? ""; - const match = normalizedTitle - ? sessions().find((session) => { - const sessionTitle = session.title?.trim().toLowerCase() ?? ""; - if (sessionTitle !== normalizedTitle) return false; - if (!workspaceRoot) return true; - const sessionRoot = normalizeDirectoryPath(typeof session.directory === "string" ? session.directory : ""); - return sessionRoot === workspaceRoot; - }) - : null; - if (match) { - goToSession(match.id, { replace: true }); - return; - } - setPendingInitialSessionSelection(null); - setView("session"); - return; - } - - if (currentView() !== "session") return; - const normalizedPath = location.pathname.toLowerCase().replace(/\/+$/, ""); - if (normalizedPath !== "/session") return; - if (!client()) return; - if (!sessionsLoaded()) return; - if (creatingSession()) return; - if (selectedSessionId()) return; - - // Keep /session as a draft-ready empty state until the user picks a session - // or sends a prompt. Avoid auto-selecting prior sessions on app launch. - return; - }); - - createEffect(() => { - const active = workspaceStore.selectedWorkspaceDisplay(); - if (active.workspaceType !== "remote" || active.remoteType !== "openwork") { - return; - } - const hostUrl = active.openworkHostUrl?.trim() ?? ""; - if (!hostUrl) return; - const token = active.openworkToken?.trim() ?? ""; - const settings = openworkServerSettings(); - if (settings.urlOverride?.trim() === hostUrl && (!token || settings.token?.trim() === token)) { - return; - } - updateOpenworkServerSettings({ - ...settings, - urlOverride: hostUrl, - token: token || settings.token, - }); - }); - - async function restartLocalServer() { - const activeWorkspace = workspaceStore.selectedWorkspaceDisplay(); - const activeLocalPath = - activeWorkspace.workspaceType === "local" ? workspaceStore.selectedWorkspacePath().trim() : ""; - const runningProjectDir = workspaceStore.engine()?.projectDir?.trim() ?? ""; - const workspacePath = activeLocalPath || runningProjectDir; - - if (!workspacePath) { - setError(t("app.error_pick_local_folder")); - return false; - } - - return workspaceStore.startHost({ workspacePath, navigate: false }); - } - - const canReloadLocalEngine = () => - isTauriRuntime() && workspaceStore.selectedWorkspaceDisplay().workspaceType === "local"; - - const canReloadWorkspace = createMemo(() => { - if (canReloadLocalEngine()) return true; - if (workspaceStore.selectedWorkspaceDisplay().workspaceType !== "remote") return false; - return openworkServerStatus() === "connected" && Boolean(openworkServerClient() && runtimeWorkspaceId()); - }); - - const reloadWorkspaceEngineFromUi = async () => { - if (canReloadLocalEngine()) { - return workspaceStore.reloadWorkspaceEngine(); - } - - if (workspaceStore.selectedWorkspaceDisplay().workspaceType !== "remote") { - return false; - } - - const client = openworkServerClient(); - const workspaceId = runtimeWorkspaceId(); - if (!client || !workspaceId || openworkServerStatus() !== "connected") { - setError(t("app.error_connect_first")); - return false; - } - - try { - await client.reloadEngine(workspaceId); - await workspaceStore.activateWorkspace(workspaceStore.selectedWorkspaceId()); - await refreshMcpServers(); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : t("app.error_runtime_changes"); - setError(message); - return false; - } - }; - - const systemState = createSystemState({ - client, - sessions, - sessionStatusById, - refreshPlugins, - refreshSkills, - refreshMcpServers, - reloadWorkspaceEngine: reloadWorkspaceEngineFromUi, - canReloadWorkspaceEngine: () => canReloadWorkspace(), - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setError, - }); - - const { - reloadPending, - reloadCopy, - reloadTrigger, - reloadBusy, - reloadError, - reloadWorkspaceEngine, - clearReloadRequired, - cacheRepairBusy, - cacheRepairResult, - repairOpencodeCache, - dockerCleanupBusy, - dockerCleanupResult, - cleanupOpenworkDockerContainers, - updateAutoCheck, - setUpdateAutoCheck, - updateAutoDownload, - setUpdateAutoDownload, - updateStatus, - setUpdateStatus, - pendingUpdate, - updateEnv, - setUpdateEnv, - checkForUpdates, - downloadUpdate, - installUpdateAndRestart, - resetModalOpen, - setResetModalOpen, - resetModalMode, - resetModalText, - setResetModalText, - resetModalBusy, - openResetModal, - confirmReset, - anyActiveRuns, - } = systemState; - - markReloadRequiredHandler = systemState.markReloadRequired; - - const UPDATE_AUTO_CHECK_EVERY_MS = 12 * 60 * 60_000; - const UPDATE_AUTO_CHECK_POLL_MS = 60_000; - - const resetAppConfigDefaults = async () => { - try { - setThemeMode("system"); - setEngineSource(isTauriRuntime() ? "sidecar" : "path"); - setEngineCustomBinPath(""); - setEngineRuntime("openwork-orchestrator"); - modelConfig.resetAppDefaults(); - resetSessionDisplayPreferences(); - setHideTitlebar(false); - setUpdateAutoCheck(true); - setUpdateAutoDownload(false); - setUpdateStatus({ state: "idle", lastCheckedAt: null }); - setDeveloperMode(false); - - clearStartupPreference(); - setStartupPreference(null); - setRememberStartupChoice(false); - - resetOpenworkServerSettings(); - - return { ok: true, message: t("app.reset_config_ok") }; - } catch (error) { - const message = error instanceof Error ? error.message : t("app.error_reset_config"); - return { ok: false, message }; - } - }; - - const getUpdateLastCheckedAt = (state: ReturnType) => { - if (state.state === "checking") return null; - return state.lastCheckedAt ?? null; - }; - - const shouldAutoCheckForUpdates = () => { - const state = updateStatus(); - const lastCheckedAt = getUpdateLastCheckedAt(state); - if (!lastCheckedAt) return true; - return Date.now() - lastCheckedAt >= UPDATE_AUTO_CHECK_EVERY_MS; - }; - - const isActiveSessionStatus = (status: string | null | undefined) => - status === "running" || status === "retry"; - - const reloadRequired = (...sources: ReloadTrigger["type"][]) => { - if (!reloadPending()) return false; - const triggerType = reloadTrigger()?.type; - if (!triggerType) return false; - if (!sources.length) return true; - return sources.includes(triggerType); - }; - - const markOpencodeConfigReloadRequired = () => { - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); - }; - - const activeReloadBlockingSessions = createMemo(() => { - const statuses = sessionStatusById(); - return sessions() - .filter((session) => isActiveSessionStatus(statuses[session.id])) - .map((session) => ({ - id: session.id, - title: session.title?.trim() || session.slug?.trim() || session.id, - })); - }); - - const forceStopActiveSessionsAndReload = async () => { - const activeSessions = activeReloadBlockingSessions(); - for (const session of activeSessions) { - try { - await abortSession(session.id); - } catch { - // ignore and continue stopping the rest before reload - } - } - await reloadWorkspaceEngine(); - }; - - const { - projectDir: workspaceProjectDir, - stopHost, - } = workspaceStore; - - const schedulerPluginInstalled = createMemo(() => isPluginInstalledByName("opencode-scheduler")); - - const automationsStore = createAutomationsStore({ - selectedWorkspaceId: () => workspaceStore.selectedWorkspaceId(), - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), - runtimeWorkspaceId, - openworkServer: openworkServerStore, - schedulerPluginInstalled, - }); - - const { - scheduledJobsPollingAvailable, - refreshScheduledJobs, - } = automationsStore; - - createEffect(() => { - if (typeof window === "undefined") return; - if (currentView() !== "settings") return; - if (settingsTab() !== "automations") return; - if (!documentVisible()) return; - - const pollingAvailable = scheduledJobsPollingAvailable(); - const startedAt = Date.now(); - let active = true; - let failureCount = 0; - let timeoutId: number | undefined; - - const clearTimer = () => { - if (timeoutId == null) return; - window.clearTimeout(timeoutId); - timeoutId = undefined; - }; - - const nextDelayMs = () => { - const baseDelay = Date.now() - startedAt < 60_000 ? 5_000 : 15_000; - if (failureCount <= 0) return baseDelay; - return Math.min(baseDelay * 2 ** failureCount, 60_000); - }; - - const scheduleNext = () => { - clearTimer(); - if (!active || !pollingAvailable) return; - timeoutId = window.setTimeout(() => { - void run("poll"); - }, nextDelayMs()); - }; - - const run = async (_reason: "initial" | "focus" | "poll") => { - if (!active) return; - const result = await refreshScheduledJobs(); - if (!active) return; - - if (result === "error") { - failureCount += 1; - } else if (result === "success" || result === "unavailable") { - failureCount = 0; - } - - scheduleNext(); - }; - - const handleFocus = () => { - clearTimer(); - void run("focus"); - }; - - void run("initial"); - window.addEventListener("focus", handleFocus); - - onCleanup(() => { - active = false; - clearTimer(); - window.removeEventListener("focus", handleFocus); - }); - }); - - const selectedWorkspaceDisplay = createMemo(() => workspaceStore.selectedWorkspaceDisplay()); - const resolvedActiveWorkspaceConfig = createMemo( - () => activeWorkspaceServerConfig() ?? workspaceStore.workspaceConfig(), - ); - const activePermissionMemo = createMemo(() => activePermission()); - - const [expandedStepIds, setExpandedStepIds] = createSignal>( - new Set() - ); - const [autoConnectAttempted, setAutoConnectAttempted] = createSignal(false); - - const [appVersion, setAppVersion] = createSignal(null); - const [launchUpdateCheckTriggered, setLaunchUpdateCheckTriggered] = createSignal(false); - - const logAppUpdateLifecycle = (label: string, payload?: unknown) => { - try { - if (payload === undefined) { - console.log(`[APP-UPDATES] ${label}`); - } else { - console.log(`[APP-UPDATES] ${label}`, payload); - } - } catch { - // ignore - } - }; - - - const busySeconds = createMemo(() => { - const start = busyStartedAt(); - if (!start) return 0; - return Math.max(0, Math.round((Date.now() - start) / 1000)); - }); - - const newTaskDisabled = createMemo(() => { - if (!client()) { - return true; - } - - const label = busyLabel(); - // Allow creating a new session even while a run is in progress. - if (busy() && label === "status.running") return false; - - // Otherwise, block during engine / connection transitions. - if ( - busy() && - (label === "status.connecting" || - label === "status.starting_engine" || - label === "status.disconnecting") - ) { - return true; - } - - return busy(); - }); - - createEffect(() => { - if (isTauriRuntime()) return; - if (autoConnectAttempted()) return; - if (client()) return; - if (openworkServerStatus() !== "connected") return; - - const settings = openworkServerSettings(); - if (!settings.urlOverride || !settings.token) return; - - setAutoConnectAttempted(true); - void workspaceStore.onConnectClient(); - }); - - function openSettingsFromModelPicker() { - setSettingsTab("general"); - setView("settings"); - } - - - onMount(async () => { - const startupPref = readStartupPreference(); - if (startupPref) { - setRememberStartupChoice(true); - setStartupPreference(startupPref); - } - - const unsubscribeTheme = subscribeToSystemTheme((isDark) => { - if (themeMode() !== "system") return; - applyThemeMode(isDark ? "dark" : "light"); - }); - - onCleanup(() => { - unsubscribeTheme(); - }); - - createEffect(() => { - const next = themeMode(); - persistThemeMode(next); - applyThemeMode(next); - }); - - if (typeof window !== "undefined") { - try { - // In Tauri/desktop mode, do NOT restore the cached baseUrl from localStorage. - // OpenCode is assigned a random port on every restart, so the stored URL is - // always stale after a relaunch. The correct baseUrl is provided by engine_info(). - // Web mode still needs the cached value since it connects to a fixed server URL. - if (!isTauriRuntime()) { - const storedBaseUrl = window.localStorage.getItem("openwork.baseUrl"); - if (storedBaseUrl) { - setBaseUrl(storedBaseUrl); - } - } - - const storedClientDir = window.localStorage.getItem( - "openwork.clientDirectory" - ); - if (storedClientDir) { - setClientDirectory(storedClientDir); - } - - const storedEngineSource = window.localStorage.getItem( - "openwork.engineSource" - ); - const storedEngineCustomBinPath = window.localStorage.getItem( - "openwork.engineCustomBinPath" - ); - if (storedEngineCustomBinPath) { - setEngineCustomBinPath(storedEngineCustomBinPath); - } - if ( - storedEngineSource === "path" || - storedEngineSource === "sidecar" || - storedEngineSource === "custom" - ) { - if (storedEngineSource === "custom" && !(storedEngineCustomBinPath ?? "").trim()) { - setEngineSource(isTauriRuntime() ? "sidecar" : "path"); - } else { - setEngineSource(storedEngineSource); - } - } - - const storedEngineRuntime = window.localStorage.getItem( - "openwork.engineRuntime" - ); - if (storedEngineRuntime === "direct" || storedEngineRuntime === "openwork-orchestrator") { - setEngineRuntime(storedEngineRuntime); - } - - const storedOpencodeEnableExa = window.localStorage.getItem( - "openwork.opencodeEnableExa" - ); - if (storedOpencodeEnableExa === "0" || storedOpencodeEnableExa === "1") { - setOpencodeEnableExa(storedOpencodeEnableExa === "1"); - } - - const storedHideTitlebar = window.localStorage.getItem(HIDE_TITLEBAR_PREF_KEY); - if (storedHideTitlebar != null) { - try { - const parsed = JSON.parse(storedHideTitlebar); - if (typeof parsed === "boolean") { - setHideTitlebar(parsed); - } - } catch { - // ignore - } - } - - const storedUpdateAutoCheck = window.localStorage.getItem( - "openwork.updateAutoCheck" - ); - if (storedUpdateAutoCheck === "0" || storedUpdateAutoCheck === "1") { - setUpdateAutoCheck(storedUpdateAutoCheck === "1"); - } - - const storedUpdateAutoDownload = window.localStorage.getItem( - "openwork.updateAutoDownload" - ); - if (storedUpdateAutoDownload === "0" || storedUpdateAutoDownload === "1") { - const enabled = storedUpdateAutoDownload === "1"; - setUpdateAutoDownload(enabled); - if (enabled) { - setUpdateAutoCheck(true); - } - } - - const storedUpdateCheckedAt = window.localStorage.getItem( - "openwork.updateLastCheckedAt" - ); - if (storedUpdateCheckedAt) { - const parsed = Number(storedUpdateCheckedAt); - if (Number.isFinite(parsed) && parsed > 0) { - setUpdateStatus({ state: "idle", lastCheckedAt: parsed }); - } - } - - await refreshMcpServers(); - } catch { - // ignore - } - } - - if (isTauriRuntime()) { - try { - setAppVersion(await getVersion()); - } catch { - // ignore - } - - try { - setUpdateEnv(await updaterEnvironment()); - } catch { - // ignore - } - - if (!launchUpdateCheckTriggered() && denAuth.status() !== "checking") { - logAppUpdateLifecycle("mount-triggering-launch-update-check", { - denAuthStatus: denAuth.status(), - }); - setLaunchUpdateCheckTriggered(true); - checkForUpdates({ quiet: true }).catch(() => undefined); - } - } - - if (typeof window !== "undefined") { - const handleDeepLinkEvent = (event: Event) => { - const detail = (event as CustomEvent).detail; - deepLinks.consumeDeepLinks(detail?.urls ?? []); - }; - - deepLinks.consumeDeepLinks(drainPendingDeepLinks(window)); - window.addEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); - onCleanup(() => { - window.removeEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); - }); - } - - void workspaceStore.bootstrapOnboarding(); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (onboardingStep() !== "local") return; - void workspaceStore.refreshEngineDoctor(); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem("openwork.baseUrl", baseUrl()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.clientDirectory", - clientDirectory() - ); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - // Legacy key: keep for backwards compatibility. - try { - window.localStorage.setItem("openwork.projectDir", workspaceProjectDir()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem("openwork.engineSource", engineSource()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - const value = engineCustomBinPath().trim(); - if (value) { - window.localStorage.setItem("openwork.engineCustomBinPath", value); - } else { - window.localStorage.removeItem("openwork.engineCustomBinPath"); - } - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem("openwork.engineRuntime", engineRuntime()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.opencodeEnableExa", - opencodeEnableExa() ? "1" : "0" - ); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.updateAutoCheck", - updateAutoCheck() ? "1" : "0" - ); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.updateAutoDownload", - updateAutoDownload() ? "1" : "0" - ); - } catch { - // ignore - } - }); - - // Persist and apply hideTitlebar setting - createEffect(() => { - if (typeof window === "undefined") return; - const hide = hideTitlebar(); - try { - window.localStorage.setItem(HIDE_TITLEBAR_PREF_KEY, JSON.stringify(hide)); - } catch { - // ignore - } - // Apply to window decorations (only in Tauri desktop environment) - if (isTauriRuntime()) { - setWindowDecorations(!hide).catch(() => { - // ignore errors (e.g., window not ready) - }); - } - }); - - createEffect(() => { - const state = updateStatus(); - if (typeof window === "undefined") return; - if (state.state === "idle" && state.lastCheckedAt) { - try { - window.localStorage.setItem( - "openwork.updateLastCheckedAt", - String(state.lastCheckedAt) - ); - } catch { - // ignore - } - } - }); - - createEffect(() => { - logAppUpdateLifecycle("den-auth-status", { - status: denAuth.status(), - isSignedIn: denAuth.isSignedIn(), - }); - }); - - createEffect(() => { - if (booting()) return; - if (!isTauriRuntime()) return; - if (launchUpdateCheckTriggered()) { - logAppUpdateLifecycle("launch-update-check-skipped-already-triggered"); - return; - } - if (denAuth.status() === "checking") { - logAppUpdateLifecycle("launch-update-check-waiting-for-den-auth", { - denAuthStatus: denAuth.status(), - }); - return; - } - - const state = updateStatus(); - if (state.state === "checking" || state.state === "downloading") return; - - logAppUpdateLifecycle("effect-triggering-launch-update-check", { - denAuthStatus: denAuth.status(), - updateState: state.state, - }); - setLaunchUpdateCheckTriggered(true); - checkForUpdates({ quiet: true }).catch(() => undefined); - }); - - createEffect(() => { - if (booting()) return; - if (typeof window === "undefined") return; - if (!isTauriRuntime()) return; - if (!launchUpdateCheckTriggered()) return; - if (!updateAutoCheck()) return; - - const maybeRunAutoUpdateCheck = () => { - if (!updateAutoCheck()) return; - const state = updateStatus(); - if (state.state === "checking" || state.state === "downloading") return; - if (!shouldAutoCheckForUpdates()) return; - checkForUpdates({ quiet: true }).catch(() => undefined); - }; - - const interval = window.setInterval(maybeRunAutoUpdateCheck, UPDATE_AUTO_CHECK_POLL_MS); - onCleanup(() => window.clearInterval(interval)); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!updateAutoDownload()) return; - - const state = updateStatus(); - if (state.state !== "available") return; - if (!pendingUpdate()) return; - - downloadUpdate().catch(() => undefined); - }); - - const headerConnectedVersion = createMemo(() => { - const fallbackVersion = connectedVersion()?.trim() ?? ""; - if (!developerMode()) { - return fallbackVersion || null; - } - - const openworkVersion = - appVersion()?.trim() || - openworkServerDiagnostics()?.version?.trim() || - ""; - if (!openworkVersion) { - return fallbackVersion || null; - } - - const normalizedVersion = openworkVersion.startsWith("v") - ? openworkVersion - : `v${openworkVersion}`; - return `OpenWork ${normalizedVersion}`; - }); - - const headerStatus = createMemo(() => { - if (!client() || !headerConnectedVersion()) return t("status.disconnected", currentLocale()); - const bits = [`${t("status.connected", currentLocale())} · ${headerConnectedVersion()}`]; - if (sseConnected()) bits.push(t("status.live", currentLocale())); - return bits.join(" · "); - }); - - const busyHint = createMemo(() => { - if (!busy() || !busyLabel()) return null; - const seconds = busySeconds(); - const label = t(busyLabel()!, currentLocale()); - return seconds > 0 ? `${label} · ${seconds}s` : label; - }); - - const modelControlsStore = createModelControlsStore({ - selectedSessionModelLabel, - openSessionModelPicker, - sessionModelVariantLabel, - sessionModelVariant: modelVariant, - sessionModelBehaviorOptions, - setSessionModelVariant, - defaultModelLabel, - defaultModelRef, - openDefaultModelPicker, - autoCompactContext, - toggleAutoCompactContext, - autoCompactContextBusy: autoCompactContextSaving, - defaultModelVariantLabel, - editDefaultModelVariant: openDefaultModelPicker, - }); - - const settingsShellProps = () => { - const workspaceType = selectedWorkspaceDisplay().workspaceType; - const isRemoteWorkspace = workspaceType === "remote"; - const openworkStatus = openworkServerStatus(); - const canUseDesktopTools = isTauriRuntime() && !isRemoteWorkspace; - const canInstallSkillCreator = isRemoteWorkspace - ? openworkServerCanWriteSkills() - : isTauriRuntime(); - const canEditPlugins = isRemoteWorkspace - ? openworkServerCanWritePlugins() - : isTauriRuntime(); - const canUseGlobalPluginScope = !isRemoteWorkspace && isTauriRuntime(); - const skillsAccessHint = isRemoteWorkspace - ? openworkStatus === "disconnected" - ? t("app.skills_hint_disconnected") - : openworkStatus === "limited" - ? t("app.skills_hint_limited") - : openworkServerCanWriteSkills() - ? null - : t("app.skills_hint_readonly") - : null; - const pluginsAccessHint = isRemoteWorkspace - ? openworkStatus === "disconnected" - ? t("app.plugins_hint_disconnected") - : openworkStatus === "limited" - ? t("app.plugins_hint_limited") - : openworkServerCanWritePlugins() - ? null - : t("app.plugins_hint_readonly") - : null; - - return { - settingsTab: settingsTab(), - setSettingsTab, - providers: visibleProviders(), - providerConnectedIds: visibleProviderConnectedIds(), - providerAuthBusy: providerAuthBusy(), - providerAuthModalOpen: providerAuthModalOpen(), - providerAuthError: providerAuthError(), - providerAuthMethods: providerAuthMethods(), - providerAuthProviders: providerAuthProviders(), - providerAuthPreferredProviderId: providerAuthPreferredProviderId(), - providerAuthWorkerType: providerAuthWorkerType(), - cloudOrgProviders: cloudOrgProviders(), - importedCloudProviders: importedCloudProviders(), - openProviderAuthModal, - disconnectProvider, - removeCloudProvider, - runCloudProviderSync, - closeProviderAuthModal, - startProviderAuth, - completeProviderAuthOAuth, - refreshProviders, - refreshCloudOrgProviders, - submitProviderApiKey, - connectCloudProvider, - setView, - toggleSettings: () => toggleSettingsView("general"), - startupPreference: startupPreference(), - baseUrl: baseUrl(), - clientConnected: Boolean(client()), - busy: busy(), - busyHint: busyHint(), - newTaskDisabled: newTaskDisabled(), - headerStatus: headerStatus(), - error: error(), - openworkServerStatus: openworkStatus, - openworkServerUrl: openworkServerUrl(), - openworkServerClient: openworkServerClient(), - openworkReconnectBusy: openworkReconnectBusy(), - reconnectOpenworkServer, - openworkServerSettings: openworkServerSettings(), - openworkServerHostInfo: openworkServerHostInfo(), - shareRemoteAccessBusy: shareRemoteAccessBusy(), - shareRemoteAccessError: shareRemoteAccessError(), - saveShareRemoteAccess, - openworkServerCapabilities: openworkServerCapabilities(), - openworkServerDiagnostics: openworkServerDiagnostics(), - runtimeWorkspaceId: runtimeWorkspaceId(), - activeWorkspaceType: workspaceStore.selectedWorkspaceDisplay().workspaceType, - openworkAuditEntries: openworkAuditEntries(), - openworkAuditStatus: openworkAuditStatus(), - openworkAuditError: openworkAuditError(), - opencodeConnectStatus: opencodeConnectStatus(), - engineInfo: workspaceStore.engine(), - orchestratorStatus: orchestratorStatusState(), - opencodeRouterInfo: opencodeRouterInfoState(), - engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null, - updateOpenworkServerSettings, - resetOpenworkServerSettings, - testOpenworkServerConnection, - canReloadWorkspace: canReloadWorkspace(), - reloadWorkspaceEngine, - reloadBusy: reloadBusy(), - reloadError: reloadError(), - selectedWorkspaceDisplay: selectedWorkspaceDisplay(), - workspaces: workspaceStore.workspaces(), - selectedWorkspaceId: workspaceStore.selectedWorkspaceId(), - connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), - workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(), - selectWorkspace: workspaceStore.selectWorkspace, - switchWorkspace: workspaceStore.switchWorkspace, - testWorkspaceConnection: workspaceStore.testWorkspaceConnection, - recoverWorkspace: workspaceStore.recoverWorkspace, - openCreateWorkspace, - connectRemoteWorkspace: workspaceStore.createRemoteWorkspaceFlow, - openTeamBundle: bundlesStore.openTeamBundle, - exportWorkspaceConfig: workspaceStore.exportWorkspaceConfig, - exportWorkspaceBusy: workspaceStore.exportingWorkspaceConfig(), - createWorkspaceOpen: workspaceStore.createWorkspaceOpen(), - setCreateWorkspaceOpen: workspaceStore.setCreateWorkspaceOpen, - createWorkspaceFlow: workspaceStore.createWorkspaceFlow, - pickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, - workspaceSessionGroups: hydratedSidebarWorkspaceGroups(), - selectedSessionId: activeSessionId(), - openRenameWorkspace: workspaceStore.openRenameWorkspace, - editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings, - forgetWorkspace: workspaceStore.forgetWorkspace, - schedulerPluginInstalled: schedulerPluginInstalled(), - selectedWorkspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), - skillsAccessHint, - canInstallSkillCreator, - canUseDesktopTools, - pluginsAccessHint, - canEditPlugins, - canUseGlobalPluginScope, - suggestedPlugins: SUGGESTED_PLUGINS, - addPlugin, - createSessionInWorkspace, - createSessionAndOpen, - hideTitlebar: hideTitlebar(), - toggleHideTitlebar: () => setHideTitlebar((v) => !v), - updateAutoCheck: updateAutoCheck(), - toggleUpdateAutoCheck: () => setUpdateAutoCheck((v) => !v), - updateAutoDownload: updateAutoDownload(), - toggleUpdateAutoDownload: () => - setUpdateAutoDownload((v) => { - const next = !v; - if (next) { - setUpdateAutoCheck(true); - } - return next; - }), - updateStatus: updateStatus(), - updateEnv: updateEnv(), - appVersion: appVersion(), - checkForUpdates: () => checkForUpdates(), - downloadUpdate: () => downloadUpdate(), - installUpdateAndRestart, - anyActiveRuns: anyActiveRuns(), - engineSource: engineSource(), - setEngineSource, - engineCustomBinPath: engineCustomBinPath(), - setEngineCustomBinPath, - engineRuntime: engineRuntime(), - setEngineRuntime, - opencodeEnableExa: opencodeEnableExa(), - toggleOpencodeEnableExa: () => setOpencodeEnableExa((v) => !v), - isWindows: isWindowsPlatform(), - toggleDeveloperMode: () => setDeveloperMode((v) => !v), - developerMode: developerMode(), - stopHost, - restartLocalServer, - openResetModal, - resetModalBusy: resetModalBusy(), - onResetStartupPreference: () => { - clearStartupPreference(); - setStartupPreference(null); - setRememberStartupChoice(false); - }, - themeMode: themeMode(), - setThemeMode, - pendingPermissions: pendingPermissions(), - events: events(), - workspaceDebugEvents: workspaceStore.workspaceDebugEvents(), - sandboxCreateProgress: workspaceStore.sandboxCreateProgress(), - sandboxCreateProgressLast: workspaceStore.lastSandboxCreateProgress(), - clearWorkspaceDebugEvents: workspaceStore.clearWorkspaceDebugEvents, - safeStringify, - repairOpencodeCache, - cacheRepairBusy: cacheRepairBusy(), - cacheRepairResult: cacheRepairResult(), - cleanupOpenworkDockerContainers, - dockerCleanupBusy: dockerCleanupBusy(), - dockerCleanupResult: dockerCleanupResult(), - markOpencodeConfigReloadRequired, - resetAppConfigDefaults, - openDebugDeepLink: deepLinks.openDebugDeepLink, - language: currentLocale(), - setLanguage: setLocale, - }; - }; - - const sessionProps = () => ({ - providerAuthWorkerType: providerAuthWorkerType(), - selectedSessionId: activeSessionId(), - setView, - setSettingsTab, - toggleSettings: () => toggleSettingsView("general"), - selectedWorkspaceDisplay: selectedWorkspaceDisplay(), - selectedWorkspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), - activeWorkspaceConfig: resolvedActiveWorkspaceConfig(), - workspaces: workspaceStore.workspaces(), - selectedWorkspaceId: workspaceStore.selectedWorkspaceId(), - connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), - workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(), - selectWorkspace: workspaceStore.selectWorkspace, - switchWorkspace: workspaceStore.switchWorkspace, - testWorkspaceConnection: workspaceStore.testWorkspaceConnection, - recoverWorkspace: workspaceStore.recoverWorkspace, - editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings, - forgetWorkspace: workspaceStore.forgetWorkspace, - openCreateWorkspace, - exportWorkspaceConfig: workspaceStore.exportWorkspaceConfig, - exportWorkspaceBusy: workspaceStore.exportingWorkspaceConfig(), - clientConnected: Boolean(client()), - openworkServerStatus: openworkServerStatus(), - openworkServerClient: openworkServerClient(), - openworkServerDiagnostics: openworkServerDiagnostics(), - openworkServerSettings: openworkServerSettings(), - openworkServerHostInfo: openworkServerHostInfo(), - shareRemoteAccessBusy: shareRemoteAccessBusy(), - shareRemoteAccessError: shareRemoteAccessError(), - saveShareRemoteAccess, - runtimeWorkspaceId: runtimeWorkspaceId(), - engineInfo: workspaceStore.engine(), - engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null, - orchestratorStatus: orchestratorStatusState(), - opencodeRouterInfo: opencodeRouterInfoState(), - appVersion: appVersion(), - booting: booting(), - startupPhase: bootPhase(), - startupBranch: startupBranch(), - startupTrace: startupTrace(), - headerStatus: headerStatus(), - busyHint: busyHint(), - updateStatus: updateStatus(), - anyActiveRuns: anyActiveRuns(), - installUpdateAndRestart, - skills: skills(), - newTaskDisabled: newTaskDisabled(), - sidebarHydratedFromCache: sidebarHydratedFromCache(), - workspaceSessionGroups: hydratedSidebarWorkspaceGroups(), - openRenameWorkspace: workspaceStore.openRenameWorkspace, - messages: visibleMessages(), - getSessionById: sessionById, - getMessagesBySessionId: messagesBySessionId, - ensureSessionLoaded, - sessionLoadingById, - todos: activeTodos(), - busyLabel: busyLabel(), - developerMode: developerMode(), - sessionCompactionState: selectedSessionCompactionState(), - expandedStepIds: expandedStepIds(), - setExpandedStepIds: setExpandedStepIds, - workingFiles: activeWorkingFiles(), - busy: busy(), - prompt: prompt(), - setPrompt: setPrompt, - activePermission: activePermissionMemo(), - permissionReplyBusy: permissionReplyBusy(), - respondPermission: respondPermission, - respondPermissionAndRemember: respondPermissionAndRemember, - activeQuestion: activeQuestion(), - questionReplyBusy: questionReplyBusy(), - respondQuestion: respondQuestion, - safeStringify: safeStringify, - startProviderAuth: startProviderAuth, - completeProviderAuthOAuth: completeProviderAuthOAuth, - refreshProviders: refreshProviders, - submitProviderApiKey: submitProviderApiKey, - connectCloudProvider: connectCloudProvider, - openProviderAuthModal: openProviderAuthModal, - closeProviderAuthModal: closeProviderAuthModal, - providerAuthModalOpen: providerAuthModalOpen(), - providerAuthBusy: providerAuthBusy(), - providerAuthError: providerAuthError(), - providerAuthMethods: providerAuthMethods(), - providerAuthProviders: providerAuthProviders(), - providerAuthPreferredProviderId: providerAuthPreferredProviderId(), - providers: visibleProviders(), - providerConnectedIds: visibleProviderConnectedIds(), - sessionStatusById: activeSessionStatusById(), - hasEarlierMessages: selectedSessionHasEarlierMessages(), - loadingEarlierMessages: selectedSessionLoadingEarlierMessages(), - loadEarlierMessages, - sessionErrorTurns: selectedSessionErrorTurns(), - sessionStatus: selectedSessionStatus(), - error: error(), - }); - - const reactSessionRuntimeEnabled = createMemo(() => reactSessionEnabled()); - const reactSessionRuntimeBaseUrl = createMemo(() => { - const workspaceId = runtimeWorkspaceId()?.trim() ?? ""; - const baseUrl = openworkServerClient()?.baseUrl?.trim() ?? ""; - if (!workspaceId || !baseUrl) return ""; - const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl; - return `${mounted.replace(/\/+$/, "")}/opencode`; - }); - const reactSessionRuntimeToken = createMemo( - () => openworkServerClient()?.token?.trim() || openworkServerSettings().token?.trim() || "", - ); - const showReactSessionRuntime = createMemo( - () => - reactSessionRuntimeEnabled() && - openworkServerStatus() === "connected" && - Boolean(runtimeWorkspaceId()?.trim() && reactSessionRuntimeBaseUrl() && reactSessionRuntimeToken()), - ); - - const settingsTabs = new Set([ - "general", - "den", - "automations", - "skills", - "extensions", - "messaging", - "advanced", - "appearance", - "updates", - "recovery", - "debug", - ]); - - const resolveSettingsTab = (value?: string | null) => { - const normalized = value?.trim().toLowerCase() ?? ""; - if (settingsTabs.has(normalized as SettingsTab)) { - return normalized as SettingsTab; - } - return "general"; - }; - - createEffect(() => { - const rawPath = location.pathname.trim(); - const path = rawPath.toLowerCase(); - - if (forceSigninEnabled()) { - if (denAuth.status() === "checking") { - return; - } - - if (!denAuth.isSignedIn()) { - if (path !== "/signin") { - navigate("/signin", { replace: true }); - } - return; - } - - if (path === "/signin") { - navigate("/session", { replace: true }); - return; - } - } else if (path === "/signin") { - navigate("/session", { replace: true }); - return; - } - - if (path === "" || path === "/") { - navigate("/session", { replace: true }); - return; - } - - if (path.startsWith("/settings")) { - const [, , tabSegment] = path.split("/"); - const resolvedTab = resolveSettingsTab(tabSegment); - - if (resolvedTab !== settingsTab()) { - setSettingsTabState(resolvedTab); - } - if (!tabSegment || tabSegment !== resolvedTab) { - goToSettings(resolvedTab, { replace: true }); - } - return; - } - - if (path.startsWith("/session")) { - const [, , sessionSegment] = rawPath.split("/"); - const id = (sessionSegment ?? "").trim(); - - if (!id) { - if (selectedSessionId()) { - workspaceStore.clearSelectedSessionSurface(); - } - return; - } - - // If the URL points at a session that no longer exists (e.g. after deletion), - // route back to /session so the app can fall back safely. - const pendingInitialSelection = pendingInitialSessionSelection(); - const selectedWorkspaceRoot = normalizeDirectoryPath(workspaceStore.selectedWorkspaceRoot().trim()); - const matchingSession = sessions().find((session) => session.id === id) ?? null; - const hasMatchingSessionInScope = matchingSession - ? !selectedWorkspaceRoot || normalizeDirectoryPath(matchingSession.directory) === selectedWorkspaceRoot - : false; - if ( - sessionsLoaded() && - !pendingInitialSelection && - shouldRedirectMissingSessionAfterScopedLoad({ - loadedScopeRoot: loadedSessionScopeRoot(), - workspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), - hasMatchingSession: hasMatchingSessionInScope, - }) - ) { - if (selectedSessionId() === id) { - setSelectedSessionId(null); - } - navigate("/session", { replace: true }); - return; - } - - if (selectedSessionId() !== id) { - setSelectedSessionId(id); - void selectSession(id); - } - return; - } - - const fallback = activeSessionId(); - if (fallback) { - goToSession(fallback, { replace: true }); - return; - } - navigate("/session", { replace: true }); - }); - - return ( - - - - - - - - - - - {null} - - - - - - - - - - - - - - setResetModalOpen(false)} - onConfirm={confirmReset} - onTextChange={setResetModalText} - /> - - 0} - activeSessions={activeReloadBlockingSessions()} - isRemoteWorkspace={selectedWorkspaceDisplay().workspaceType === "remote"} - onForceStopSession={(sessionID) => abortSession(sessionID)} - onReloadEngine={() => reloadWorkspaceEngine()} - /> - - { - void bundlesStore.openCreateWorkspaceFromChoice(); - }} - onSelectWorker={(workspaceId) => { - void bundlesStore.importBundleIntoExistingWorkspace(workspaceId); - }} - /> - - { - const warning = bundlesStore.untrustedBundleWarning(); - const actualOrigin = warning?.actualOrigin?.trim() || "an unknown origin"; - const configuredOrigin = warning?.configuredOrigin?.trim() || "the configured OpenWork share service"; - return `This link points to ${actualOrigin}, but OpenWork only auto-imports bundles from ${configuredOrigin}. Untrusted bundles can contain malicious instructions or settings. Only continue if you trust the sender and expect this import.`; - })()} - confirmLabel="Import anyway" - cancelLabel="Cancel" - variant="warning" - onConfirm={() => { - void bundlesStore.confirmUntrustedBundleWarning(); - }} - onCancel={bundlesStore.dismissUntrustedBundleWarning} - /> - - { - bundlesStore.clearBundleStartRequest(); - }} - onPickFolder={workspaceStore.pickWorkspaceFolder} - onConfirm={(folder) => { - void bundlesStore.startWorkspaceFromBundle(folder); - }} - /> - - { - workspaceStore.setCreateWorkspaceOpen(false); - workspaceStore.clearSandboxCreateProgress?.(); - bundlesStore.clearCreateWorkspaceRequest(); - }} - onPickFolder={workspaceStore.pickWorkspaceFolder} - onImportConfig={isTauriRuntime() ? workspaceStore.importWorkspaceConfig : undefined} - importingConfig={workspaceStore.importingWorkspaceConfig()} - defaultPreset={bundlesStore.createWorkspaceDefaultPreset()} - onConfirmRemote={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} - onConfirmTemplate={(template, preset, folder) => - bundlesStore.startWorkspaceFromTeamTemplate({ - name: template.name, - templateData: template.templateData, - folder, - preset, - }) - } - onConfirm={bundlesStore.handleCreateWorkspaceConfirm} - onConfirmWorker={ - isTauriRuntime() - ? bundlesStore.handleCreateSandboxConfirm - : undefined - } - workerDisabled={(() => { - if (!isTauriRuntime()) return true; - if (workspaceStore.sandboxDoctorBusy?.()) return true; - const doctor = workspaceStore.sandboxDoctorResult?.(); - if (!doctor) return false; - return !doctor?.ready; - })()} - workerDisabledReason={(() => { - if (!isTauriRuntime()) return t("app.error.tauri_required", currentLocale()); - if (workspaceStore.sandboxDoctorBusy?.()) { - return t("dashboard.sandbox_checking_docker", currentLocale()); - } - const doctor = workspaceStore.sandboxDoctorResult?.(); - if (!doctor || doctor.ready) return null; - const message = doctor?.error?.trim(); - return message || t("dashboard.sandbox_get_ready_desc", currentLocale()); - })()} - workerCtaLabel={t("dashboard.sandbox_get_ready_action", currentLocale())} - workerCtaDescription={t("dashboard.sandbox_get_ready_desc", currentLocale())} - onWorkerCta={async () => { - const url = "https://www.docker.com/products/docker-desktop/"; - if (isTauriRuntime()) { - const { openUrl } = await import("@tauri-apps/plugin-opener"); - await openUrl(url); - } else { - window.open(url, "_blank", "noopener,noreferrer"); - } - }} - workerRetryLabel={t("common.retry", currentLocale())} - workerDebugLines={(() => { - const doctor = workspaceStore.sandboxDoctorResult?.(); - const lines: string[] = []; - if (!doctor?.debug) return lines; - const selected = doctor.debug.selectedBin?.trim(); - if (selected) lines.push(`selected: ${selected}`); - if (doctor.debug.candidates?.length) { - lines.push(`candidates: ${doctor.debug.candidates.join(", ")}`); - } - if (doctor.debug.versionCommand) { - const cmd = doctor.debug.versionCommand; - lines.push(`docker --version exit=${cmd.status}`); - if (cmd.stderr?.trim()) lines.push(`docker --version stderr: ${cmd.stderr.trim()}`); - } - if (doctor.debug.infoCommand) { - const cmd = doctor.debug.infoCommand; - lines.push(`docker info exit=${cmd.status}`); - if (cmd.stderr?.trim()) lines.push(`docker info stderr: ${cmd.stderr.trim()}`); - } - return lines; - })()} - onWorkerRetry={() => { - void workspaceStore.refreshSandboxDoctor?.(); - }} - workerSubmitting={workspaceStore.sandboxPreflightBusy?.() ?? false} - localDisabled={!isTauriRuntime()} - localDisabledReason={ - !isTauriRuntime() - ? t("app.local_disabled_reason") - : null - } - remoteSubmitting={busy() && busyLabel() === "status.connecting"} - remoteError={busyLabel() === "status.connecting" ? error() : null} - submitting={(() => { - const phase = workspaceStore.sandboxCreatePhase?.() ?? "idle"; - if (phase === "provisioning" || phase === "finalizing") return true; - return busy() && busyLabel() === "status.creating_workspace"; - })()} - submittingProgress={workspaceStore.sandboxCreateProgress?.() ?? null} - /> - - setRestrictionNotice(null)} - title={restrictionNotice()?.title ?? "Restriction"} - message={restrictionNotice()?.message ?? ""} - /> - - { - const request = bundlesStore.skillDestinationRequest(); - if (!request) return null; - return { - name: request.bundle.name, - description: request.bundle.description ?? null, - trigger: request.bundle.trigger ?? null, - }; - })()} - workspaces={bundlesStore.skillDestinationWorkspaces()} - selectedWorkspaceId={workspaceStore.selectedWorkspaceId()} - busyWorkspaceId={bundlesStore.skillDestinationBusyId()} - onClose={() => { - bundlesStore.clearSkillDestinationRequest(); - }} - onSubmitWorkspace={bundlesStore.importSkillIntoWorkspace} - onCreateWorker={ - isTauriRuntime() - ? bundlesStore.openCreateWorkspaceFromSkillDestination - : undefined - } - onConnectRemote={() => { - bundlesStore.openRemoteConnectFromSkillDestination(); - }} - /> - - { - workspaceStore.setCreateRemoteWorkspaceOpen(false); - deepLinks.clearDeepLinkRemoteWorkspaceDefaults(); - }} - onConfirm={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} - initialValues={deepLinks.deepLinkRemoteWorkspaceDefaults() ?? undefined} - submitting={ - busy() && - (busyLabel() === "status.creating_workspace" || busyLabel() === "status.connecting") - } - /> - - 0 ? t("app.reload_stop_tasks") : t("app.reload_now")} - dismissLabel={t("app.reload_later")} - reloadBusy={reloadBusy()} - canReload={canReloadWorkspace()} - hasActiveRuns={activeReloadBlockingSessions().length > 0} - onReload={() => { - void (activeReloadBlockingSessions().length > 0 - ? forceStopActiveSessionsAndReload() - : reloadWorkspaceEngine()); - }} - onDismissReload={clearReloadRequired} - /> - - 0 && !workspaceStore.renameWorkspaceBusy()} - onClose={workspaceStore.closeRenameWorkspace} - onSave={workspaceStore.saveRenameWorkspace} - onTitleChange={workspaceStore.setRenameWorkspaceName} - /> - - { - void workspaceStore.saveWorkspaceConnectionSettings(input); - }} - initialValues={workspaceStore.editRemoteWorkspaceDefaults() ?? undefined} - submitting={busy() && busyLabel() === "status.connecting"} - error={workspaceStore.editRemoteWorkspaceError()} - title={t("dashboard.edit_remote_workspace_title", currentLocale())} - subtitle={t("dashboard.edit_remote_workspace_subtitle", currentLocale())} - confirmLabel={t("dashboard.edit_remote_workspace_confirm", currentLocale())} - /> - - - - - - - - ); -} diff --git a/apps/app/src/app/automations/provider.tsx b/apps/app/src/app/automations/provider.tsx deleted file mode 100644 index e59cf719..00000000 --- a/apps/app/src/app/automations/provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { AutomationsStore } from "../context/automations"; - -const AutomationsContext = createContext(); - -export function AutomationsProvider(props: ParentProps<{ store: AutomationsStore }>) { - return ( - - {props.children} - - ); -} - -export function useAutomations() { - const context = useContext(AutomationsContext); - if (!context) { - throw new Error("useAutomations must be used within an AutomationsProvider"); - } - return context; -} diff --git a/apps/app/src/app/bundles/import-modal.tsx b/apps/app/src/app/bundles/import-modal.tsx deleted file mode 100644 index 77bbce09..00000000 --- a/apps/app/src/app/bundles/import-modal.tsx +++ /dev/null @@ -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 ( - -
-
-
-
-
-
- -
-
-

{props.title}

-

{props.description}

-
-
- -
- - 0}> -
- - {(item) => ( - - {item} - - )} - - 0}> - - +{hiddenItemCount()} more - - -
-
-
- -
- -
{props.error}
-
- - - -
- - - -
- 0} - fallback={
No configured workers are available yet. Create a new worker to import this bundle.
} - > - - {(worker) => { - const disabledReason = () => worker.disabledReason?.trim() ?? ""; - const disabled = () => Boolean(disabledReason()) || busy(); - return ( - - ); - }} - -
-
-
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/bundles/index.ts b/apps/app/src/app/bundles/index.ts index 51b7b054..002cd2ab 100644 --- a/apps/app/src/app/bundles/index.ts +++ b/apps/app/src/app/bundles/index.ts @@ -2,5 +2,4 @@ export * from "./apply"; export * from "./publish"; export * from "./schema"; export * from "./sources"; -export * from "./store"; export * from "./types"; diff --git a/apps/app/src/app/bundles/skill-destination-modal.tsx b/apps/app/src/app/bundles/skill-destination-modal.tsx deleted file mode 100644 index 897d7c14..00000000 --- a/apps/app/src/app/bundles/skill-destination-modal.tsx +++ /dev/null @@ -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; - onCreateWorker?: () => void; - onConnectRemote?: () => void; -}) { - const translate = (key: string) => t(key, currentLocale()); - const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal(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 ( - -
-
-
-
-
-
- - {translate("share_skill_destination.skill_label")} -
-
-
-
- -
-
-
- {translate("share_skill_destination.skill_label")} -
-

- {props.skill?.name ?? translate("share_skill_destination.fallback_skill_name")} -

- -

{props.skill?.description?.trim()}

-
- -
- {translate("share_skill_destination.trigger_label")} - {props.skill?.trigger?.trim()} -
-
-
-
-
-
-

{translate("share_skill_destination.title")}

-

{translate("share_skill_destination.subtitle")}

-
-
- - -
-
- -
-
-
-
{translate("share_skill_destination.existing_workers")}
- 0}> - {props.workspaces.length} - -
- - 0} - fallback={ -
- {translate("share_skill_destination.no_workers")} -
- } - > -
- - {(workspace) => { - const isActive = () => workspace.id === props.selectedWorkspaceId; - const isSelected = () => workspace.id === selectedWorkspaceId(); - const isBusy = () => workspace.id === props.busyWorkspaceId; - const WorkspaceIcon = () => (workspace.workspaceType === "remote" ? : ); - - return ( - - ); - }} - -
-
-
- - -
-
{translate("share_skill_destination.more_options")}
-
- - - - - - - -
-
-
-
- -
-
- - {(workspace) => ( -
- {displayName(workspace())} - · - {subtitle(workspace())} -
- )} -
- -
- - -
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/bundles/start-modal.tsx b/apps/app/src/app/bundles/start-modal.tsx deleted file mode 100644 index fdf9aced..00000000 --- a/apps/app/src/app/bundles/start-modal.tsx +++ /dev/null @@ -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; - onConfirm: (folder: string | null) => void | Promise; -}) { - let pickFolderRef: HTMLButtonElement | undefined; - const [selectedFolder, setSelectedFolder] = createSignal(null); - const [pickingFolder, setPickingFolder] = createSignal(false); - - createEffect(() => { - if (!props.open) return; - setSelectedFolder(null); - requestAnimationFrame(() => pickFolderRef?.focus()); - }); - - createEffect(() => { - if (!props.open) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - if (props.busy) return; - props.onClose(); - }; - window.addEventListener("keydown", handleKeyDown); - onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); - }); - - const visibleItems = createMemo(() => (props.items ?? []).filter(Boolean).slice(0, 4)); - const hiddenItemCount = createMemo(() => Math.max(0, (props.items ?? []).filter(Boolean).length - visibleItems().length)); - const canSubmit = createMemo(() => Boolean(selectedFolder()?.trim()) && !props.busy && !pickingFolder()); - - const handlePickFolder = async () => { - if (pickingFolder() || props.busy) return; - setPickingFolder(true); - try { - const next = await props.onPickFolder(); - if (next) setSelectedFolder(next); - } finally { - setPickingFolder(false); - } - }; - - return ( - -
-
-
-
-
-
- -
-
-

Start with {props.templateName}

-

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

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

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

-
- No folder selected yet.}> - {selectedFolder()} - -
-
- -
-
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/bundles/store.ts b/apps/app/src/app/bundles/store.ts deleted file mode 100644 index 4b26324e..00000000 --- a/apps/app/src/app/bundles/store.ts +++ /dev/null @@ -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; - -export function createBundlesStore(options: { - booting: Accessor; - startupPreference: Accessor; - openworkServer: OpenworkServerStore; - runtimeWorkspaceId: Accessor; - workspaceStore: WorkspaceStore; - setError: (value: string | null) => void; - error: Accessor; - setView: (next: View, sessionId?: string) => void; - setSettingsTab: (nextTab: SettingsTab) => void; - refreshActiveWorkspaceServerConfig: (workspaceId: string) => Promise; - refreshSkills: (input?: { force?: boolean }) => Promise; - refreshHubSkills: (input?: { force?: boolean }) => Promise; - markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void; - showStatusToast: (toast: AppStatusToastInput) => void; -}) { - const [pendingBundleRequest, setPendingBundleRequest] = createSignal(null); - const [bundleStartRequest, setBundleStartRequest] = createSignal(null); - const [bundleStartBusy, setBundleStartBusy] = createSignal(false); - const [createWorkspaceRequest, setCreateWorkspaceRequest] = createSignal(null); - const [skillDestinationRequest, setSkillDestinationRequest] = createSignal(null); - const [skillDestinationBusyId, setSkillDestinationBusyId] = createSignal(null); - const [bundleImportChoice, setBundleImportChoice] = createSignal(null); - const [bundleImportBusy, setBundleImportBusy] = createSignal(false); - const [bundleImportError, setBundleImportError] = createSignal(null); - const [bundleNoticeShown, setBundleNoticeShown] = createSignal(false); - const [untrustedBundleWarning, setUntrustedBundleWarning] = createSignal(null); - - const showSkillSuccessToast = (toast: { title: string; description: string }) => { - options.showStatusToast({ - ...toast, - tone: "success", - durationMs: 4200, - }); - }; - - const resetInteractiveBundleState = () => { - setSkillDestinationRequest(null); - setSkillDestinationBusyId(null); - setBundleImportChoice(null); - setBundleStartRequest(null); - setCreateWorkspaceRequest(null); - setBundleImportError(null); - setBundleNoticeShown(false); - setUntrustedBundleWarning(null); - }; - - const maybeWarnAboutUntrustedBundle = (request: BundleRequest, options?: { allowUntrustedClientFetch?: boolean }) => { - const rawUrl = request.bundleUrl?.trim() ?? ""; - if (!rawUrl || options?.allowUntrustedClientFetch) return false; - const trust = describeBundleUrlTrust(rawUrl); - if (trust.trusted) return false; - setUntrustedBundleWarning({ - request, - actualOrigin: trust.actualOrigin, - configuredOrigin: trust.configuredOrigin, - }); - return true; - }; - - const resolveBundleWorkerTarget = () => { - const pref = options.startupPreference(); - const hostInfo = options.openworkServer.openworkServerHostInfo(); - const settings = options.openworkServer.openworkServerSettings(); - - const localHostUrl = normalizeOpenworkServerUrl(hostInfo?.baseUrl ?? "") ?? ""; - const localToken = hostInfo?.clientToken?.trim() ?? ""; - const serverHostUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; - const serverToken = settings.token?.trim() ?? ""; - - if (pref === "server") { - return { - hostUrl: serverHostUrl || localHostUrl, - token: serverToken || localToken, - }; - } - - if (pref === "local") { - return { - hostUrl: localHostUrl || serverHostUrl, - token: localToken || serverToken, - }; - } - - if (localHostUrl) { - return { - hostUrl: localHostUrl, - token: localToken || serverToken, - }; - } - - return { - hostUrl: serverHostUrl, - token: serverToken || localToken, - }; - }; - - const resolveActiveBundleImportTarget = (): BundleImportTarget => { - const active = options.workspaceStore.selectedWorkspaceDisplay(); - if (active.workspaceType === "local") { - return { localRoot: options.workspaceStore.selectedWorkspaceRoot().trim() }; - } - - return { - workspaceId: - active.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || - null, - directoryHint: active.directory?.trim() || active.path?.trim() || null, - }; - }; - - const waitForBundleImportTarget = async (timeoutMs = 20_000, target?: BundleImportTarget) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const client = options.openworkServer.openworkServerClient(); - if (client && options.openworkServer.openworkServerStatus() === "connected") { - if (target?.workspaceId?.trim() || target?.localRoot?.trim() || target?.directoryHint?.trim()) { - try { - const matchId = await options.workspaceStore.ensureRuntimeWorkspaceId({ - workspaceId: target.workspaceId, - localRoot: target.localRoot, - directoryHint: target.directoryHint, - strictMatch: true, - }); - if (matchId) { - return { client, workspaceId: matchId }; - } - } catch { - // ignore and keep polling - } - } else { - const workspaceId = options.runtimeWorkspaceId(); - if (workspaceId) { - return { client, workspaceId }; - } - } - } - await new Promise((resolve) => { - window.setTimeout(resolve, 200); - }); - } - throw new Error("OpenWork worker is not ready yet."); - }; - - const importBundlePayload = async (bundle: BundleV1, target?: BundleImportTarget) => { - const { client, workspaceId } = await waitForBundleImportTarget(20_000, target); - const { payload, importedSkillsCount } = buildImportPayloadFromBundle(bundle); - await client.importWorkspace(workspaceId, payload); - await options.refreshActiveWorkspaceServerConfig(workspaceId); - await options.refreshSkills({ force: true }); - await options.refreshHubSkills({ force: true }); - if (importedSkillsCount > 0) { - options.markReloadRequired("skills", { - type: "skill", - name: bundle.name?.trim() || undefined, - action: "added", - }); - } - }; - - const importBundleIntoActiveWorker = async ( - request: BundleRequest, - target?: BundleImportTarget, - bundleOverride?: BundleV1, - importOptions?: { allowUntrustedClientFetch?: boolean }, - ) => { - try { - const bundle = - bundleOverride ?? - (await fetchBundle(request.bundleUrl?.trim() ?? "", options.openworkServer.openworkServerClient(), { - forceClientFetch: importOptions?.allowUntrustedClientFetch, - })); - await importBundlePayload(bundle, target); - options.setError(null); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - return false; - } - }; - - const createWorkerForBundle = async (request: BundleRequest, bundle: BundleV1) => { - const target = resolveBundleWorkerTarget(); - const hostUrl = target.hostUrl.trim(); - const token = target.token.trim(); - if (!hostUrl || !token) { - throw new Error("Bundle link detected. Configure an OpenWork worker host and token, then open the link again."); - } - - const label = (request.label?.trim() || bundle.name?.trim() || 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 => { - if (maybeWarnAboutUntrustedBundle(request, processOptions)) { - return { mode: "untrusted_warning" }; - } - - const bundle = await fetchBundle(request.bundleUrl?.trim() ?? "", options.openworkServer.openworkServerClient(), { - forceClientFetch: processOptions?.allowUntrustedClientFetch, - }); - - if (bundle.type === "skill") { - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setSkillDestinationRequest({ request, bundle }); - return { mode: "choice", bundle }; - } - - if (bundle.type === "skills-set") { - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setBundleImportChoice({ request, bundle }); - return { mode: "choice", bundle }; - } - - if (request.intent === "new_worker" && isTauriRuntime()) { - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setCreateWorkspaceRequest(null); - setBundleImportChoice(null); - setBundleStartRequest({ - request, - bundle, - defaultPreset: defaultPresetFromWorkspaceProfileBundle(bundle), - }); - return { mode: "start_modal", bundle }; - } - - if (request.intent === "import_current") { - const client = options.openworkServer.openworkServerClient(); - const connected = options.openworkServer.openworkServerStatus() === "connected"; - const target = resolveActiveBundleImportTarget(); - const hasTargetHint = Boolean(target.workspaceId?.trim() || target.localRoot?.trim() || target.directoryHint?.trim()); - if (!client || !connected || !hasTargetHint) { - if (!bundleNoticeShown()) { - setBundleNoticeShown(true); - options.setError("Bundle link detected. Connect to a writable OpenWork worker to import this bundle."); - } - return { mode: "blocked_import_current", bundle }; - } - } else { - const target = resolveBundleWorkerTarget(); - if (!target.hostUrl.trim() || !target.token.trim()) { - if (!bundleNoticeShown()) { - setBundleNoticeShown(true); - options.setError("Bundle link detected. Configure an OpenWork host and token to create a new worker."); - } - return { mode: "blocked_new_worker", bundle }; - } - } - - if (request.intent === "new_worker") { - await createWorkerForBundle(request, bundle); - } - - await importBundlePayload(bundle, resolveActiveBundleImportTarget()); - options.setError(null); - return { mode: "imported", bundle }; - }; - - createEffect(() => { - const request = pendingBundleRequest(); - if (!request || options.booting()) { - return; - } - - if (untrack(bundleImportBusy)) { - return; - } - - let cancelled = false; - setBundleImportBusy(true); - - void (async () => { - try { - await processBundleRequest(request); - if (cancelled) return; - } catch (error) { - if (!cancelled) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } - } finally { - if (!cancelled) { - const nextPendingRequest = pendingBundleRequest(); - const shouldClearPendingRequest = nextPendingRequest === request; - setBundleImportBusy(false); - if (shouldClearPendingRequest) { - setPendingBundleRequest(null); - setBundleNoticeShown(false); - } else if (nextPendingRequest) { - setPendingBundleRequest({ ...nextPendingRequest }); - } - } - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - const queueBundleLink = (rawUrl: string): boolean => { - const parsed = parseBundleDeepLink(rawUrl); - if (!parsed) { - return false; - } - setPendingBundleRequest(parsed); - resetInteractiveBundleState(); - return true; - }; - - const openDebugBundleRequest = async (request: BundleRequest): Promise<{ ok: boolean; message: string }> => { - setPendingBundleRequest(null); - setBundleNoticeShown(false); - resetInteractiveBundleState(); - options.setError(null); - - try { - setBundleImportBusy(true); - const result = await processBundleRequest(request); - switch (result.mode) { - case "choice": - return { ok: true, message: "Opened the bundle import chooser." }; - case "start_modal": - return { ok: true, message: "Opened the template start flow." }; - case "blocked_import_current": - case "blocked_new_worker": - return { ok: false, message: options.error() || "The bundle needs more worker setup before it can open." }; - case "untrusted_warning": - return { ok: false, message: "Showed a security warning for an untrusted bundle link." }; - case "imported": - return { ok: true, message: "Imported the bundle into the current worker." }; - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - options.setError(friendly); - return { ok: false, message: friendly }; - } finally { - setBundleImportBusy(false); - } - }; - - const closeBundleImportChoice = () => { - if (bundleImportBusy()) return; - setBundleImportChoice(null); - setBundleImportError(null); - }; - - const dismissUntrustedBundleWarning = () => { - if (bundleImportBusy()) return; - setUntrustedBundleWarning(null); - }; - - const confirmUntrustedBundleWarning = async () => { - const warning = untrustedBundleWarning(); - if (!warning || bundleImportBusy()) return; - setUntrustedBundleWarning(null); - setBundleImportError(null); - options.setError(null); - - try { - setBundleImportBusy(true); - await processBundleRequest(warning.request, { allowUntrustedClientFetch: true }); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - options.setError(friendly); - } finally { - setBundleImportBusy(false); - } - }; - - const openTeamBundle = async (input: { - templateId?: string; - name: string; - templateData: unknown; - organizationName?: string | null; - }) => { - const bundle = parseBundlePayload(input.templateData); - options.setError(null); - options.setView("settings"); - options.setSettingsTab("general"); - setSkillDestinationBusyId(null); - setBundleImportError(null); - setBundleStartRequest(null); - setCreateWorkspaceRequest(null); - - if (bundle.type === "skill") { - setBundleImportChoice(null); - setSkillDestinationRequest({ - request: { - intent: "import_current", - source: "team-template", - label: input.name, - }, - bundle, - }); - return; - } - - setSkillDestinationRequest(null); - setBundleImportChoice({ - request: { - intent: "import_current", - source: "team-template", - label: input.name, - }, - bundle, - }); - }; - - const startWorkspaceFromTeamTemplate = async (input: { - name: string; - templateData: unknown; - folder: string | null; - preset?: WorkspacePreset; - }) => { - const bundle = parseBundlePayload(input.templateData); - if (bundle.type !== "workspace-profile") { - throw new Error("Only workspace templates can start a new workspace."); - } - - options.setError(null); - setSkillDestinationRequest(null); - setBundleImportChoice(null); - setBundleImportError(null); - setCreateWorkspaceRequest(null); - setBundleStartRequest(null); - - const imported = await createWorkspaceFromBundle( - bundle, - input.folder, - input.preset ?? defaultPresetFromWorkspaceProfileBundle(bundle), - ); - if (!imported) { - throw new Error(`Failed to create ${input.name} from template.`); - } - }; - - const bundleImportSummary = createMemo(() => { - const choice = bundleImportChoice(); - return choice ? describeBundleImport(choice.bundle) : null; - }); - - const bundleStartItems = createMemo(() => { - const request = bundleStartRequest(); - return request ? describeBundleImport(request.bundle).items : []; - }); - - const createWorkspaceDefaultPreset = createMemo(() => createWorkspaceRequest()?.defaultPreset ?? "starter"); - - const skillDestinationWorkspaces = createMemo(() => { - const activeId = options.workspaceStore.selectedWorkspaceId(); - return options.workspaceStore - .workspaces() - .filter((workspace) => isBundleImportWorkspace(workspace)) - .slice() - .sort((a, b) => { - if (a.id === activeId && b.id !== activeId) return -1; - if (b.id === activeId && a.id !== activeId) return 1; - const aLabel = - a.displayName?.trim() || - a.openworkWorkspaceName?.trim() || - a.name?.trim() || - a.directory?.trim() || - a.path?.trim() || - a.baseUrl?.trim() || - ""; - const bLabel = - b.displayName?.trim() || - b.openworkWorkspaceName?.trim() || - b.name?.trim() || - b.directory?.trim() || - b.path?.trim() || - b.baseUrl?.trim() || - ""; - return aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" }); - }); - }); - - const bundleWorkerOptions = createMemo(() => { - const selectedWorkspaceId = options.workspaceStore.selectedWorkspaceId().trim(); - const items = options.workspaceStore.workspaces().map((workspace) => { - let disabledReason: string | null = null; - if (!resolveBundleImportTargetForWorkspace(workspace)) { - disabledReason = - workspace.workspaceType === "remote" && workspace.remoteType !== "openwork" - ? "Only OpenWork-connected workers support direct bundle imports." - : "This worker is missing the info OpenWork needs to import the bundle."; - } - - const label = - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - workspace.path?.trim() || - 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, - }; -} diff --git a/apps/app/src/app/cloud/den-auth-provider.tsx b/apps/app/src/app/cloud/den-auth-provider.tsx deleted file mode 100644 index ed7ee093..00000000 --- a/apps/app/src/app/cloud/den-auth-provider.tsx +++ /dev/null @@ -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; - user: Accessor; - error: Accessor; - isSignedIn: Accessor; - refresh: () => Promise; -}; - -const DenAuthContext = createContext(); - -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("checking"); - const [user, setUser] = createSignal(null); - const [error, setError] = createSignal(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 ( - - {props.children} - - ); -} - -export function useDenAuth() { - const context = useContext(DenAuthContext); - if (!context) { - throw new Error("useDenAuth must be used within a DenAuthProvider"); - } - return context; -} diff --git a/apps/app/src/app/cloud/den-signin-surface.tsx b/apps/app/src/app/cloud/den-signin-surface.tsx deleted file mode 100644 index e3f995fb..00000000 --- a/apps/app/src/app/cloud/den-signin-surface.tsx +++ /dev/null @@ -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 = ( -
-
-
-
- - {tr("den.cloud_section_title")} -
-
-
- {tr("den.signin_title")} -
-
-
-
- - -
- - 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} - /> -
- - - -
-
-
- - - {(value) => ( -
- {value()} -
- )} -
- - - {(value) =>
{value()}
} -
- -
-
- {tr("den.auto_reconnect_hint")} -
-
- -
- - - -
- - -
- - props.onManualAuthInput(event.currentTarget.value) - } - placeholder={tr("den.signin_link_placeholder")} - disabled={props.authBusy || props.sessionBusy} - hint={tr("den.signin_link_hint")} - /> -
- -
- {tr("den.signin_code_note")} -
-
-
-
- - - {(value) => ( -
- {value()} -
- )} -
-
- ); - - if (variant() === "fullscreen") { - return ( -
-
-
{content}
-
-
- ); - } - - return content; -} diff --git a/apps/app/src/app/cloud/desktop-config-provider.tsx b/apps/app/src/app/cloud/desktop-config-provider.tsx deleted file mode 100644 index 935f6a30..00000000 --- a/apps/app/src/app/cloud/desktop-config-provider.tsx +++ /dev/null @@ -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; - loading: Accessor; - refresh: () => Promise; - checkRestriction: DesktopAppRestrictionChecker; -}; - -const DesktopConfigContext = createContext(); - -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(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 ( - - {props.children} - - ); -} - -export function useDesktopConfig() { - const context = useContext(DesktopConfigContext); - if (!context) { - throw new Error("useDesktopConfig must be used within a DesktopConfigProvider"); - } - return context; -} diff --git a/apps/app/src/app/cloud/forced-signin-page.tsx b/apps/app/src/app/cloud/forced-signin-page.tsx deleted file mode 100644 index 2c936d4f..00000000 --- a/apps/app/src/app/cloud/forced-signin-page.tsx +++ /dev/null @@ -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(null); - const [authBusy, setAuthBusy] = createSignal(false); - const [baseUrlBusy, setBaseUrlBusy] = createSignal(false); - const [manualAuthOpen, setManualAuthOpen] = createSignal(false); - const [manualAuthInput, setManualAuthInput] = createSignal(""); - const [authError, setAuthError] = createSignal(null); - const [statusMessage, setStatusMessage] = createSignal(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; - 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 ( - setBaseUrlDraft(baseUrl())} - onApplyBaseUrl={() => { - void applyBaseUrl(); - }} - onOpenControlPlane={openControlPlane} - onOpenBrowserAuth={openBrowserAuth} - onToggleManualAuth={() => { - setManualAuthOpen((value) => !value); - setAuthError(null); - }} - onManualAuthInput={setManualAuthInput} - onSubmitManualAuth={() => { - void submitManualAuth(); - }} - /> - ); -} diff --git a/apps/app/src/app/components/add-mcp-modal.tsx b/apps/app/src/app/components/add-mcp-modal.tsx deleted file mode 100644 index 9301c0e8..00000000 --- a/apps/app/src/app/components/add-mcp-modal.tsx +++ /dev/null @@ -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(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 ( - -
-
- -
event.stopPropagation()} - > - {/* Header */} -
-
-

- {tr("mcp.add_modal_title")} -

-

{tr("mcp.add_modal_subtitle")}

-
- -
- - {/* Content */} -
- setName(e.currentTarget.value)} - autofocus - /> - -
-
{tr("mcp.server_type")}
-
- - -
- -
{tr("mcp.remote_workspace_url_hint")}
-
-
- - -
- setUrl(e.currentTarget.value)} - /> -
-
{tr("mcp.sign_in_section_label")}
- -
-
-
- - - setCommand(e.currentTarget.value)} - /> - - - -
- {error()} -
-
-
- - {/* Footer */} -
- - -
-
-
- - ); -} diff --git a/apps/app/src/app/components/button.tsx b/apps/app/src/app/components/button.tsx deleted file mode 100644 index c5ff94a1..00000000 --- a/apps/app/src/app/components/button.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { splitProps } from "solid-js"; -import type { JSX } from "solid-js"; - -type ButtonProps = JSX.ButtonHTMLAttributes & { - 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, 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 ( - - -
- - - -
- ); -} diff --git a/apps/app/src/app/components/control-chrome-setup-modal.tsx b/apps/app/src/app/components/control-chrome-setup-modal.tsx deleted file mode 100644 index d8674014..00000000 --- a/apps/app/src/app/components/control-chrome-setup-modal.tsx +++ /dev/null @@ -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 ( - -
-
- -
-
-
-
-
- - Chrome DevTools MCP -
-
-

- {tr("mcp.control_chrome_setup_title")} -

-

- {tr("mcp.control_chrome_setup_subtitle")} -

-
-
- -
-
- -
-
-
-
- -
-
-

- {tr("mcp.control_chrome_browser_title")} -

-

- {tr("mcp.control_chrome_browser_hint")} -

-
    -
  1. 1. {tr("mcp.control_chrome_browser_step_one")}
  2. -
  3. 2. {tr("mcp.control_chrome_browser_step_two")}
  4. -
  5. 3. {tr("mcp.control_chrome_browser_step_three")}
  6. -
- - {tr("mcp.control_chrome_docs")} - - -
-
-
- -
-
-
- -
-
-

- {tr("mcp.control_chrome_profile_title")} -

-

- {tr("mcp.control_chrome_profile_hint")} -

- - - -
- {useExistingProfile() - ? tr("mcp.control_chrome_toggle_on") - : tr("mcp.control_chrome_toggle_off")} -
-
-
-
-
- -
- - -
-
-
- - ); -} diff --git a/apps/app/src/app/components/den-settings-panel.tsx b/apps/app/src/app/components/den-settings-panel.tsx deleted file mode 100644 index 8658ec53..00000000 --- a/apps/app/src/app/components/den-settings-panel.tsx +++ /dev/null @@ -1,1970 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; -import { ArrowUpRight, Boxes, Brain, Cloud, KeyRound, LogOut, Package, RefreshCcw, Server, Users } from "lucide-solid"; - -import Button from "./button"; -import TextInput from "./text-input"; -import DenSignInSurface from "../cloud/den-signin-surface"; -import { currentLocale, t } from "../../i18n"; -import { - buildDenAuthUrl, - clearDenSession, - DEFAULT_DEN_BASE_URL, - DenApiError, - type DenOrgSkillHub, - type DenOrgLlmProvider, - type DenTemplate, - createDenClient, - normalizeDenBaseUrl, - readDenBootstrapConfig, - readDenSettings, - resolveDenBaseUrls, - setDenBootstrapConfig, - writeDenSettings, -} from "../lib/den"; -import type { DenOrgSkillCard } from "../types"; -import type { CloudImportedProvider, CloudImportedSkill, CloudImportedSkillHub } from "../cloud/import-state"; -import { - denSettingsChangedEvent, - denSessionUpdatedEvent, - dispatchDenSessionUpdated, - type DenSessionUpdatedDetail, -} from "../lib/den-session-events"; -import { - clearDenTemplateCache, - loadDenTemplateCache, - readDenTemplateCacheSnapshot, -} from "../lib/den-template-cache"; -import { usePlatform } from "../context/platform"; -import { useExtensions } from "../extensions/provider"; - -type DenSettingsPanelProps = { - developerMode: boolean; - connectRemoteWorkspace: (input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; - }) => Promise; - openTeamBundle: (input: { - templateId: string; - name: string; - templateData: unknown; - organizationName?: string | null; - }) => void | Promise; - cloudOrgProviders: DenOrgLlmProvider[]; - importedCloudProviders: Record; - refreshCloudOrgProviders: (options?: { force?: boolean }) => Promise; - connectCloudProvider: (cloudProviderId: string) => Promise; - removeCloudProvider: (cloudProviderId: string) => Promise; - runCloudProviderSync: (reason: "sign_in" | "app_launch" | "interval" | "settings_cloud_opened") => Promise; -}; - -type CloudSkillHubRow = { - key: string; - hubId: string; - name: string; - hub: DenOrgSkillHub | null; - imported: CloudImportedSkillHub | null; - status: "available" | "imported" | "out_of_sync" | "removed_from_cloud"; - liveSkillCount: number; - importedSkillCount: number; -}; - -type CloudProviderRow = { - key: string; - cloudProviderId: string; - provider: DenOrgLlmProvider | null; - imported: CloudImportedProvider | null; - status: "available" | "imported" | "out_of_sync" | "removed_from_cloud"; - name: string; -}; - -type CloudSkillRow = { - key: string; - cloudSkillId: string; - skill: DenOrgSkillCard | null; - imported: CloudImportedSkill | null; - status: "available" | "installed" | "out_of_sync" | "removed_from_cloud"; - title: string; - installedName: string | null; -}; - -const sortStrings = (values: string[]) => [...values].sort(); - -const sameStringList = (a: string[], b: string[]) => - a.length === b.length && a.every((value, index) => value === b[index]); - -function statusBadgeClass(kind: "ready" | "warning" | "neutral" | "error") { - switch (kind) { - case "ready": - return "border-green-7/30 bg-green-3/20 text-green-11"; - case "warning": - return "border-amber-7/30 bg-amber-3/20 text-amber-11"; - case "error": - return "border-red-7/30 bg-red-3/20 text-red-11"; - default: - return "border-gray-6/60 bg-gray-3/20 text-gray-11"; - } -} - -function workerStatusMeta(status: string, tr: (key: string) => string) { - const normalized = status.trim().toLowerCase(); - switch (normalized) { - case "healthy": - return { label: tr("dashboard.worker_status_ready"), tone: "ready" as const, canOpen: true }; - case "provisioning": - return { label: tr("dashboard.worker_status_starting"), tone: "warning" as const, canOpen: false }; - case "failed": - return { label: tr("dashboard.worker_status_attention"), tone: "error" as const, canOpen: false }; - case "stopped": - return { label: tr("dashboard.worker_status_stopped"), tone: "neutral" as const, canOpen: false }; - default: - return { - label: normalized - ? `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}` - : tr("dashboard.worker_status_unknown"), - tone: "neutral" as const, - canOpen: normalized === "ready", - }; - } -} - -export default function DenSettingsPanel(props: DenSettingsPanelProps) { - const platform = usePlatform(); - const extensions = useExtensions(); - const tr = (key: string) => t(key, currentLocale()); - 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(null); - const [baseUrlBusy, setBaseUrlBusy] = createSignal(false); - const [authToken, setAuthToken] = createSignal(initial.authToken?.trim() || ""); - const [activeOrgId, setActiveOrgId] = createSignal(initial.activeOrgId?.trim() || ""); - const [authBusy, setAuthBusy] = createSignal(false); - const [manualAuthOpen, setManualAuthOpen] = createSignal(false); - const [manualAuthInput, setManualAuthInput] = createSignal(""); - const [sessionBusy, setSessionBusy] = createSignal(false); - const [orgsBusy, setOrgsBusy] = createSignal(false); - const [workersBusy, setWorkersBusy] = createSignal(false); - const [openingWorkerId, setOpeningWorkerId] = createSignal(null); - const [openingTemplateId, setOpeningTemplateId] = createSignal(null); - const [user, setUser] = createSignal<{ - id: string; - email: string; - name: string | null; - } | null>(null); - const [orgs, setOrgs] = createSignal< - Array<{ id: string; name: string; slug: string; role: "owner" | "admin" | "member" }> - >([]); - const [workers, setWorkers] = createSignal< - Array<{ - workerId: string; - workerName: string; - status: string; - instanceUrl: string | null; - provider: string | null; - isMine: boolean; - createdAt: string | null; - }> - >([]); - const [statusMessage, setStatusMessage] = createSignal(null); - const [authError, setAuthError] = createSignal(null); - const [orgsError, setOrgsError] = createSignal(null); - const [workersError, setWorkersError] = createSignal(null); - const [templateActionError, setTemplateActionError] = createSignal(null); - const [skillHubsBusy, setSkillHubsBusy] = createSignal(false); - const [skillHubActionId, setSkillHubActionId] = createSignal(null); - const [skillHubActionKind, setSkillHubActionKind] = createSignal<"import" | "remove" | "sync" | null>(null); - const [skillHubActionError, setSkillHubActionError] = createSignal(null); - const [skillsBusy, setSkillsBusy] = createSignal(false); - const [skillActionId, setSkillActionId] = createSignal(null); - const [skillActionKind, setSkillActionKind] = createSignal<"import" | "remove" | "sync" | null>(null); - const [skillActionError, setSkillActionError] = createSignal(null); - const [providersBusy, setProvidersBusy] = createSignal(false); - const [providerActionId, setProviderActionId] = createSignal(null); - const [providerActionKind, setProviderActionKind] = createSignal<"import" | "remove" | "sync" | null>(null); - const [providerActionError, setProviderActionError] = createSignal(null); - - const activeOrg = createMemo(() => orgs().find((org) => org.id === activeOrgId()) ?? null); - const client = createMemo(() => - createDenClient({ - baseUrl: baseUrl(), - apiBaseUrl: readDenSettings().apiBaseUrl, - token: authToken(), - }), - ); - const isSignedIn = createMemo(() => Boolean(user() && authToken().trim())); - const activeOrgName = createMemo(() => activeOrg()?.name || tr("den.no_org_selected")); - const templateCacheSnapshot = createMemo(() => - readDenTemplateCacheSnapshot({ - baseUrl: baseUrl(), - token: authToken(), - orgSlug: activeOrg()?.slug ?? null, - }), - ); - const templatesBusy = createMemo(() => templateCacheSnapshot().busy); - const templates = createMemo(() => templateCacheSnapshot().templates); - const templatesError = createMemo( - () => templateActionError() ?? templateCacheSnapshot().error, - ); - const skillHubImports = createMemo(() => extensions.importedCloudSkillHubs()); - const skillHubRows = createMemo(() => { - const liveHubs = extensions.cloudOrgSkillHubs(); - const imported = skillHubImports(); - const rows: CloudSkillHubRow[] = liveHubs.map((hub) => { - const importedHub = imported[hub.id] ?? null; - const currentSkillIds = sortStrings(hub.skills.map((skill) => skill.id)); - const importedSkillIds = sortStrings(importedHub?.skillIds ?? []); - const status = !importedHub - ? "available" - : sameStringList(currentSkillIds, importedSkillIds) - ? "imported" - : "out_of_sync"; - return { - key: `live:${hub.id}`, - hubId: hub.id, - name: hub.name, - hub, - imported: importedHub, - status, - liveSkillCount: hub.skills.length, - importedSkillCount: importedHub?.skillNames.length ?? 0, - }; - }); - - for (const importedHub of Object.values(imported)) { - if (liveHubs.some((hub) => hub.id === importedHub.hubId)) continue; - rows.push({ - key: `imported:${importedHub.hubId}`, - hubId: importedHub.hubId, - name: importedHub.name, - hub: null, - imported: importedHub, - status: "removed_from_cloud", - liveSkillCount: 0, - importedSkillCount: importedHub.skillNames.length, - }); - } - - return rows; - }); - const installedSkillNames = createMemo(() => new Set(extensions.skills().map((skill) => skill.name))); - const skillRows = createMemo(() => { - const liveSkills = extensions.cloudOrgSkills(); - const imported = extensions.importedCloudSkills(); - const installedNames = installedSkillNames(); - const rows: CloudSkillRow[] = liveSkills.map((skill) => { - const importedSkill = imported[skill.id] ?? null; - const remoteUpdatedAt = skill.updatedAt ? Date.parse(skill.updatedAt) : Number.NaN; - const importedUpdatedAt = importedSkill?.updatedAt ? Date.parse(importedSkill.updatedAt) : Number.NaN; - const installedName = importedSkill?.installedName?.trim() || null; - const installedLocally = installedName ? installedNames.has(installedName) : false; - const status = !importedSkill - ? "available" - : !installedLocally - ? "out_of_sync" - : Number.isFinite(remoteUpdatedAt) && (!Number.isFinite(importedUpdatedAt) || remoteUpdatedAt > importedUpdatedAt) - ? "out_of_sync" - : "installed"; - - return { - key: `live:${skill.id}`, - cloudSkillId: skill.id, - skill, - imported: importedSkill, - status, - title: skill.title, - installedName, - }; - }); - - for (const importedSkill of Object.values(imported)) { - if (liveSkills.some((skill) => skill.id === importedSkill.cloudSkillId)) continue; - rows.push({ - key: `imported:${importedSkill.cloudSkillId}`, - cloudSkillId: importedSkill.cloudSkillId, - skill: null, - imported: importedSkill, - status: "removed_from_cloud", - title: importedSkill.title, - installedName: importedSkill.installedName, - }); - } - - return rows.sort((a, b) => a.title.localeCompare(b.title)); - }); - const providerRows = createMemo(() => { - const imported = props.importedCloudProviders; - const rows: CloudProviderRow[] = props.cloudOrgProviders.map((provider) => { - const importedProvider = imported[provider.id] ?? null; - const status = !importedProvider - ? "available" - : importedProvider.sourceProviderId !== provider.providerId || - (importedProvider.source ?? null) !== provider.source || - (importedProvider.updatedAt ?? null) !== (provider.updatedAt ?? null) || - !sameStringList(importedProvider.modelIds, sortStrings(provider.models.map((model) => model.id))) - ? "out_of_sync" - : "imported"; - return { - key: `live:${provider.id}`, - cloudProviderId: provider.id, - provider, - imported: importedProvider, - status, - name: provider.name, - }; - }); - - for (const importedProvider of Object.values(imported)) { - if (props.cloudOrgProviders.some((provider) => provider.id === importedProvider.cloudProviderId)) continue; - rows.push({ - key: `imported:${importedProvider.cloudProviderId}`, - cloudProviderId: importedProvider.cloudProviderId, - provider: null, - imported: importedProvider, - status: "removed_from_cloud", - name: importedProvider.name, - }); - } - - return rows; - }); - - const summaryTone = createMemo(() => { - if (authError() || workersError() || orgsError() || templatesError() || skillActionError() || providerActionError() || skillHubActionError()) return "error" as const; - if (sessionBusy() || orgsBusy() || workersBusy() || templatesBusy() || skillsBusy() || providersBusy() || skillHubsBusy()) return "warning" as const; - if (isSignedIn()) return "ready" as const; - return "neutral" as const; - }); - - const summaryLabel = createMemo(() => { - if (authError()) return tr("den.needs_attention"); - if (sessionBusy()) return tr("den.checking_session"); - if (isSignedIn()) return t("dashboard.connected", currentLocale()); - return tr("den.signed_out"); - }); - - createEffect(() => { - writeDenSettings({ - baseUrl: baseUrl(), - authToken: authToken() || null, - activeOrgId: activeOrgId() || null, - activeOrgSlug: activeOrg()?.slug ?? null, - activeOrgName: activeOrg()?.name ?? null, - }); - }); - - const openControlPlane = () => { - platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl); - }; - - const openBrowserAuth = (mode: "sign-in" | "sign-up") => { - platform.openLink(buildDenAuthUrl(baseUrl(), mode)); - setStatusMessage( - mode === "sign-up" - ? tr("den.status_browser_signup") - : tr("den.status_browser_signin"), - ); - 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(tr("den.error_paste_valid_code")); - } - return; - } - - const nextBaseUrl = parsed.baseUrl ?? baseUrl(); - - setAuthBusy(true); - setAuthError(null); - setStatusMessage(tr("den.signing_in")); - - try { - const result = await createDenClient({ - baseUrl: nextBaseUrl, - apiBaseUrl: readDenSettings().apiBaseUrl, - }).exchangeDesktopHandoff(parsed.grant); - if (!result.token) { - throw new Error(tr("den.error_no_token")); - } - - 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 - : tr("den.error_signin_failed"), - }); - } finally { - setAuthBusy(false); - } - }; - - const clearSessionState = () => { - setUser(null); - setOrgs([]); - setWorkers([]); - setActiveOrgId(""); - setOrgsError(null); - setWorkersError(null); - setTemplateActionError(null); - setSkillHubActionError(null); - setProviderActionError(null); - setSkillHubActionKind(null); - setProviderActionKind(null); - }; - - const clearSignedInState = (message?: string | null) => { - clearDenSession({ includeBaseUrls: !props.developerMode }); - clearDenTemplateCache(); - const nextBaseUrl = readDenSettings().baseUrl || DEFAULT_DEN_BASE_URL; - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - setAuthToken(""); - setOpeningWorkerId(null); - setOpeningTemplateId(null); - setSkillHubActionId(null); - setProviderActionId(null); - setSkillHubActionKind(null); - setProviderActionKind(null); - clearSessionState(); - setBaseUrlError(null); - setAuthError(null); - setStatusMessage(message ?? null); - }; - - const applyBaseUrl = async () => { - const normalized = normalizeDenBaseUrl(baseUrlDraft()); - if (!normalized) { - setBaseUrlError(tr("den.error_base_url")); - return; - } - - const resolved = resolveDenBaseUrls(normalized); - setBaseUrlBusy(true); - - try { - await setDenBootstrapConfig({ - baseUrl: resolved.baseUrl, - apiBaseUrl: resolved.apiBaseUrl, - requireSignin: readDenBootstrapConfig().requireSignin, - }); - setBaseUrlError(null); - if (resolved.baseUrl === baseUrl()) { - setBaseUrlDraft(resolved.baseUrl); - return; - } - - setBaseUrl(resolved.baseUrl); - setBaseUrlDraft(resolved.baseUrl); - writeDenSettings({ - baseUrl: resolved.baseUrl, - apiBaseUrl: resolved.apiBaseUrl, - authToken: null, - activeOrgId: null, - activeOrgSlug: null, - activeOrgName: null, - }, { persistBootstrap: false }); - clearSignedInState(tr("den.status_base_url_updated")); - } catch (error) { - setBaseUrlError( - error instanceof Error ? error.message : tr("den.error_base_url"), - ); - } finally { - setBaseUrlBusy(false); - } - }; - - const refreshOrgs = async (quiet = false) => { - if (!authToken().trim()) { - setOrgs([]); - setActiveOrgId(""); - return; - } - - setOrgsBusy(true); - if (!quiet) setOrgsError(null); - - try { - const response = await client().listOrgs(); - setOrgs(response.orgs); - const current = activeOrgId().trim(); - const fallback = response.defaultOrgId ?? response.orgs[0]?.id ?? ""; - const next = response.orgs.some((org) => org.id === current) ? current : fallback; - const nextOrg = response.orgs.find((org) => org.id === next) ?? null; - - if (nextOrg && response.activeOrgId !== nextOrg.id) { - await client().setActiveOrganization({ organizationId: nextOrg.id }); - } - - setActiveOrgId(next); - writeDenSettings({ - baseUrl: baseUrl(), - authToken: authToken() || null, - activeOrgId: next || null, - activeOrgSlug: nextOrg?.slug ?? null, - activeOrgName: nextOrg?.name ?? null, - }); - if (!quiet && response.orgs.length > 0) { - setStatusMessage( - t("den.status_loaded_orgs", currentLocale(), { count: response.orgs.length, plural: response.orgs.length === 1 ? "" : "s" }), - ); - } - } catch (error) { - setOrgsError(error instanceof Error ? error.message : tr("den.error_load_orgs")); - } finally { - setOrgsBusy(false); - } - }; - - const switchActiveOrg = async (nextId: string) => { - const nextOrg = orgs().find((org) => org.id === nextId) ?? null; - if (!nextOrg || nextId === activeOrgId()) { - return; - } - - setOrgsBusy(true); - setOrgsError(null); - try { - await client().setActiveOrganization({ organizationId: nextId }); - setActiveOrgId(nextId); - writeDenSettings({ - baseUrl: baseUrl(), - authToken: authToken() || null, - activeOrgId: nextId || null, - activeOrgSlug: nextOrg.slug, - activeOrgName: nextOrg.name, - }); - setStatusMessage( - t("den.org_switched", currentLocale(), { name: nextOrg.name }), - ); - } catch (error) { - setOrgsError(error instanceof Error ? error.message : tr("den.error_load_orgs")); - } finally { - setOrgsBusy(false); - } - }; - - const refreshWorkers = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - setWorkers([]); - return; - } - - setWorkersBusy(true); - if (!quiet) setWorkersError(null); - - try { - const nextWorkers = await client().listWorkers(orgId, 20); - setWorkers(nextWorkers); - if (!quiet) { - setStatusMessage( - nextWorkers.length > 0 - ? t("den.status_loaded_workers", currentLocale(), { count: nextWorkers.length, plural: nextWorkers.length === 1 ? "" : "s", name: activeOrg()?.name ?? tr("den.active_org_title") }) - : t("den.status_no_workers", currentLocale(), { name: activeOrg()?.name ?? tr("den.active_org_title") }), - ); - } - } catch (error) { - setWorkersError(error instanceof Error ? error.message : tr("den.error_load_workers")); - } finally { - setWorkersBusy(false); - } - }; - - const refreshTemplates = async (quiet = false) => { - const orgSlug = activeOrg()?.slug?.trim() ?? ""; - if (!authToken().trim() || !orgSlug) { - return; - } - - setTemplateActionError(null); - - try { - const nextTemplates = await loadDenTemplateCache( - { - baseUrl: baseUrl(), - token: authToken(), - orgSlug, - }, - { force: true }, - ); - if (!quiet) { - setStatusMessage( - nextTemplates.length > 0 - ? t("den.status_loaded_templates", currentLocale(), { count: nextTemplates.length, plural: nextTemplates.length === 1 ? "" : "s", name: activeOrg()?.name ?? tr("den.active_org_title") }) - : t("den.status_no_templates", currentLocale(), { name: activeOrg()?.name ?? tr("den.active_org_title") }), - ); - } - } catch (error) { - if (!quiet) { - setTemplateActionError(error instanceof Error ? error.message : tr("den.error_load_templates")); - } - } - }; - - const refreshSkillHubs = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - return; - } - - setSkillHubsBusy(true); - if (!quiet) setSkillHubActionError(null); - - try { - await extensions.refreshCloudOrgSkillHubs({ force: true }); - if (!quiet) { - const count = extensions.cloudOrgSkillHubs().length; - setStatusMessage( - count > 0 - ? `Loaded ${count} cloud skill hub${count === 1 ? "" : "s"} for ${activeOrg()?.name ?? tr("den.active_org_title")}.` - : `No cloud skill hubs are available for ${activeOrg()?.name ?? tr("den.active_org_title")}.`, - ); - } - } catch (error) { - if (!quiet) { - setSkillHubActionError( - error instanceof Error ? error.message : "Failed to load cloud skill hubs.", - ); - } - } finally { - setSkillHubsBusy(false); - } - }; - - const refreshSkills = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - return; - } - - setSkillsBusy(true); - if (!quiet) setSkillActionError(null); - - try { - await extensions.refreshCloudOrgSkills({ force: true }); - if (!quiet) { - const count = extensions.cloudOrgSkills().length; - setStatusMessage( - count > 0 - ? t("den.status_loaded_skills", currentLocale(), { count, plural: count === 1 ? "" : "s", name: activeOrg()?.name ?? tr("den.active_org_title") }) - : t("den.status_no_skills", currentLocale(), { name: activeOrg()?.name ?? tr("den.active_org_title") }), - ); - } - } catch (error) { - if (!quiet) { - setSkillActionError(error instanceof Error ? error.message : tr("den.error_load_skills")); - } - } finally { - setSkillsBusy(false); - } - }; - - const refreshProviders = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - return; - } - - setProvidersBusy(true); - setProviderActionError(null); - - try { - const items = await props.refreshCloudOrgProviders({ force: !quiet }); - if (!quiet) { - setStatusMessage( - items.length > 0 - ? `Loaded ${items.length} cloud provider${items.length === 1 ? "" : "s"} for ${activeOrg()?.name ?? tr("den.active_org_title")}.` - : `No cloud providers are available for ${activeOrg()?.name ?? tr("den.active_org_title")}.`, - ); - } - } catch (error) { - if (!quiet) { - setProviderActionError( - error instanceof Error ? error.message : "Failed to load cloud providers.", - ); - } - } finally { - setProvidersBusy(false); - } - }; - - createEffect(() => { - const token = authToken().trim(); - const currentBaseUrl = baseUrl(); - let cancelled = false; - - if (!token) { - setSessionBusy(false); - clearSessionState(); - setAuthError(null); - return; - } - - setSessionBusy(true); - setAuthError(null); - - void createDenClient({ - baseUrl: currentBaseUrl, - apiBaseUrl: readDenSettings().apiBaseUrl, - token, - }) - .getSession() - .then((nextUser) => { - if (cancelled) return; - setUser(nextUser); - setStatusMessage(t("den.status_signed_in_as", currentLocale(), { email: nextUser.email })); - }) - .catch((error) => { - if (cancelled) return; - if (error instanceof DenApiError && error.status === 401) { - clearSignedInState(); - } else { - clearSessionState(); - } - setAuthError( - error instanceof Error ? error.message : tr("den.error_no_session"), - ); - }) - .finally(() => { - if (!cancelled) setSessionBusy(false); - }); - - return () => { - cancelled = true; - }; - }); - - createEffect(() => { - if (!user()) return; - void refreshOrgs(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshWorkers(true); - }); - - createEffect(() => { - if (!user() || !activeOrg()?.slug?.trim()) return; - void refreshTemplates(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshSkillHubs(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshSkills(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshProviders(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void props.runCloudProviderSync("settings_cloud_opened"); - }); - - createEffect(() => { - const handler = (event: Event) => { - const customEvent = event as CustomEvent; - const nextSettings = readDenSettings(); - const nextBaseUrl = - customEvent.detail?.baseUrl?.trim() || - nextSettings.baseUrl || - DEFAULT_DEN_BASE_URL; - const nextToken = - customEvent.detail?.token?.trim() || - nextSettings.authToken?.trim() || - ""; - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - setAuthToken(nextToken); - setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); - if (customEvent.detail?.status === "success") { - clearSessionState(); - if (customEvent.detail.user) { - setUser(customEvent.detail.user); - } - setAuthError(null); - setSessionBusy(false); - setStatusMessage( - customEvent.detail.email?.trim() - ? t("den.status_cloud_signed_in_as", currentLocale(), { email: customEvent.detail.email.trim() }) - : tr("den.status_cloud_signin_done"), - ); - } else if (customEvent.detail?.status === "error") { - setAuthError( - customEvent.detail.message?.trim() || - tr("den.error_signin_failed"), - ); - } - }; - - window.addEventListener( - denSessionUpdatedEvent, - handler as EventListener, - ); - return () => - window.removeEventListener( - denSessionUpdatedEvent, - handler as EventListener, - ); - }); - - createEffect(() => { - const handler = () => { - const nextSettings = readDenSettings(); - setBaseUrl(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setAuthToken(nextSettings.authToken?.trim() || ""); - setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); - }; - - window.addEventListener(denSettingsChangedEvent, handler as EventListener); - return () => - window.removeEventListener( - denSettingsChangedEvent, - handler as EventListener, - ); - }); - - const signOut = async () => { - if (authBusy()) return; - - setAuthBusy(true); - try { - if (authToken().trim()) { - await client().signOut(); - } - } catch { - // ignore remote sign out failures - } finally { - setAuthBusy(false); - } - - clearSignedInState(tr("den.status_signed_out")); - }; - - const handleOpenWorker = async (workerId: string, workerName: string) => { - const orgId = activeOrgId().trim(); - if (!orgId) { - setWorkersError(tr("den.error_choose_org")); - return; - } - - setOpeningWorkerId(workerId); - setWorkersError(null); - - try { - const tokens = await client().getWorkerTokens(workerId, orgId); - const openworkUrl = tokens.openworkUrl?.trim() ?? ""; - const accessToken = - tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; - if (!openworkUrl || !accessToken) { - throw new Error(tr("den.error_worker_not_ready")); - } - - const ok = await props.connectRemoteWorkspace({ - openworkHostUrl: openworkUrl, - openworkToken: accessToken, - directory: null, - displayName: workerName, - }); - if (!ok) { - throw new Error(t("den.error_open_worker", currentLocale(), { name: workerName })); - } - - setStatusMessage(t("den.status_opened_worker", currentLocale(), { name: workerName })); - } catch (error) { - setWorkersError( - error instanceof Error ? error.message : t("den.error_open_worker_fallback", currentLocale(), { name: workerName }), - ); - } finally { - setOpeningWorkerId(null); - } - }; - - const handleOpenTemplate = async (template: DenTemplate) => { - if (openingTemplateId()) return; - - setOpeningTemplateId(template.id); - setTemplateActionError(null); - - try { - await props.openTeamBundle({ - templateId: template.id, - name: template.name, - templateData: template.templateData, - organizationName: activeOrg()?.name ?? null, - }); - const orgName = activeOrg()?.name; - setStatusMessage( - orgName - ? t("den.status_opened_template", currentLocale(), { name: template.name, org: orgName }) - : t("den.status_opened_template_fallback", currentLocale(), { name: template.name }), - ); - } catch (error) { - setTemplateActionError(error instanceof Error ? error.message : t("den.error_open_template", currentLocale(), { name: template.name })); - } finally { - setOpeningTemplateId(null); - } - }; - - const handleImportSkillHub = async (hubId: string) => { - const hub = extensions.cloudOrgSkillHubs().find((entry) => entry.id === hubId); - if (!hub || skillHubActionId()) return; - - setSkillHubActionId(hub.id); - setSkillHubActionKind("import"); - setSkillHubActionError(null); - - try { - const result = await extensions.importCloudOrgSkillHub(hub); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillHubActionError(error instanceof Error ? error.message : `Failed to import ${hub.name}.`); - } finally { - setSkillHubActionId(null); - setSkillHubActionKind(null); - } - }; - - const handleRemoveSkillHub = async (hubId: string) => { - const imported = skillHubImports()[hubId]; - if (!imported || skillHubActionId()) return; - - setSkillHubActionId(hubId); - setSkillHubActionKind("remove"); - setSkillHubActionError(null); - - try { - const result = await extensions.removeCloudOrgSkillHub(hubId); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillHubActionError(error instanceof Error ? error.message : `Failed to remove ${imported.name}.`); - } finally { - setSkillHubActionId(null); - setSkillHubActionKind(null); - } - }; - - const handleSyncSkillHub = async (hubId: string) => { - const hub = extensions.cloudOrgSkillHubs().find((entry) => entry.id === hubId); - if (!hub || skillHubActionId()) return; - - setSkillHubActionId(hub.id); - setSkillHubActionKind("sync"); - setSkillHubActionError(null); - - try { - const result = await extensions.syncCloudOrgSkillHub(hub); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillHubActionError(error instanceof Error ? error.message : `Failed to sync ${hub.name}.`); - } finally { - setSkillHubActionId(null); - setSkillHubActionKind(null); - } - }; - - const handleImportSkill = async (cloudSkillId: string, title: string) => { - const skill = extensions.cloudOrgSkills().find((entry) => entry.id === cloudSkillId); - if (!skill || skillActionId()) return; - - setSkillActionId(cloudSkillId); - setSkillActionKind("import"); - setSkillActionError(null); - - try { - const result = await extensions.installCloudOrgSkill(skill); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillActionError(error instanceof Error ? error.message : t("den.import_skill_failed", undefined, { name: title })); - } finally { - setSkillActionId(null); - setSkillActionKind(null); - } - }; - - const handleRemoveSkill = async (cloudSkillId: string, title: string) => { - if (skillActionId()) return; - - setSkillActionId(cloudSkillId); - setSkillActionKind("remove"); - setSkillActionError(null); - - try { - const result = await extensions.removeCloudOrgSkill(cloudSkillId); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillActionError(error instanceof Error ? error.message : t("den.remove_skill_failed", undefined, { name: title })); - } finally { - setSkillActionId(null); - setSkillActionKind(null); - } - }; - - const handleSyncSkill = async (cloudSkillId: string, title: string) => { - const skill = extensions.cloudOrgSkills().find((entry) => entry.id === cloudSkillId); - if (!skill || skillActionId()) return; - - setSkillActionId(cloudSkillId); - setSkillActionKind("sync"); - setSkillActionError(null); - - try { - const result = await extensions.syncCloudOrgSkill(skill); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillActionError(error instanceof Error ? error.message : t("den.sync_skill_failed", undefined, { name: title })); - } finally { - setSkillActionId(null); - setSkillActionKind(null); - } - }; - - const handleImportProvider = async (cloudProviderId: string, providerName: string) => { - if (providerActionId()) return; - - setProviderActionId(cloudProviderId); - setProviderActionKind("import"); - setProviderActionError(null); - - try { - const message = await props.connectCloudProvider(cloudProviderId); - setStatusMessage(`${message || t("den.imported_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`); - } catch (error) { - setProviderActionError(error instanceof Error ? error.message : t("den.import_provider_failed", undefined, { name: providerName })); - } finally { - setProviderActionId(null); - setProviderActionKind(null); - } - }; - - const handleRemoveProvider = async (cloudProviderId: string, providerName: string) => { - if (providerActionId()) return; - - setProviderActionId(cloudProviderId); - setProviderActionKind("remove"); - setProviderActionError(null); - - try { - const message = await props.removeCloudProvider(cloudProviderId); - setStatusMessage(`${message || t("den.removed_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`); - } catch (error) { - setProviderActionError(error instanceof Error ? error.message : t("den.remove_provider_failed", undefined, { name: providerName })); - } finally { - setProviderActionId(null); - setProviderActionKind(null); - } - }; - - const handleSyncProvider = async (cloudProviderId: string, providerName: string) => { - if (providerActionId()) return; - - setProviderActionId(cloudProviderId); - setProviderActionKind("sync"); - setProviderActionError(null); - - try { - await props.connectCloudProvider(cloudProviderId); - setStatusMessage(`${t("den.synced_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`); - } catch (error) { - setProviderActionError(error instanceof Error ? error.message : t("den.sync_provider_failed", undefined, { name: providerName })); - } finally { - setProviderActionId(null); - setProviderActionKind(null); - } - }; - - const formatTemplateTimestamp = (value: string | null) => { - if (!value) return tr("dashboard.recently_updated"); - const date = new Date(value); - if (Number.isNaN(date.getTime())) return tr("dashboard.recently_updated"); - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(date); - }; - - const templateCreatorLabel = (template: DenTemplate) => { - const creator = template.creator; - if (!creator) return tr("dashboard.unknown_creator"); - return creator.name?.trim() || creator.email?.trim() || tr("dashboard.unknown_creator"); - }; - - const settingsPanelClass = - "ow-soft-card rounded-[28px] p-5 md:p-6"; - const settingsPanelSoftClass = - "ow-soft-card-quiet rounded-2xl p-4"; - // Keep Cloud badges and controls on design-language tokens so dark mode preserves contrast. - 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 headerStatusBadgeClass = - "inline-flex min-h-10 min-w-[132px] items-center justify-center gap-2 rounded-2xl border border-dls-border bg-dls-hover px-4 text-center text-sm font-medium text-dls-text shadow-sm"; - const sectionPillClass = - "inline-flex items-center gap-1.5 rounded-full border border-dls-border bg-dls-hover px-2.5 py-1 text-[11px] font-medium text-dls-secondary"; - const softNoticeClass = - "rounded-xl border border-dls-border bg-dls-hover px-3 py-2 text-xs text-dls-secondary"; - const quietControlClass = - "border border-dls-border bg-dls-hover text-dls-text shadow-sm"; - - return ( -
- -
-
-
- - {tr("den.cloud_section_title")} -
-
-
- {tr("den.cloud_section_desc")} -
-
- {tr("den.cloud_sleep_hint")} -
-
-
-
- - {summaryLabel()} -
-
- - -
- setBaseUrlDraft(event.currentTarget.value)} - placeholder={DEFAULT_DEN_BASE_URL} - hint={tr("den.cloud_control_plane_url_hint")} - disabled={authBusy() || sessionBusy()} - /> -
- - - -
-
-
- - - {(value) => ( -
- {value()} -
- )} -
- - - {(value) => ( -
- {value()} -
- )} -
-
- }> - setBaseUrlDraft(baseUrl())} - onApplyBaseUrl={() => { - void applyBaseUrl(); - }} - onOpenControlPlane={openControlPlane} - onOpenBrowserAuth={openBrowserAuth} - onToggleManualAuth={() => { - setManualAuthOpen((value) => !value); - setAuthError(null); - }} - onManualAuthInput={setManualAuthInput} - onSubmitManualAuth={() => void submitManualAuth()} - /> - - - -
-
-
-
{tr("den.cloud_account_title")}
-
- {tr("den.cloud_account_hint")} -
-
- -
-
-
-
- {user()?.name || user()?.email} -
-
- {user()?.email} -
-
- -
- -
-
-
{tr("den.active_org_title")}
-
- {tr("den.active_org_hint")} -
-
-
- - -
-
-
- - - {(value) => ( -
- {value()} -
- )} -
-
- -
-
-
-
- - {tr("den.cloud_skills_title")} -
-
- {tr("den.cloud_skills_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_cloud_skills")} - -
-
- -
- - {(row) => { - const actionBusy = createMemo(() => skillActionId() === row.cloudSkillId); - const actionLabel = createMemo(() => { - if (!actionBusy()) return null; - switch (skillActionKind()) { - case "import": - return tr("den.importing"); - case "sync": - return tr("den.syncing"); - default: - return tr("den.removing"); - } - }); - - return ( -
-
-
- {row.title} - - {t("skills.cloud_hub_label", currentLocale(), { name: row.skill?.hubName ?? "" })} - - - {tr("skills.cloud_shared_org")} - - - {tr("skills.cloud_shared_public")} - - - {tr("den.private_badge")} - - - {t("den.installed_name_badge", currentLocale(), { name: row.installedName ?? "" })} - - - - {row.status === "installed" - ? tr("den.imported_badge") - : row.status === "out_of_sync" - ? tr("den.out_of_sync_badge") - : tr("den.removed_from_cloud_badge")} - - -
-
- {row.status === "available" - ? t("den.cloud_skill_detail", currentLocale(), { title: row.title }) - : row.status === "installed" - ? t("den.cloud_skill_imported_detail", currentLocale(), { - name: row.installedName ?? row.title, - }) - : row.status === "out_of_sync" - ? t("den.cloud_skill_sync_detail", currentLocale(), { - name: row.installedName ?? row.title, - }) - : t("den.cloud_skill_removed_detail", currentLocale(), { - name: row.installedName ?? row.title, - })} -
-
-
- - - - -
-
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.cloud_workers_title")} -
-
- {tr("den.cloud_workers_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- {tr("den.no_cloud_workers")} -
-
- -
- - {(worker) => { - const status = createMemo(() => workerStatusMeta(worker.status, tr)); - return ( -
-
-
- - {worker.workerName} - - - {status().label} - - - - {tr("den.worker_mine_badge")} - - -
-
- {worker.provider ? t("den.worker_provider_label", currentLocale(), { provider: worker.provider }) : tr("den.worker_secondary_cloud")} - - {(value) => · {value()}} - -
-
- -
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.team_templates_title")} -
-
- {tr("den.team_templates_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_team_templates")} - -
-
- -
- - {(template) => { - const isMine = () => template.creator?.userId === user()?.id; - const opening = () => openingTemplateId() === template.id; - return ( -
-
-
- - {template.name} - - - {tr("den.team_template_badge")} - - - - {tr("den.worker_mine_badge")} - - -
-
- by {templateCreatorLabel(template)} · {formatTemplateTimestamp(template.createdAt)} -
-
- -
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.skill_hubs_title")} -
-
- {tr("den.skill_hubs_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_skill_hubs")} - -
-
- -
- - {(row) => { - const actionBusy = createMemo(() => skillHubActionId() === row.hubId); - const actionLabel = createMemo(() => { - if (!actionBusy()) return null; - switch (skillHubActionKind()) { - case "import": - return tr("den.importing"); - case "sync": - return tr("den.syncing"); - default: - return tr("den.removing"); - } - }); - return ( -
-
-
- {row.name} - - {t("den.skill_hub_skills_badge", currentLocale(), { - count: row.hub?.skills.length ?? row.importedSkillCount, - })} - - - - {row.status === "imported" - ? tr("den.imported_badge") - : row.status === "out_of_sync" - ? tr("den.out_of_sync_badge") - : tr("den.removed_from_cloud_badge")} - - -
-
- {row.status === "available" - ? t("den.skill_hub_detail", currentLocale(), { count: row.liveSkillCount }) - : row.status === "imported" - ? t("den.skill_hub_imported_detail", currentLocale(), { - count: row.importedSkillCount, - }) - : row.status === "out_of_sync" - ? t("den.skill_hub_sync_detail", currentLocale(), { - liveCount: row.liveSkillCount, - importedCount: row.importedSkillCount, - }) - : t("den.skill_hub_removed_detail", currentLocale(), { - importedCount: row.importedSkillCount, - })} -
-
-
- - - - -
-
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.cloud_providers_title")} -
-
- {tr("den.cloud_providers_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_cloud_providers")} - -
-
- -
- - {(row) => { - const actionBusy = createMemo(() => providerActionId() === row.cloudProviderId); - const actionLabel = createMemo(() => { - if (!actionBusy()) return null; - switch (providerActionKind()) { - case "import": - return tr("den.importing"); - case "sync": - return tr("den.syncing"); - default: - return tr("den.removing"); - } - }); - return ( -
-
-
- {row.name} - - - {row.provider?.providerId ?? row.imported?.providerId} - - - {tr("den.credentials_ready_badge")} - - - - {row.status === "imported" - ? tr("den.imported_badge") - : row.status === "out_of_sync" - ? tr("den.out_of_sync_badge") - : tr("den.removed_from_cloud_badge")} - - -
-
- {row.status === "removed_from_cloud" - ? t("den.cloud_provider_removed_detail", currentLocale(), { - providerId: row.imported?.providerId ?? row.name, - }) - : row.status === "out_of_sync" - ? t("den.cloud_provider_sync_detail", currentLocale(), { - count: row.provider?.models.length ?? 0, - source: row.provider?.source === "custom" ? "custom" : "managed", - }) - : t("den.cloud_provider_detail", currentLocale(), { - count: row.provider?.models.length ?? 0, - source: row.provider?.source === "custom" ? "custom" : "managed", - })} -
-
-
- - - - -
-
- ); - }} -
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/flyout-item.tsx b/apps/app/src/app/components/flyout-item.tsx deleted file mode 100644 index c8855c0b..00000000 --- a/apps/app/src/app/components/flyout-item.tsx +++ /dev/null @@ -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 ( -
- - - - - - - - - - {props.item.label} -
- ); -} diff --git a/apps/app/src/app/components/mcp-auth-modal.tsx b/apps/app/src/app/components/mcp-auth-modal.tsx deleted file mode 100644 index 8d0b04a1..00000000 --- a/apps/app/src/app/components/mcp-auth-modal.tsx +++ /dev/null @@ -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; - onReloadEngine?: () => void | Promise; - 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; -}; - -export default function McpAuthModal(props: McpAuthModalProps) { - const translate = (key: string, replacements?: Record) => { - 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(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(null); - const [authorizationUrl, setAuthorizationUrl] = createSignal(null); - const [callbackInput, setCallbackInput] = createSignal(""); - const [manualAuthBusy, setManualAuthBusy] = createSignal(false); - const [cliAuthBusy, setCliAuthBusy] = createSignal(false); - const [cliAuthResult, setCliAuthResult] = createSignal(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(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 ( - -
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-
-

- {translate("mcp.auth.connect_server", { server: serverName() })} -

-

{translate("mcp.auth.open_browser_signin")}

-
- -
- - {/* Content */} -
- -
-
- -
-
-

- {translate("mcp.auth.waiting_authorization")} -

-

- {translate("mcp.auth.follow_browser_steps")} -

- -
-
-
- - -
-
- -
-
-

- {props.reloadBlocked - ? translate("mcp.auth.waiting_for_conversation_title") - : translate("mcp.auth.applying_changes_title")} -

-

- {props.reloadBlocked - ? translate("mcp.auth.waiting_for_conversation_body") - : translate("mcp.auth.applying_changes_body")} -

-
- 0}> -
- - {(session) => ( -
- - {translate("mcp.auth.waiting_for_session", { session: session.title })} - - -
- )} -
-
-
-
-
- - -
-
-
- -
-
-

{translate("mcp.auth.already_connected")}

-

- {translate("mcp.auth.already_connected_description", { server: serverName() })} -

-
-
-

- {translate("mcp.auth.configured_previously")} -

-
-
- - -
-

{reloadNotice()}

- -
- - - - -
-
-
- - -
-

{error()}

- - -
- - - - -
-
- - -
- -
-
- - -
-

{translate("mcp.auth.invalid_refresh_token")}

- - - - - -
- {translate("mcp.auth.reauth_cli_hint", { server: serverName() })} -
-
-
- -
- {translate("mcp.auth.reauth_remote_hint")} -
-
- -
{cliAuthResult()}
-
-
-
-
-
- - -
-
- {translate("mcp.auth.manual_finish_title")} -
-
- {translate("mcp.auth.manual_finish_hint")} -
-
-
-
{translate("mcp.auth.authorization_link")}
-
- {authorizationUrl()} -
-
- -
- setCallbackInput(event.currentTarget.value)} - /> -
- {translate("mcp.auth.port_forward_hint")} -
-
- -
-
-
- - -
-
-
- 1 -
-
-

{translate("mcp.auth.step1_title")}

-

- {translate("mcp.auth.step1_description", { server: serverName() })} -

-
-
- -
-
- 2 -
-
-

{translate("mcp.auth.step2_title")}

-

- {translate("mcp.auth.step2_description")} -

-
-
- -
-
- 3 -
-
-

{translate("mcp.auth.step3_title")}

-

- {translate("mcp.auth.step3_description")} -

-
-
-
- -
-
-

{translate("mcp.auth.waiting_authorization")}

-

- {translate("mcp.auth.follow_browser_steps")} -

- -
-
-
-
- - {/* Footer */} -
- - - - - - - -
-
-
- - ); -} diff --git a/apps/app/src/app/components/model-picker-modal.tsx b/apps/app/src/app/components/model-picker-modal.tsx deleted file mode 100644 index fcddced2..00000000 --- a/apps/app/src/app/components/model-picker-modal.tsx +++ /dev/null @@ -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) => 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(); - const items: { providerID: string; title: string; matchCount: number }[] = []; - const counts = new Map(); - - 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(() => { - 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 ( -
{ - 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, - }); - }} - > -
- -
-
- {opt.title} -
-
- {opt.description ?? opt.providerID} - - {opt.providerID}/{opt.modelID} - -
- -
{opt.footer}
-
- 0}> -
e.stopPropagation()}> - {opt.behaviorTitle}: -
e.stopPropagation()}> - - {(option) => ( - - )} - -
-
-
-
-
-
- ); - }; - - const renderProviderLink = (provider: { providerID: string; title: string; matchCount: number }, index: number) => ( -
{ - 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(); - }} - > -
- -
-
- {provider.title} -
-
- {translate("model_picker.connect_provider_hint")} - - {translate(provider.matchCount === 1 ? "model_picker.model_count_one" : "model_picker.model_count", { count: provider.matchCount })} - -
-
-
-
- ); - - return ( - -
-
-
-
-
-

- {translate(props.target === "default" ? "model_picker.default_model_title" : "model_picker.chat_model_title")} -

-

- {translate(props.target === "default" - ? "model_picker.default_model_desc" - : "model_picker.chat_model_desc")} -

-
- -
- -
-
- - (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" - /> -
- -
- {translate("settings.showing_models", { count: props.filteredOptions.length, total: props.options.length })} -
-
-
- -
- 0}> -
-
- {translate("model_picker.recommended")} -
- {({ opt, index }) => renderOption(opt, index)} -
-
- - 0}> -
-
- {translate("model_picker.other_connected_models")} -
- {({ opt, index }) => renderOption(opt, index)} -
-
- - 0}> -
-
- {translate("model_picker.more_providers")} -
- - {(provider) => renderProviderLink(provider, provider.index)} - -
-
- - -
- {translate("model_picker.no_results")} -
-
-
- -
- -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/part-view.tsx b/apps/app/src/app/components/part-view.tsx deleted file mode 100644 index 4105e0f9..00000000 --- a/apps/app/src/app/components/part-view.tsx +++ /dev/null @@ -1,1164 +0,0 @@ -import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { marked } from "marked"; -import type { Part } from "@opencode-ai/sdk/v2/client"; -import { File } from "lucide-solid"; -import { isTauriRuntime, safeStringify, summarizeStep } from "../utils"; -import { usePlatform } from "../context/platform"; -import { perfNow, recordPerfLog } from "../lib/perf-log"; - -type Props = { - part: Part; - developerMode?: boolean; - showThinking?: boolean; - tone?: "light" | "dark"; - workspaceRoot?: string; - renderMarkdown?: boolean; - markdownThrottleMs?: number; - highlightQuery?: string; -}; - -type LinkType = "url" | "file"; - -type TextSegment = - | { kind: "text"; value: string } - | { kind: "link"; value: string; href: string; type: LinkType }; - -type LinkDetectionOptions = { - allowFilePaths?: boolean; -}; - -const WEB_LINK_RE = /^(?:https?:\/\/|www\.)/i; -const FILE_URI_RE = /^file:\/\//i; -const URI_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/; -const WINDOWS_PATH_RE = /^[A-Za-z]:[\\/][^\s"'`\)\]\}>]+$/; -const POSIX_PATH_RE = /^\/(?!\/)[^\s"'`\)\]\}>][^\s"'`\)\]\}>]*$/; -const TILDE_PATH_RE = /^~\/[^\s"'`\)\]\}>][^\s"'`\)\]\}>]*$/; -const BARE_FILENAME_RE = /^(?!\.)(?!.*\.\.)(?:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)$/; -const SAFE_PATH_CHAR_RE = /[^\s"'`\)\]\}>]/; - -const stripFileReferenceSuffix = (value: string) => { - const withoutQueryOrFragment = value.replace(/[?#].*$/, "").trim(); - if (!withoutQueryOrFragment) return ""; - return withoutQueryOrFragment.replace(/:(\d+)(?::\d+)?$/, ""); -}; - -const isWorkspaceRelativeFilePath = (value: string) => { - const stripped = stripFileReferenceSuffix(value); - if (!stripped) return false; - - const normalized = stripped.replace(/\\/g, "/"); - if (!normalized.includes("/")) return false; - if (normalized.startsWith("/") || normalized.startsWith("~/") || normalized.startsWith("//")) { - return false; - } - if (URI_SCHEME_RE.test(normalized)) return false; - if (/^[A-Za-z]:\//.test(normalized)) return false; - - const segments = normalized.split("/"); - if (!segments.length) return false; - return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); -}; - -const isRelativeFilePath = (value: string) => { - if (value === "." || value === "..") return false; - - const normalized = value.replace(/\\/g, "/"); - const segments = normalized.split("/"); - const hasNonTraversalSegment = segments.some((segment) => segment && segment !== "." && segment !== ".."); - - if (normalized.startsWith("./") || normalized.startsWith("../")) { - return hasNonTraversalSegment; - } - - const [firstSegment, secondSegment] = normalized.split("/"); - if (!secondSegment || firstSegment.length <= 1) return false; - if (secondSegment === "." || secondSegment === "..") return false; - return firstSegment.startsWith(".") && SAFE_PATH_CHAR_RE.test(secondSegment); -}; - -const isBareRelativeFilePath = (value: string) => { - if (value.includes("/") || value.includes("\\") || value.includes(":")) return false; - if (!BARE_FILENAME_RE.test(value)) return false; - - const extension = value.split(".").pop() ?? ""; - if (!/[A-Za-z]/.test(extension)) return false; - - const dotCount = (value.match(/\./g) ?? []).length; - if (dotCount === 1 && !value.includes("_") && !value.includes("-")) { - const [name, tld] = value.split("."); - if (/^[A-Za-z]{2,24}$/.test(name ?? "") && /^[A-Za-z]{2,10}$/.test(tld ?? "")) { - return false; - } - } - - return true; -}; - -const LEADING_PUNCTU = /[\"'`\(\[\{<]/; -const TRAILING_PUNCTU = /[\"'`\)\]}>.,:;!?]/; - -const isLikelyWebLink = (value: string) => WEB_LINK_RE.test(value); - -const isLikelyFilePath = (value: string) => { - if (FILE_URI_RE.test(value)) return true; - if (WINDOWS_PATH_RE.test(value)) return true; - if (POSIX_PATH_RE.test(value)) return true; - if (TILDE_PATH_RE.test(value)) return true; - if (isRelativeFilePath(value)) return true; - if (isBareRelativeFilePath(value)) return true; - if (isWorkspaceRelativeFilePath(value)) return true; - - return false; -}; - -const parseLinkFromToken = ( - token: string, - options: LinkDetectionOptions = {}, -): { href: string; type: LinkType; value: string } | null => { - let start = 0; - let end = token.length; - - while (start < end && LEADING_PUNCTU.test(token[start] ?? "")) { - start += 1; - } - - while (end > start && TRAILING_PUNCTU.test(token[end - 1] ?? "")) { - end -= 1; - } - - const value = token.slice(start, end); - if (!value) return null; - - if (isLikelyWebLink(value)) { - return { - value, - type: "url", - href: value.toLowerCase().startsWith("www.") ? `https://${value}` : value, - }; - } - - if ((options.allowFilePaths ?? true) && isLikelyFilePath(value)) { - return { - value, - type: "file", - href: value, - }; - } - - return null; -}; - -const splitTextTokens = (text: string, options: LinkDetectionOptions = {}): TextSegment[] => { - const tokens: TextSegment[] = []; - const matches = text.matchAll(/\S+/g); - let position = 0; - - for (const match of matches) { - const token = match[0] ?? ""; - const index = match.index ?? 0; - - if (index > position) { - tokens.push({ kind: "text", value: text.slice(position, index) }); - } - - const link = parseLinkFromToken(token, options); - if (!link) { - tokens.push({ kind: "text", value: token }); - } else { - const start = token.indexOf(link.value); - if (start > 0) { - tokens.push({ kind: "text", value: token.slice(0, start) }); - } - tokens.push({ kind: "link", value: link.value, href: link.href, type: link.type }); - const end = start + link.value.length; - if (end < token.length) { - tokens.push({ kind: "text", value: token.slice(end) }); - } - } - - position = index + token.length; - } - - if (position < text.length) { - tokens.push({ kind: "text", value: text.slice(position) }); - } - - return tokens; -}; - -const escapeHtml = (value: string) => - value.replace(/&/g, "&").replace(//g, ">"); - -const renderInlineTextWithLinks = (text: string, options: LinkDetectionOptions = {}) => { - const tokens = splitTextTokens(text, options); - return tokens - .map((token) => { - if (token.kind === "text") return escapeHtml(token.value); - return `${escapeHtml(token.value)}`; - }) - .join(""); -}; - -const normalizeRelativePath = (relativePath: string, workspaceRoot: string) => { - const root = workspaceRoot.trim().replace(/\\/g, "/").replace(/\/+$/g, ""); - if (!root) return null; - - const relative = relativePath.trim().replace(/\\/g, "/"); - if (!relative) return null; - - const isPosixRoot = root.startsWith("/"); - const rootValue = isPosixRoot ? root.slice(1) : root; - const rootParts = rootValue.split("/").filter((value) => value.length > 0); - const isWindowsDrive = /^[A-Za-z]:$/.test(rootParts[0] ?? ""); - const resolved: string[] = [...rootParts]; - const segments = relative.split("/"); - - for (const segment of segments) { - if (!segment || segment === ".") continue; - - if (segment === "..") { - if (!(isWindowsDrive && resolved.length === 1)) { - resolved.pop(); - } - continue; - } - - resolved.push(segment); - } - - const normalized = resolved.join("/"); - if (isPosixRoot) return `/${normalized || ""}` || "/"; - return normalized; -}; - -const normalizeFilePath = (href: string, workspaceRoot: string): string | null => { - const strippedHref = stripFileReferenceSuffix(href); - if (!strippedHref) return null; - - if (FILE_URI_RE.test(href)) { - try { - const parsed = new URL(href); - if (parsed.protocol !== "file:") return null; - const raw = decodeURIComponent(parsed.pathname || ""); - if (!raw) return null; - if (/^\/[A-Za-z]:\//.test(raw)) { - return raw.slice(1); - } - if (parsed.hostname && !parsed.pathname.startsWith(`/${parsed.hostname}`) && !raw.startsWith("/")) { - return `/${parsed.hostname}${raw}`; - } - return raw; - } catch { - const raw = decodeURIComponent(href.replace(/^file:\/\//, "")); - if (!raw) return null; - return raw; - } - } - - const trimmed = strippedHref.trim(); - if (isRelativeFilePath(trimmed) || isBareRelativeFilePath(trimmed) || isWorkspaceRelativeFilePath(trimmed)) { - if (!workspaceRoot) return null; - return normalizeRelativePath(trimmed, workspaceRoot); - } - - return href; -}; - -function clampText(text: string, max = 800) { - if (text.length <= max) return text; - return `${text.slice(0, max)}\n\n… (truncated)`; -} - -const SEARCH_HIGHLIGHT_MARK_ATTR = "data-search-highlight"; - -const clearTextHighlights = (root: HTMLElement) => { - const marks = root.querySelectorAll(`mark[${SEARCH_HIGHLIGHT_MARK_ATTR}="true"]`); - marks.forEach((mark) => { - const parent = mark.parentNode; - if (!parent) return; - parent.replaceChild(document.createTextNode(mark.textContent ?? ""), mark); - }); - root.normalize(); -}; - -const applyTextHighlights = (root: HTMLElement, query: string) => { - clearTextHighlights(root); - const needle = query.trim().toLowerCase(); - if (!needle) return; - - const walker = document.createTreeWalker( - root, - NodeFilter.SHOW_TEXT, - { - acceptNode(node) { - const value = node.nodeValue ?? ""; - if (!value.trim()) return NodeFilter.FILTER_REJECT; - const parent = node.parentElement; - if (!parent) return NodeFilter.FILTER_REJECT; - if (parent.closest("pre, code")) return NodeFilter.FILTER_REJECT; - if (parent.tagName === "SCRIPT" || parent.tagName === "STYLE") return NodeFilter.FILTER_REJECT; - return value.toLowerCase().includes(needle) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - }, - }, - ); - - const nodes: Text[] = []; - let current = walker.nextNode(); - while (current) { - nodes.push(current as Text); - current = walker.nextNode(); - } - - nodes.forEach((node) => { - const text = node.nodeValue ?? ""; - const lower = text.toLowerCase(); - let searchIndex = 0; - const fragment = document.createDocumentFragment(); - - while (searchIndex < text.length) { - const matchIndex = lower.indexOf(needle, searchIndex); - if (matchIndex === -1) { - fragment.appendChild(document.createTextNode(text.slice(searchIndex))); - break; - } - - if (matchIndex > searchIndex) { - fragment.appendChild(document.createTextNode(text.slice(searchIndex, matchIndex))); - } - - const mark = document.createElement("mark"); - mark.setAttribute(SEARCH_HIGHLIGHT_MARK_ATTR, "true"); - mark.className = "rounded px-0.5 bg-amber-4/70 text-current"; - mark.textContent = text.slice(matchIndex, matchIndex + needle.length); - fragment.appendChild(mark); - searchIndex = matchIndex + needle.length; - } - - node.parentNode?.replaceChild(fragment, node); - }); -}; - -function useThrottledValue(value: () => T, delayMs: number | (() => number) = 80) { - const [state, setState] = createSignal(value()); - let timer: ReturnType | undefined; - let hasEmitted = false; - - createEffect(() => { - const next = value(); - const delay = typeof delayMs === "function" ? delayMs() : delayMs; - // Always apply the first non-empty value synchronously so the initial - // render never falls through to the raw-text fallback. - if (!delay || !hasEmitted) { - hasEmitted = true; - setState(() => next); - return; - } - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - setState(() => next); - timer = undefined; - }, delay); - }); - - onCleanup(() => { - if (timer) clearTimeout(timer); - }); - - return state; -} - -const MARKDOWN_CACHE_MAX_ENTRIES = 100; -const LARGE_TEXT_COLLAPSE_CHAR_THRESHOLD = 12_000; -const LARGE_TEXT_PREVIEW_CHARS = 3_200; -const markdownHtmlCache = new Map(); -const expandedLargeTextPartIds = new Set(); -const rendererByTone = new Map<"light" | "dark", ReturnType>(); - -function markdownCacheKey(tone: "light" | "dark", text: string) { - return `${tone}\u0000${text}`; -} - -function readMarkdownCache(key: string) { - const cached = markdownHtmlCache.get(key); - if (cached === undefined) return; - markdownHtmlCache.delete(key); - markdownHtmlCache.set(key, cached); - return cached; -} - -function writeMarkdownCache(key: string, html: string) { - if (markdownHtmlCache.has(key)) { - markdownHtmlCache.delete(key); - } - markdownHtmlCache.set(key, html); - - while (markdownHtmlCache.size > MARKDOWN_CACHE_MAX_ENTRIES) { - const oldest = markdownHtmlCache.keys().next().value; - if (!oldest) break; - markdownHtmlCache.delete(oldest); - } -} - -function rendererForTone(tone: "light" | "dark") { - const cached = rendererByTone.get(tone); - if (cached) return cached; - const next = createCustomRenderer(tone); - rendererByTone.set(tone, next); - return next; -} - -function createCustomRenderer(tone: "light" | "dark") { - const renderer = new marked.Renderer(); - const codeBlockClass = - tone === "dark" - ? "bg-gray-12/10 border-gray-11/20 text-gray-12" - : "bg-gray-1/80 border-gray-6/70 text-gray-12"; - const inlineCodeClass = - tone === "dark" - ? "bg-gray-12/15 text-gray-12" - : "bg-gray-2/70 text-gray-12"; - - const isSafeUrl = (url: string) => { - const normalized = (url || "").trim().toLowerCase(); - if (normalized.startsWith("javascript:")) return false; - // Allow data:image/* URIs (base64-encoded images from AI models) but block - // other data: schemes (e.g. data:text/html) which could be used for XSS. - if (normalized.startsWith("data:")) return normalized.startsWith("data:image/"); - return true; - }; - - renderer.html = ({ text }) => escapeHtml(text); - - renderer.code = ({ text, lang }) => { - const language = lang || ""; - return ` -
- ${ - language - ? `
${escapeHtml(language)}
` - : "" - } -
${escapeHtml(
-          text
-        )}
-
- `; - }; - - renderer.codespan = ({ text }) => { - return `${escapeHtml( - text - )}`; - }; - - renderer.link = ({ href, title, text }) => { - const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "#") : "#"; - const safeTitle = title ? escapeHtml(title) : ""; - return ` - - ${text} - - `; - }; - - renderer.image = ({ href, title, text }) => { - const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "") : ""; - const safeTitle = title ? escapeHtml(title) : ""; - return ` - ${escapeHtml(text || - `; - }; - - return renderer; -} - -export default function PartView(props: Props) { - const platform = usePlatform(); - const p = () => props.part; - const developerMode = () => props.developerMode ?? false; - const tone = () => props.tone ?? "light"; - const showThinking = () => props.showThinking ?? true; - const renderMarkdown = () => props.renderMarkdown ?? false; - const markdownThrottleMs = () => Math.max(0, props.markdownThrottleMs ?? 100); - const textPartStableId = createMemo(() => { - if (p().type !== "text") return ""; - const record = p() as { id?: string | number; messageID?: string | number }; - const partId = record.id; - if (typeof partId === "string") return partId; - if (typeof partId === "number") return String(partId); - const messageId = record.messageID; - if (typeof messageId === "string") return `msg:${messageId}`; - if (typeof messageId === "number") return `msg:${String(messageId)}`; - return ""; - }); - const isPersistedExpanded = () => { - const id = textPartStableId(); - return Boolean(id && expandedLargeTextPartIds.has(id)); - }; - const [expandedLongText, setExpandedLongText] = createSignal(isPersistedExpanded()); - createEffect(() => { - if (!isPersistedExpanded()) return; - if (expandedLongText()) return; - setExpandedLongText(true); - }); - const rawText = createMemo(() => { - if (p().type !== "text") return ""; - return "text" in p() ? String((p() as { text: string }).text ?? "") : ""; - }); - const shouldCollapseLongText = createMemo( - () => renderMarkdown() && p().type === "text" && rawText().length >= LARGE_TEXT_COLLAPSE_CHAR_THRESHOLD, - ); - const collapsedLongText = createMemo( - () => shouldCollapseLongText() && !(expandedLongText() || isPersistedExpanded()), - ); - const collapsedPreviewText = createMemo(() => { - const text = rawText(); - if (!collapsedLongText()) return text; - if (text.length <= LARGE_TEXT_PREVIEW_CHARS) return text; - return `${text.slice(0, LARGE_TEXT_PREVIEW_CHARS)}\n\n...`; - }); - let textContainerEl: HTMLDivElement | undefined; - const fileInfo = () => { - if (p().type !== "file") return null; - const part = p() as { - filename?: string; - url?: string; - mime?: string; - source?: { - type?: string; - path?: string; - name?: string; - clientName?: string; - uri?: string; - }; - }; - const source = part.source ?? {}; - const sourceType = typeof source.type === "string" ? source.type : ""; - const sourcePath = typeof source.path === "string" ? source.path : ""; - const sourceName = typeof source.name === "string" ? source.name : ""; - const sourceClient = typeof source.clientName === "string" ? source.clientName : ""; - const sourceUri = typeof source.uri === "string" ? source.uri : ""; - const filename = typeof part.filename === "string" ? part.filename : ""; - const url = typeof part.url === "string" ? part.url : ""; - const pathName = sourcePath ? sourcePath.split(/[\\/]/).pop() ?? sourcePath : ""; - const title = filename || pathName || sourceName || url || "File"; - const detail = (() => { - if (sourceType === "symbol") { - if (sourcePath) return `${sourceName || "symbol"} - ${sourcePath}`; - return sourceName || ""; - } - if (sourceType === "resource") { - const details = [sourceClient, sourceUri].filter(Boolean).join(" - "); - return details || url; - } - return sourcePath || url; - })(); - const mime = typeof part.mime === "string" ? part.mime : ""; - return { title, detail, mime }; - }; - - const textClass = () => (tone() === "dark" ? "text-gray-12" : "text-gray-12"); - const subtleTextClass = () => (tone() === "dark" ? "text-gray-12/70" : "text-gray-11"); - const panelBgClass = () => (tone() === "dark" ? "bg-gray-2/10" : "bg-gray-2/30"); - const toolOnly = () => true; - const showToolOutput = () => developerMode(); - const markdownSource = createMemo(() => { - if (!renderMarkdown() || p().type !== "text") return ""; - if (collapsedLongText()) return ""; - return rawText(); - }); - const throttledMarkdownSource = useThrottledValue(markdownSource, markdownThrottleMs); - const renderedMarkdown = createMemo(() => { - if (!renderMarkdown() || p().type !== "text") return null; - if (collapsedLongText()) return null; - const text = throttledMarkdownSource(); - if (!text.trim()) return ""; - - const toneKey = tone(); - const cacheKey = markdownCacheKey(toneKey, text); - const cachedHtml = readMarkdownCache(cacheKey); - if (cachedHtml !== undefined) return cachedHtml; - - try { - const startedAt = perfNow(); - const renderer = rendererForTone(toneKey); - const result = marked.parse(text, { - breaks: true, - gfm: true, - renderer, - async: false - }); - const parseMs = Math.round((perfNow() - startedAt) * 100) / 100; - if (developerMode() && (parseMs >= 12 || text.length >= 6_000)) { - const record = p() as { id?: string; messageID?: string }; - recordPerfLog(true, "session.render", "markdown-parse", { - partID: record.id ?? null, - messageID: record.messageID ?? null, - chars: text.length, - ms: parseMs, - }); - } - - const html = typeof result === "string" ? result : ""; - // If marked returned empty HTML for non-empty source, treat as a parse - // failure so the fallback renders plain text instead of blank space. - if (!html && text.trim()) { - return null; - } - writeMarkdownCache(cacheKey, html); - return html; - } catch (error) { - console.error('Markdown parsing error:', error); - return null; - } - }); - - const openLink = async (href: string, type: LinkType) => { - if (type === "url") { - platform.openLink(href); - return; - } - - const filePath = normalizeFilePath(href, props.workspaceRoot ?? ""); - if (!filePath) return; - - if (!isTauriRuntime()) { - platform.openLink(href.startsWith("file://") ? href : `file://${filePath}`); - return; - } - - try { - const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); - await revealItemInDir(filePath).catch(() => openPath(filePath)); - } catch { - platform.openLink(href.startsWith("file://") ? href : `file://${filePath}`); - } - }; - - const openMarkdownLink = async (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - const anchor = target.closest("a"); - if (!(anchor instanceof HTMLAnchorElement)) return; - - const href = anchor.getAttribute("href")?.trim(); - if (!href) return; - const link = parseLinkFromToken(href); - if (!link) return; - - event.preventDefault(); - event.stopPropagation(); - await openLink(link.href, link.type); - }; - - const renderTextWithLinks = () => { - const text = "text" in p() ? String((p() as { text: string }).text) : ""; - if (!text) return {""}; - - const tokens = splitTextTokens(text); - return ( - - - {(token) => - token.kind === "link" ? ( - { - event.preventDefault(); - event.stopPropagation(); - void openLink(token.href, token.type); - }} - > - {token.value} - - ) : ( - token.value - ) - } - - - ); - }; - - createEffect(() => { - if (p().type !== "text") return; - const root = textContainerEl; - if (!root) return; - const query = props.highlightQuery ?? ""; - const markdownSnapshot = renderMarkdown() ? renderedMarkdown() : null; - queueMicrotask(() => { - if (!textContainerEl || textContainerEl !== root) return; - applyTextHighlights(textContainerEl, query); - }); - void markdownSnapshot; - }); - - const toolData = () => { - if (p().type !== "tool") return null; - return p() as any; - }; - - let toolSummaryRuns = 0; - let lastToolSummaryAt = 0; - const toolSummary = createMemo(() => { - if (p().type !== "tool") return null; - const startedAt = perfNow(); - const summary = summarizeStep(p()); - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - toolSummaryRuns += 1; - const now = Date.now(); - const sinceLastMs = lastToolSummaryAt > 0 ? now - lastToolSummaryAt : null; - lastToolSummaryAt = now; - - if (developerMode() && (elapsedMs >= 4 || (toolSummaryRuns >= 6 && (sinceLastMs ?? 0) < 300))) { - const record = p() as { id?: string; messageID?: string }; - recordPerfLog(true, "session.render", "tool-summary", { - partID: record.id ?? null, - messageID: record.messageID ?? null, - runs: toolSummaryRuns, - sinceLastMs, - ms: elapsedMs, - }); - } - - return summary; - }); - const toolState = () => toolData()?.state ?? {}; - const toolName = () => (toolData()?.tool ? String(toolData()?.tool) : "tool"); - const toolTitle = () => { - const title = toolSummary()?.title; - if (title) return title; - return toolState()?.title ? String(toolState().title) : toolName(); - }; - const toolStatus = () => (toolState()?.status ? String(toolState().status) : "unknown"); - const toolSubtitle = () => { - const detail = toolSummary()?.detail; - if (detail) return detail; - if (toolState()?.subtitle || toolState()?.detail || toolState()?.summary) { - return String(toolState().subtitle ?? toolState().detail ?? toolState().summary); - } - return ""; - }; - - const extractDiff = () => { - const state = toolState(); - const candidates = [state?.diff, state?.patch, state?.output]; - for (const candidate of candidates) { - if (typeof candidate !== "string") continue; - if (candidate.includes("@@") || candidate.includes("+++ ") || candidate.includes("--- ")) { - return candidate; - } - } - return null; - }; - - const diffText = createMemo(() => (p().type === "tool" ? extractDiff() : null)); - const normalizeToolText = (value: unknown) => { - if (typeof value !== "string") return ""; - return value.replace(/(?:\r?\n\s*)+$/, ""); - }; - const diffTextNormalized = createMemo(() => normalizeToolText(diffText())); - const diffLines = createMemo(() => (diffTextNormalized() ? diffTextNormalized().split("\n") : [])); - const diffLineClass = (line: string) => { - if (line.startsWith("+")) return "text-green-11 bg-green-1/40"; - if (line.startsWith("-")) return "text-red-11 bg-red-1/40"; - if (line.startsWith("@@")) return "text-blue-11 bg-blue-1/30"; - return "text-gray-12"; - }; - - const toolOutput = () => normalizeToolText(toolState()?.output); - const hasReadXmlOutput = createMemo(() => { - if (toolName().toLowerCase() !== "read") return false; - const output = toolOutput().trimStart(); - return output.startsWith("") || output.startsWith("") || output.startsWith(""); - }); - - const toolError = () => { - const error = toolState()?.error; - return typeof error === "string" ? error : null; - }; - - const toolInput = () => toolState()?.input; - - const diagnostics = () => { - const items = toolState()?.diagnostics; - return Array.isArray(items) ? items : []; - }; - - const formatDiagnosticLocation = (diagnostic: any) => { - const raw = diagnostic?.file ?? diagnostic?.path ?? diagnostic?.uri ?? ""; - const file = typeof raw === "string" ? raw.replace(/^file:\/\//, "") : ""; - const line = diagnostic?.line ?? diagnostic?.range?.start?.line; - const character = diagnostic?.character ?? diagnostic?.range?.start?.character; - const location = - typeof line === "number" - ? `${line + 1}${typeof character === "number" ? `:${character + 1}` : ""}` - : ""; - return `${file}${file && location ? ":" : ""}${location}`.trim(); - }; - - const formatDiagnosticLabel = (diagnostic: any) => { - const severity = diagnostic?.severity ?? diagnostic?.level; - if (typeof severity === "string") return severity; - if (severity === 1) return "error"; - if (severity === 2) return "warning"; - if (severity === 3) return "info"; - if (severity === 4) return "hint"; - return "diagnostic"; - }; - - const isLargeOutput = createMemo(() => toolOutput().length > 800); - - const [expandedOutput, setExpandedOutput] = createSignal(false); - const outputPreview = createMemo(() => { - const output = toolOutput(); - if (!output) return ""; - if (isLargeOutput() && !expandedOutput()) { - return `${output.slice(0, 800)}\n\n… (truncated)`; - } - return output; - }); - - const toolImages = () => { - const state = toolState(); - const candidates = Array.isArray(state?.images) ? state.images : []; - return candidates - .map((item: any) => { - if (typeof item === "string") return { src: item, alt: "" }; - const src = item?.url ?? item?.src ?? item?.data; - if (!src) return null; - if (item?.data && item?.mediaType && !String(item.data).startsWith("data:")) { - return { src: `data:${item.mediaType};base64,${item.data}`, alt: item?.alt ?? "" }; - } - return { src, alt: item?.alt ?? "" }; - }) - .filter(Boolean); - }; - - const inlineImage = () => { - if (p().type !== "file") return null; - const record = p() as any; - const mime = typeof record?.mime === "string" ? record.mime : ""; - if (!mime.startsWith("image/")) return null; - const src = record?.url ?? record?.src ?? record?.data ?? record?.source; - if (!src) return null; - if (record?.data && record?.mediaType && !String(record.data).startsWith("data:")) { - return `data:${record.mediaType};base64,${record.data}`; - } - return src as string; - }; - - return ( - - - -
-
{ - textContainerEl = el; - }} - class={`whitespace-pre-wrap break-words text-[14px] leading-relaxed max-h-[22rem] overflow-hidden ${textClass()}`.trim()} - > - {collapsedPreviewText()} -
- -
-
- { - textContainerEl = el; - }} - class={`whitespace-pre-wrap break-words ${textClass()}`.trim()} - > - {renderTextWithLinks()} -
- } - > - {/* null = parse error → plain text; "" = empty/pending → nothing; string = rendered HTML */} - -
{ textContainerEl = el; }} - class={`whitespace-pre-wrap break-words ${textClass()}`.trim()} - > - {renderTextWithLinks()} -
-
- -
{ textContainerEl = el; }} - class={`markdown-content max-w-none ${textClass()} - [&_strong]:font-semibold - [&_em]:italic - [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:my-4 - [&_h2]:text-xl [&_h2]:font-bold [&_h2]:my-3 - [&_h3]:text-lg [&_h3]:font-bold [&_h3]:my-2 - [&_p]:my-3 [&_p]:leading-relaxed - [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-3 - [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-3 - [&_li]:my-1 - [&_blockquote]:border-l-4 [&_blockquote]:border-dls-border [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic - [&_table]:w-full [&_table]:border-collapse [&_table]:my-4 - [&_th]:border [&_th]:border-dls-border [&_th]:p-2 [&_th]:bg-dls-hover - [&_td]:border [&_td]:border-dls-border [&_td]:p-2 - `.trim()} - innerHTML={renderedMarkdown()!} - onClick={openMarkdownLink} - /> - - - - - - - {(info) => ( -
-
- -
-
-
{info().title}
- -
{info().detail}
-
-
- -
- {info().mime} -
-
-
- )} -
-
- - - -
- Thinking -
-              {clampText(String((p() as { text: string }).text), 2000)}
-            
-
-
-
- - - -
-
-
-
- {toolTitle()} -
-
{toolName()}
-
-
- {toolStatus()} -
-
- - -
{toolSubtitle()}
-
- - 0}> -
-
Diagnostics
-
- - {(diag: any) => ( -
-
-
{String(diag?.message ?? "")}
- -
- {[diag?.source, diag?.code].filter(Boolean).join(" · ")} -
-
-
-
-
{formatDiagnosticLabel(diag)}
- -
{formatDiagnosticLocation(diag)}
-
-
-
- )} -
-
-
-
- - -
-
Diff
-
- - {(line) => ( -
- {line || " "} -
- )} -
-
-
-
- - 0}> -
- - {(image: any) => ( - {image.alt - )} - -
-
- - -
- {toolError()} -
-
- - -
-                {outputPreview()}
-              
-
- - -
- Raw read output -
-                  {outputPreview()}
-                
-
-
- - - - - - -
- Input -
-                  {safeStringify(toolInput())}
-                
-
-
-
-
-
- - - - - - -
- {p().type === "step-start" ? "Step started" : "Step finished"} - - - {" "}· {String((p() as any).reason)} - - -
-
- - - -
-            {safeStringify(p())}
-          
-
-
- - ); -} diff --git a/apps/app/src/app/components/question-modal.tsx b/apps/app/src/app/components/question-modal.tsx deleted file mode 100644 index e77b0642..00000000 --- a/apps/app/src/app/components/question-modal.tsx +++ /dev/null @@ -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([]); - const [currentSelection, setCurrentSelection] = createSignal([]); - 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 ( - -
-
-
-
-
- -
-
-

- {currentQuestion()!.header || t("common.question")} -

-
- {t("question_modal.question_counter", undefined, { current: currentIndex() + 1, total: props.questions.length })} -
-
-
-

- {currentQuestion()!.question} -

-
- -
-
- - {(opt, idx) => { - const isSelected = () => currentSelection().includes(opt.description); - const isFocused = () => focusedOptionIndex() === idx(); - - return ( - - ); - }} - -
- - -
- - 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(); - } - }} - /> -
-
-
- -
-
- ↑↓ - {t("common.navigate")} - - {t("common.select")} -
- -
- - - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/reload-workspace-toast.tsx b/apps/app/src/app/components/reload-workspace-toast.tsx deleted file mode 100644 index 99bcf958..00000000 --- a/apps/app/src/app/components/reload-workspace-toast.tsx +++ /dev/null @@ -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 ( - -
-
-
- -
- -
-
-
-
- {props.title} - - - Active tasks - - -
- - -
-
- {props.hasActiveRuns ? ( - Reloading will stop active tasks. - ) : props.error ? ( - {props.error} - ) : ( - getDescription() - )} -
- -
- - {props.warning} -
-
- -
Blocked: {props.blockedReason}
-
-
-
-
- - -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/rename-session-modal.tsx b/apps/app/src/app/components/rename-session-modal.tsx deleted file mode 100644 index 9bdcde38..00000000 --- a/apps/app/src/app/components/rename-session-modal.tsx +++ /dev/null @@ -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 ( - -
-
-
-
-
-

{translate("session.rename_title")}

-

{translate("session.rename_description")}

-
- -
- -
- 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(); - }} - /> -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/rename-workspace-modal.tsx b/apps/app/src/app/components/rename-workspace-modal.tsx deleted file mode 100644 index 3e44e846..00000000 --- a/apps/app/src/app/components/rename-workspace-modal.tsx +++ /dev/null @@ -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 ( - -
-
-
-
-
-

{translate("workspace.rename_title")}

-

{translate("workspace.rename_description")}

-
- -
- -
- 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(); - }} - /> -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/reset-modal.tsx b/apps/app/src/app/components/reset-modal.tsx deleted file mode 100644 index 8dbf05f0..00000000 --- a/apps/app/src/app/components/reset-modal.tsx +++ /dev/null @@ -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, {RESET_CONFIRM_WORD}] - : [part], - ); - }; - - return ( - -
-
-
-
-
-

- - {translate("settings.reset_onboarding_title")} - {translate("settings.reset_app_data_title")} - -

-

{resetConfirmationHint()}

-
- -
- -
-
- - - {translate("settings.reset_onboarding_warning")} - - {translate("settings.reset_app_data_warning")} - -
- - -
{translate("settings.reset_stop_active_runs")}
-
- - props.onTextChange(e.currentTarget.value)} - disabled={props.busy} - /> -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/restriction-notice-modal.tsx b/apps/app/src/app/components/restriction-notice-modal.tsx deleted file mode 100644 index 59d8218d..00000000 --- a/apps/app/src/app/components/restriction-notice-modal.tsx +++ /dev/null @@ -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 ( - -
-
-
-
-

{props.title}

-
- -
- -
-

{props.message}

-
- -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/select-menu.tsx b/apps/app/src/app/components/select-menu.tsx deleted file mode 100644 index 7e9e45ed..00000000 --- a/apps/app/src/app/components/select-menu.tsx +++ /dev/null @@ -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 `
- - -
-
-
props.setAgentPickerRef(el)} - > - - - -
-
- {t("composer.agent_label")} -
- -
- event.preventDefault() - } - > - - {t( - "composer.loading_agents", - )} -
- } - > - - - - - {(agent: Agent) => { - const active = () => - props.selectedAgent === - agent.name; - return ( - - ); - }} - - - - -
- {props.agentPickerError} -
-
- -
-
- -
- - - - 0} - > -
(variantPickerRef = el)} - > - - -
-
- {t("composer.behavior_label")} -
-
- - {(option) => ( - - )} - -
-
-
-
-
-
- - - - ); -} diff --git a/apps/app/src/app/components/session/message-list.tsx b/apps/app/src/app/components/session/message-list.tsx deleted file mode 100644 index 0644c97c..00000000 --- a/apps/app/src/app/components/session/message-list.tsx +++ /dev/null @@ -1,1223 +0,0 @@ -import { - For, - Show, - createEffect, - createMemo, - createSignal, - onCleanup, -} from "solid-js"; -import type { JSX } from "solid-js"; -import type { Part, Session } from "@opencode-ai/sdk/v2/client"; -import { - Check, - ChevronDown, - ChevronRight, - CircleAlert, - Copy, - File, -} from "lucide-solid"; -import { createVirtualizer } from "@tanstack/solid-virtual"; - -import { - SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, - type MessageGroup, - type MessageWithParts, - type StepGroupMode, -} from "../../types"; -import { - groupMessageParts, - isUserVisiblePart, - summarizeStep, -} from "../../utils"; -import PartView from "../part-view"; -import { perfNow, recordPerfLog } from "../../lib/perf-log"; -import { t } from "../../../i18n"; - -export type MessageListProps = { - messages: MessageWithParts[]; - isStreaming?: boolean; - developerMode: boolean; - showThinking: boolean; - getSessionById?: (sessionId: string | null) => Session | null; - getMessagesBySessionId?: (sessionId: string | null) => MessageWithParts[]; - ensureSessionLoaded?: (sessionId: string) => Promise | void; - sessionLoadingById?: (sessionId: string | null) => boolean; - expandedStepIds: Set; - setExpandedStepIds: (updater: (current: Set) => Set) => void; - openSessionById?: (sessionId: string) => void; - searchMatchMessageIds?: ReadonlySet; - activeSearchMessageId?: string | null; - searchHighlightQuery?: string; - workspaceRoot?: string; - scrollElement?: () => HTMLElement | undefined; - setScrollToMessageById?: ( - handler: ((messageId: string, behavior?: ScrollBehavior) => boolean) | null, - ) => void; - footer?: JSX.Element; - variant?: "default" | "nested"; -}; - -type StepClusterBlock = { - kind: "steps-cluster"; - id: string; - stepGroups: StepTimelineGroup[]; - messageIds: string[]; - isUser: boolean; -}; - -type StepTimelineGroup = { - id: string; - parts: Part[]; - mode: StepGroupMode; -}; - -type MessageBlock = { - kind: "message"; - message: MessageWithParts; - renderableParts: Part[]; - attachments: Array<{ - url: string; - filename: string; - mime: string; - }>; - groups: MessageGroup[]; - isUser: boolean; - messageId: string; -}; - -type MessageBlockItem = MessageBlock | StepClusterBlock; - -const VIRTUALIZATION_THRESHOLD = 500; -const VIRTUAL_OVERSCAN = 4; - -function normalizePath(path: string) { - const normalized = path.replace(/\\/g, "/").trim().replace(/\/+/g, "/"); - if (!normalized || normalized === "/") return normalized; - return normalized.replace(/\/+$/, ""); -} - - -type TaskStepInfo = { - isTask: boolean; - agentType?: string; - sessionId?: string; - description?: string; -}; - -function formatAgentType(agentType: string): string { - const clean = agentType.trim().replace(/[_-]+/g, " "); - if (!clean) return ""; - return clean - .split(/\s+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); -} - -function getTaskStepInfo(part: Part): TaskStepInfo { - if (part.type !== "tool") return { isTask: false }; - - const record = part as any; - const tool = typeof record.tool === "string" ? record.tool.toLowerCase() : ""; - if (tool !== "task") return { isTask: false }; - - const state = record.state ?? {}; - const input = - state.input && typeof state.input === "object" - ? (state.input as Record) - : {}; - const metadata = - state.metadata && typeof state.metadata === "object" - ? (state.metadata as Record) - : {}; - - const rawAgentType = - typeof input.subagent_type === "string" ? input.subagent_type.trim() : ""; - const agentType = rawAgentType ? formatAgentType(rawAgentType) : undefined; - const rawSessionId = - metadata.sessionId ?? - metadata.sessionID ?? - state.sessionId ?? - state.sessionID; - const rawDescription = - typeof input.description === "string" && input.description.trim() - ? input.description.trim() - : undefined; - const sessionId = - typeof rawSessionId === "string" && rawSessionId.trim() - ? rawSessionId.trim() - : undefined; - - return { isTask: true, agentType, sessionId, description: rawDescription }; -} - -function compactPathToken(value: string) { - const token = value.trim().replace(/^[`'"([{]+|[`'"\])},.;:]+$/g, ""); - const segments = token.split(/[\\/]/).filter(Boolean); - return segments.length > 0 ? segments[segments.length - 1] : token; -} - -function compactText(value: string, max = 42) { - const singleLine = value.replace(/\s+/g, " ").trim(); - if (!singleLine) return ""; - return singleLine.length > max - ? `${singleLine.slice(0, Math.max(0, max - 3))}...` - : singleLine; -} - -function cleanReasoningPreview(value: string) { - return value - .replace(/\[REDACTED\]/g, "") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/__([^_]+)__/g, "$1") - .replace(/`([^`]+)`/g, "$1") - .replace(/\s+\n/g, "\n") - .trim(); -} - -function formatStructuredValue(value: unknown) { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value.trim(); - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -function hasStructuredValue(value: unknown) { - if (value === undefined || value === null) return false; - if (typeof value === "string") return value.trim().length > 0; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === "object") return Object.keys(value as Record).length > 0; - return true; -} - -function isPathLike(value: string) { - return ( - /^(?:[A-Za-z]:[\\/]|~[\\/]|\/[\w_\-~]|\.\.?[\\/])/.test(value) || - /[\\/](?:\.opencode|Users|Library|workspaces)[\\/]/.test(value) - ); -} - -function toolHeadline(part: Part) { - if (part.type !== "tool") return ""; - - const record = part as any; - const state = record.state ?? {}; - const input = - state.input && typeof state.input === "object" - ? (state.input as Record) - : {}; - const tool = typeof record.tool === "string" ? record.tool.toLowerCase() : ""; - - const pick = (...keys: string[]) => { - for (const key of keys) { - const value = input[key]; - if (typeof value === "string" && value.trim()) return value.trim(); - } - return ""; - }; - - const target = (...keys: string[]) => { - const raw = pick(...keys); - if (!raw) return ""; - return isPathLike(raw) ? compactPathToken(raw) : raw; - }; - - if (tool === "bash") { - const description = pick("description"); - if (description) return compactText(description); - const command = pick("command", "cmd"); - return command ? compactText(t("message_list.tool_run_command", undefined, { command }), 48) : t("message_list.tool_run_command_fallback"); - } - - if (tool === "read") { - const file = target("filePath", "path", "file"); - return file ? t("message_list.tool_reviewed_file", undefined, { file }) : t("message_list.tool_reviewed_file_fallback"); - } - - if (tool === "edit") { - const file = target("filePath", "path", "file"); - return file ? t("message_list.tool_updated_file", undefined, { file }) : t("message_list.tool_updated_file_fallback"); - } - - if (tool === "write" || tool === "apply_patch") { - const file = target("filePath", "path", "file"); - return file ? t("message_list.tool_update_file", undefined, { file }) : t("message_list.tool_update_file_fallback"); - } - - if (tool === "grep" || tool === "glob" || tool === "search") { - const pattern = pick("pattern", "query"); - return pattern ? t("message_list.tool_searched_pattern", undefined, { pattern: compactText(pattern, 36) }) : t("message_list.tool_searched_code_fallback"); - } - - if (tool === "list" || tool === "list_files") { - const path = target("path"); - return path ? t("message_list.tool_reviewed_path", undefined, { path }) : t("message_list.tool_reviewed_files_fallback"); - } - - if (tool === "task") { - const description = pick("description"); - if (description) return compactText(description); - const agent = pick("subagent_type"); - return agent ? t("message_list.tool_delegate_agent", undefined, { agent }) : t("message_list.tool_delegate_task_fallback"); - } - - if (tool === "todowrite") { - return t("message_list.tool_update_todo"); - } - - if (tool === "todoread") { - return t("message_list.tool_read_todo"); - } - - if (tool === "webfetch") { - const url = pick("url"); - return url ? t("message_list.tool_checked_url", undefined, { url: compactText(url, 36) }) : t("message_list.tool_checked_web_fallback"); - } - - if (tool === "skill") { - const name = pick("name"); - return name ? t("message_list.tool_load_skill_named", undefined, { name }) : t("message_list.tool_load_skill_fallback"); - } - - const fallback = tool - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - return fallback ? compactText(fallback, 40) : ""; -} - -export default function MessageList(props: MessageListProps) { - const [copyingId, setCopyingId] = createSignal(null); - let previousMessagePartCountById = new Map(); - let previousMessageBlockById = new Map(); - let previousBlockRenderKey = ""; - let copyTimeout: number | undefined; - const isNestedVariant = () => props.variant === "nested"; - const isAttachmentPart = (part: Part) => { - if (part.type !== "file") return false; - const url = (part as { url?: string }).url; - return typeof url === "string" && !url.startsWith("file://"); - }; - const attachmentsForParts = (parts: Part[]) => - parts - .filter(isAttachmentPart) - .map((part) => { - const record = part as { - url?: string; - filename?: string; - mime?: string; - }; - return { - url: record.url ?? "", - filename: record.filename ?? "attachment", - mime: record.mime ?? "application/octet-stream", - }; - }) - .filter((attachment) => !!attachment.url); - const isImageAttachment = (mime: string) => mime.startsWith("image/"); - onCleanup(() => { - if (copyTimeout !== undefined) { - window.clearTimeout(copyTimeout); - } - }); - - const handleCopy = async (text: string, id: string) => { - try { - await navigator.clipboard.writeText(text); - setCopyingId(id); - if (copyTimeout !== undefined) { - window.clearTimeout(copyTimeout); - } - copyTimeout = window.setTimeout(() => { - setCopyingId(null); - copyTimeout = undefined; - }, 2000); - } catch { - // ignore - } - }; - - const partToText = (part: Part) => { - if (part.type === "text") { - return String((part as { text?: string }).text ?? ""); - } - if (part.type === "agent") { - const name = (part as { name?: string }).name ?? ""; - return name ? `@${name}` : "@agent"; - } - if (part.type === "file") { - const record = part as { - label?: string; - path?: string; - filename?: string; - }; - const label = record.label ?? record.path ?? record.filename ?? ""; - return label ? `@${label}` : "@file"; - } - return ""; - }; - - const toggleSteps = (id: string, relatedIds: string[] = []) => { - props.setExpandedStepIds((current) => { - const next = new Set(current); - const isExpanded = - next.has(id) || relatedIds.some((relatedId) => next.has(relatedId)); - if (isExpanded) { - next.delete(id); - relatedIds.forEach((relatedId) => next.delete(relatedId)); - } else { - next.add(id); - relatedIds.forEach((relatedId) => next.add(relatedId)); - } - return next; - }); - }; - - const isStepsExpanded = (id: string, relatedIds: string[] = []) => - props.expandedStepIds.has(id) || - relatedIds.some((relatedId) => props.expandedStepIds.has(relatedId)); - - const renderablePartsForMessage = (message: MessageWithParts) => - message.parts.filter((part) => { - if (!props.developerMode && !isUserVisiblePart(part)) { - return false; - } - - if (part.type === "reasoning") { - return props.showThinking; - } - - if (part.type === "step-start" || part.type === "step-finish") { - return false; - } - - if ( - part.type === "text" || - part.type === "tool" || - part.type === "agent" || - part.type === "file" - ) { - return true; - } - - return props.developerMode; - }); - - const messageBlocks = createMemo(() => { - const startedAt = perfNow(); - const renderKey = `${props.developerMode ? 1 : 0}:${props.showThinking ? 1 : 0}`; - const blocks: MessageBlockItem[] = []; - const nextMessagePartCountById = new Map(); - const nextMessageBlockById = new Map(); - let changedMessageCount = 0; - let addedMessageCount = 0; - let toolPartCount = 0; - let stepGroupCount = 0; - - props.messages.forEach((message, index) => { - const renderableParts = renderablePartsForMessage(message); - if (!renderableParts.length) return; - - const messageId = String((message.info as any).id ?? ""); - const idKey = messageId || `idx:${index}`; - const totalParts = message.parts.length; - nextMessagePartCountById.set(idKey, totalParts); - const previousPartCount = previousMessagePartCountById.get(idKey); - const previousBlock = previousMessageBlockById.get(idKey); - if (previousPartCount === undefined) { - addedMessageCount += 1; - } else if (previousPartCount !== totalParts) { - changedMessageCount += 1; - } - - const isUser = (message.info as any).role === "user"; - const canReuseStableBlock = - previousBlockRenderKey === renderKey && - index < props.messages.length - 1 && - previousPartCount !== undefined && - previousPartCount === totalParts && - previousBlock?.kind === "message" && - previousBlock.isUser === isUser; - - if (canReuseStableBlock && previousBlock) { - toolPartCount += previousBlock.renderableParts.reduce( - (count, part) => (part.type === "tool" ? count + 1 : count), - 0, - ); - stepGroupCount += previousBlock.groups.reduce( - (count, group) => (group.kind === "steps" ? count + 1 : count), - 0, - ); - blocks.push(previousBlock); - nextMessageBlockById.set(idKey, previousBlock); - return; - } - - toolPartCount += renderableParts.reduce( - (count, part) => (part.type === "tool" ? count + 1 : count), - 0, - ); - const groupId = String((message.info as any).id ?? "message"); - const attachments = attachmentsForParts(renderableParts); - const nonAttachmentParts = renderableParts.filter((part) => !isAttachmentPart(part)); - const groups = groupMessageParts(nonAttachmentParts, groupId); - const isStepsOnly = - groups.length > 0 && groups.every((group) => group.kind === "steps"); - const stepGroups = isStepsOnly - ? (groups as { - kind: "steps"; - id: string; - parts: Part[]; - segment: "execution"; - mode: StepGroupMode; - }[]) - : []; - stepGroupCount += groups.reduce( - (count, group) => (group.kind === "steps" ? count + 1 : count), - 0, - ); - - if (isStepsOnly) { - blocks.push({ - kind: "steps-cluster", - id: stepGroups[0].id, - stepGroups: stepGroups.map((group) => ({ - id: group.id, - parts: group.parts, - mode: group.mode, - })), - messageIds: [messageId], - isUser, - }); - return; - } - - const block: MessageBlock = { - kind: "message", - message, - renderableParts, - attachments, - groups, - isUser, - messageId, - }; - blocks.push(block); - nextMessageBlockById.set(idKey, block); - }); - - let removedMessageCount = 0; - previousMessagePartCountById.forEach((_partCount, id) => { - if (!nextMessagePartCountById.has(id)) { - removedMessageCount += 1; - } - }); - previousMessagePartCountById = nextMessagePartCountById; - previousMessageBlockById = nextMessageBlockById; - previousBlockRenderKey = renderKey; - - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - if ( - props.developerMode && - (elapsedMs >= 6 || - (Boolean(props.isStreaming) && - props.messages.length >= 16 && - changedMessageCount <= 2 && - addedMessageCount <= 1 && - removedMessageCount === 0) || - (Boolean(props.isStreaming) && toolPartCount >= 10)) - ) { - recordPerfLog(true, "session.render", "message-blocks", { - messageCount: props.messages.length, - blockCount: blocks.length, - changedMessageCount, - addedMessageCount, - removedMessageCount, - toolPartCount, - stepGroupCount, - streaming: Boolean(props.isStreaming), - ms: elapsedMs, - }); - } - - return blocks; - }); - - const latestAssistantMessageId = createMemo(() => { - for (let index = props.messages.length - 1; index >= 0; index -= 1) { - const message = props.messages[index]; - if ((message.info as any).role === "assistant") { - return String((message.info as any).id ?? ""); - } - } - return ""; - }); - - const blockIndexByMessageId = createMemo(() => { - const next = new Map(); - messageBlocks().forEach((block, index) => { - if (block.kind === "steps-cluster") { - block.messageIds.forEach((id) => { - if (id) next.set(id, index); - }); - return; - } - if (block.messageId) { - next.set(block.messageId, index); - } - }); - return next; - }); - - const shouldVirtualize = createMemo( - () => - Boolean(props.scrollElement?.()) && - messageBlocks().length >= VIRTUALIZATION_THRESHOLD, - ); - - const virtualizer = createVirtualizer({ - get count() { - return messageBlocks().length; - }, - getScrollElement: () => props.scrollElement?.() ?? null, - estimateSize: () => 220, - overscan: VIRTUAL_OVERSCAN, - getItemKey: (index) => { - const block = messageBlocks()[index]; - if (!block) return `block-${index}`; - if (block.kind === "steps-cluster") { - return `steps-${block.messageIds.join(",")}`; - } - return `message-${block.messageId}`; - }, - }); - - let cachedVirtualRows: ReturnType = []; - const virtualRows = createMemo(() => { - if (!shouldVirtualize()) { - cachedVirtualRows = []; - return []; - } - const rows = virtualizer.getVirtualItems(); - if (rows.length > 0) { - cachedVirtualRows = rows; - return rows; - } - return cachedVirtualRows; - }); - - const virtualRowByIndex = createMemo(() => { - const map = new Map< - number, - ReturnType[number] - >(); - virtualRows().forEach((row) => { - map.set(row.index, row); - }); - return map; - }); - - const virtualRowIndices = createMemo(() => - virtualRows().map((row) => row.index), - ); - - const shouldUseContentVisibility = createMemo( - () => !shouldVirtualize() && messageBlocks().length > 500, - ); - const blockPerfStyle = (index: number): JSX.CSSProperties | undefined => { - if (!shouldUseContentVisibility()) return undefined; - const total = messageBlocks().length; - if (index >= total - 24) return undefined; - return { - "content-visibility": "auto", - "contain-intrinsic-size": "220px", - }; - }; - - createEffect(() => { - const setScrollToMessageById = props.setScrollToMessageById; - if (!setScrollToMessageById) return; - const indexById = blockIndexByMessageId(); - const useVirtualization = shouldVirtualize(); - - setScrollToMessageById((messageId, behavior = "smooth") => { - const index = indexById.get(messageId); - if (index === undefined) return false; - - if (useVirtualization) { - virtualizer.scrollToIndex(index, { align: "center" }); - return true; - } - - const container = props.scrollElement?.(); - if (!container) return false; - const escapedId = messageId.replace(/"/g, '\\"'); - const target = container.querySelector( - `[data-message-id="${escapedId}"]`, - ) as HTMLElement | null; - if (!target) return false; - target.scrollIntoView({ behavior, block: "center" }); - return true; - }); - }); - - createEffect(() => { - if (!shouldVirtualize()) return; - queueMicrotask(() => { - virtualizer.measure(); - }); - }); - - onCleanup(() => { - props.setScrollToMessageById?.(null); - }); - - const sessionStreamState = (messages: MessageWithParts[]) => { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const info = messages[index]?.info as { role?: string; time?: { completed?: number } }; - if (info?.role !== "assistant") continue; - return !info.time?.completed; - } - return false; - }; - - const SubagentThread = (threadProps: { part: Part }) => { - const task = createMemo(() => getTaskStepInfo(threadProps.part)); - const sessionId = createMemo(() => task().sessionId ?? null); - const [open, setOpen] = createSignal(true); - let requestedSessionId = ""; - const session = createMemo(() => props.getSessionById?.(sessionId()) ?? null); - const childMessages = createMemo(() => props.getMessagesBySessionId?.(sessionId()) ?? []); - const loading = createMemo(() => props.sessionLoadingById?.(sessionId()) ?? false); - const streaming = createMemo(() => loading() || sessionStreamState(childMessages())); - const label = createMemo(() => { - const title = session()?.title?.trim(); - if (title) return title; - if (task().description) return task().description!; - if (task().agentType) return t("message_list.subagent_type_task", undefined, { agentType: task().agentType! }); - return t("message_list.subagent_session_fallback"); - }); - const statusLabel = createMemo(() => { - if (loading()) return t("message_list.subagent_loading_transcript"); - if (streaming()) return t("message_list.subagent_running"); - if (childMessages().length > 0) { - const count = childMessages().length; - return t("message_list.subagent_message_count", undefined, { count, plural: count === 1 ? "" : "s" }); - } - return t("message_list.subagent_waiting_transcript"); - }); - - createEffect(() => { - const id = sessionId(); - if (!id) return; - if (!props.ensureSessionLoaded) return; - if (requestedSessionId === id) return; - requestedSessionId = id; - void props.ensureSessionLoaded(id); - }); - - return ( - -
-
- - {statusLabel()} - - {task().agentType} - - - - -
- -
- 0} - fallback={
{t("message.waiting_subagent")}
} - > - -
-
-
-
-
- ); - }; - - /** Transcript step row */ - const StepRow = (rowProps: { - id: string; - part: Part; - isUser: boolean; - groupMode?: StepGroupMode; - }) => { - const summary = createMemo(() => summarizeStep(rowProps.part)); - const task = createMemo(() => getTaskStepInfo(rowProps.part)); - const toolState = createMemo(() => { - if (rowProps.part.type !== "tool") return {} as Record; - return (((rowProps.part as any).state ?? {}) as Record); - }); - const toolInput = createMemo(() => { - const input = toolState().input; - return input && typeof input === "object" - ? (input as Record) - : undefined; - }); - const toolOutput = createMemo(() => toolState().output); - const expandable = createMemo( - () => - rowProps.part.type === "tool" && - (hasStructuredValue(toolInput()) || - hasStructuredValue(toolOutput()) || - Boolean(task().isTask && task().sessionId)), - ); - const expanded = createMemo( - () => expandable() && isStepsExpanded(rowProps.id), - ); - const headline = createMemo(() => { - const title = summary().title?.trim() ?? ""; - if (title) return title; - const fromTool = toolHeadline(rowProps.part); - return fromTool || t("message_list.step_updates_progress"); - }); - const reasoningText = createMemo(() => { - if (rowProps.part.type !== "reasoning") return ""; - const raw = typeof (rowProps.part as any).text === "string" ? (rowProps.part as any).text : ""; - return cleanReasoningPreview(raw); - }); - - if (rowProps.part.type === "reasoning") { - return ( -
-
{reasoningText() || headline()}
-
- ); - } - - return ( -
- - -
- -
-
{t("message.tool_request_label")}
-
{formatStructuredValue(toolInput())}
-
-
- -
-
{t("message.tool_result_label")}
-
{formatStructuredValue(toolOutput())}
-
-
- - - -
-
-
- ); - }; - - /** Quiet steps list */ - const StepsList = (listProps: { - groupId: string; - parts: Part[]; - isUser: boolean; - groupMode: StepGroupMode; - }) => ( -
- - {(part, index) => ( - - )} - -
- ); - - /** Expandable steps container */ - const StepsContainer = (containerProps: { - id: string; - relatedIds?: string[]; - stepGroups: StepTimelineGroup[]; - isUser: boolean; - isInline?: boolean; - }) => { - const useInnerTimelineScroll = () => !Boolean(props.isStreaming); - return ( -
-
-
-
- - {(group) => ( - - )} - -
-
-
-
- ); - }; - - const renderBlock = (block: MessageBlockItem, blockIndex: number) => { - const blockMessageIds = - block.kind === "steps-cluster" ? block.messageIds : [block.messageId]; - const hasSearchMatch = blockMessageIds.some((id) => - props.searchMatchMessageIds?.has(id), - ); - const hasActiveSearchMatch = blockMessageIds.some( - (id) => id === props.activeSearchMessageId, - ); - const searchOutlineClass = hasActiveSearchMatch - ? "outline outline-2 outline-amber-8/70 outline-offset-2 rounded-2xl" - : hasSearchMatch - ? "outline outline-1 outline-amber-7/50 outline-offset-1 rounded-2xl" - : ""; - - if (block.kind === "steps-cluster") { - return ( -
-
- stepGroup.id) - .filter((stepId) => stepId !== block.id)} - stepGroups={block.stepGroups} - isUser={block.isUser} - /> -
-
- ); - } - - const groupSpacing = block.isUser ? "mb-3" : "mb-4"; - const isSyntheticSessionError = - !block.isUser && - block.messageId.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX); - - if (isSyntheticSessionError) { - const messageText = block.renderableParts - .map((part) => partToText(part)) - .join(" ") - .replace(/\s*\n+\s*/g, " ") - .replace(/\s{2,}/g, " ") - .trim(); - - return ( -
-
- -
-
- ); - } - - return ( -
-
- 0}> -
- - {(attachment) => ( -
- } - > -
- {attachment.filename} -
-
-
-
- {attachment.filename} -
-
- {attachment.mime} -
-
-
- )} -
-
-
- - {(group, idx) => ( -
- - {(() => { - const isStreamingLatestAssistant = - !block.isUser && - props.isStreaming && - block.messageId === latestAssistantMessageId(); - const markdownThrottleMs = isStreamingLatestAssistant - ? 120 - : 100; - return ( - - ); - })()} - - {group.kind === "steps" && - (() => { - const stepGroup = group as { - kind: "steps"; - id: string; - parts: Part[]; - segment: "execution"; - mode: StepGroupMode; - }; - return ( - - ); - })()} -
- )} -
- -
- -
-
-
-
- ); - }; - - return ( -
- - - {(block, blockIndex) => renderBlock(block, blockIndex())} - -
- } - > - 0} - fallback={ -
- - {(block, blockIndex) => renderBlock(block, blockIndex())} - -
- } - > -
- - {(rowIndex) => { - const virtualRow = virtualRowByIndex().get(rowIndex); - if (!virtualRow) return null; - const block = messageBlocks()[rowIndex]; - if (!block) return null; - return ( -
virtualizer.measureElement(el)} - class="absolute left-0 top-0 w-full pb-4" - style={{ - transform: `translateY(${virtualRow.start}px)`, - }} - > - {renderBlock(block, rowIndex)} -
- ); - }} -
-
-
-
- {props.footer} - - ); -} diff --git a/apps/app/src/app/components/session/scroll-controller.ts b/apps/app/src/app/components/session/scroll-controller.ts deleted file mode 100644 index dfab1f84..00000000 --- a/apps/app/src/app/components/session/scroll-controller.ts +++ /dev/null @@ -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; - renderedMessages: Accessor; - containerRef: Accessor; - contentRef: Accessor; -}; - -export function createSessionScrollController( - options: SessionScrollControllerOptions, -) { - const [mode, setMode] = createSignal("follow-latest"); - const [topClippedMessageId, setTopClippedMessageId] = createSignal(null); - const isAtBottom = createMemo(() => mode() === "follow-latest"); - - let lastKnownScrollTop = 0; - let programmaticScroll = false; - let programmaticScrollResetRafA: number | undefined; - let programmaticScrollResetRafB: number | undefined; - let observedContentHeight = 0; - 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 = (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, - }; -} diff --git a/apps/app/src/app/components/session/workspace-session-list.tsx b/apps/app/src/app/components/session/workspace-session-list.tsx deleted file mode 100644 index bc61cc10..00000000 --- a/apps/app/src/app/components/session/workspace-session-list.tsx +++ /dev/null @@ -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; - connectingWorkspaceId: string | null; - workspaceConnectionStateById: Record; - newTaskDisabled: boolean; - onSelectWorkspace: (workspaceId: string) => Promise | 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 | void; - onTestWorkspaceConnection: ( - workspaceId: string, - ) => Promise | 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; - ancestorIdsBySessionId: Map; - descendantCountBySessionId: Map; - activeIds: Set; -}; - -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 | undefined, -): SessionTreeState => { - const childrenByParent = new Map(); - const ancestorIdsBySessionId = new Map(); - const descendantCountBySessionId = new Map(); - const activeIds = new Set(); - 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, - forcedExpandedSessionIds: Set, -) => { - const roots = getRootSessions(sessions).slice(0, rootLimit); - const rows: FlattenedSessionRow[] = []; - const visited = new Set(); - - 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 - >(new Set()); - const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = - createSignal>({}); - const [workspaceMenuId, setWorkspaceMenuId] = createSignal( - null, - ); - const [sessionMenuOpen, setSessionMenuOpen] = createSignal(false); - const [expandedSessionIds, setExpandedSessionIds] = createSignal>( - 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, - ) => - 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, - ) => { - 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 ( -
-
{ - if (event.key !== "Enter" && event.key !== " ") return; - if (event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - openSession(); - }} - > -
- 0}> - - - } - > - - - - - - - {displayTitle()} - -
- -
- - - -
-
- - -
(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()} - > - - - - - - - -
-
-
- ); - }; - - return ( -
-
-
- - {(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 ( -
-
-
{ - 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), - ); - }} - > -
-
-
-
- {workspaceLabel(workspace())} -
- -
- {statusLabel()} -
-
-
-
- -
- - - - - - - -
-
- - -
(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()} - > - - - - - - - - - - - - - -
-
-
- - -
-
- 0} - fallback={ - -
- {taskLoadError().message} -
-
- } - > - - {(row) => - renderSessionRow( - workspace().id, - row, - tree, - forcedExpandedSessionIds, - )} - - - - - - - - previewCount(workspace().id) - } - > - - -
- } - > -
- {t("workspace.loading_tasks")} -
- - } - > -
- - {(idx) => ( -
-
-
- )} - -
- -
-
- -
- ); - }} - -
-
- -
- -
-
- ); -} diff --git a/apps/app/src/app/components/status-bar.tsx b/apps/app/src/app/components/status-bar.tsx deleted file mode 100644 index fca3ded5..00000000 --- a/apps/app/src/app/components/status-bar.tsx +++ /dev/null @@ -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 ( -
-
-
- - - - - - - {statusCopy().label} - {statusCopy().detail} -
- -
- - - - - -
-
-
- ); -} diff --git a/apps/app/src/app/components/status-toast.tsx b/apps/app/src/app/components/status-toast.tsx deleted file mode 100644 index c422d6f0..00000000 --- a/apps/app/src/app/components/status-toast.tsx +++ /dev/null @@ -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 ( - -
-
-
- - ) : tone() === "error" ? ( - - ) : ( - - ) - } - > - - -
- -
-
-
-
{props.title}
- -

{props.description}

-
-
- - -
- - -
- - -
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/text-input.tsx b/apps/app/src/app/components/text-input.tsx deleted file mode 100644 index ad278ae1..00000000 --- a/apps/app/src/app/components/text-input.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { splitProps, JSX } from "solid-js"; - -type TextInputProps = JSX.InputHTMLAttributes & { - label?: string; - hint?: string; -}; - -export default function TextInput(props: TextInputProps) { - const [local, rest] = splitProps(props, ["label", "hint", "class", "ref"]); - - return ( - - ); -} diff --git a/apps/app/src/app/components/web-unavailable-surface.tsx b/apps/app/src/app/components/web-unavailable-surface.tsx deleted file mode 100644 index c21fc162..00000000 --- a/apps/app/src/app/components/web-unavailable-surface.tsx +++ /dev/null @@ -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 ( -
- - - - -
-
- {props.children} -
- - -
- ); -} diff --git a/apps/app/src/app/connections/mcp-view.tsx b/apps/app/src/app/connections/mcp-view.tsx deleted file mode 100644 index 7d30b76e..00000000 --- a/apps/app/src/app/connections/mcp-view.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/apps/app/src/app/connections/openwork-server-provider.tsx b/apps/app/src/app/connections/openwork-server-provider.tsx deleted file mode 100644 index b536f2df..00000000 --- a/apps/app/src/app/connections/openwork-server-provider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, type ParentProps } from "solid-js"; - -import type { OpenworkServerStore } from "./openwork-server-store"; - -const OpenworkServerContext = createContext(); - -export function OpenworkServerProvider(props: ParentProps<{ store: OpenworkServerStore }>) { - return ( - - {props.children} - - ); -} diff --git a/apps/app/src/app/connections/openwork-server-store.ts b/apps/app/src/app/connections/openwork-server-store.ts deleted file mode 100644 index 539d113f..00000000 --- a/apps/app/src/app/connections/openwork-server-store.ts +++ /dev/null @@ -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; - -type RemoteWorkspaceInput = { - openworkHostUrl: string; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; -}; - -export function createOpenworkServerStore(options: { - startupPreference: Accessor; - documentVisible: Accessor; - developerMode: Accessor; - runtimeWorkspaceId: Accessor; - activeClient: Accessor; - selectedWorkspaceDisplay: Accessor; - restartLocalServer: () => Promise; - createRemoteWorkspaceFlow: (input: RemoteWorkspaceInput) => Promise; -}) { - const bootStartedAt = Date.now(); - const [openworkServerSettings, setOpenworkServerSettings] = createSignal({}); - const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false); - const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal(null); - const [openworkServerUrl, setOpenworkServerUrl] = createSignal(""); - const [openworkServerStatus, setOpenworkServerStatus] = createSignal("disconnected"); - const [openworkServerCapabilities, setOpenworkServerCapabilities] = - createSignal(null); - const [, setOpenworkServerCheckedAt] = createSignal(null); - const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal(null); - const [openworkServerHostInfoReady, setOpenworkServerHostInfoReady] = createSignal(!isTauriRuntime()); - const [openworkServerDiagnostics, setOpenworkServerDiagnostics] = - createSignal(null); - const [openworkReconnectBusy, setOpenworkReconnectBusy] = createSignal(false); - const [opencodeRouterInfoState, setOpenCodeRouterInfoState] = - createSignal(null); - const [orchestratorStatusState, setOrchestratorStatusState] = - createSignal(null); - const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal([]); - const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle"); - const [openworkAuditError, setOpenworkAuditError] = createSignal(null); - const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal(null); - - const openworkServerBaseUrl = createMemo(() => { - const pref = options.startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; - - if (pref === "local") return hostInfo?.baseUrl ?? ""; - if (pref === "server") return settingsUrl; - return hostInfo?.baseUrl ?? settingsUrl; - }); - - const openworkServerAuth = createMemo( - () => { - const pref = options.startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settingsToken = openworkServerSettings().token?.trim() ?? ""; - const clientToken = hostInfo?.clientToken?.trim() ?? ""; - const hostToken = hostInfo?.hostToken?.trim() ?? ""; - - if (pref === "local") { - return { token: clientToken || undefined, hostToken: hostToken || undefined }; - } - if (pref === "server") { - return { token: settingsToken || undefined, hostToken: undefined }; - } - if (hostInfo?.baseUrl) { - return { token: clientToken || undefined, hostToken: hostToken || undefined }; - } - return { token: settingsToken || undefined, hostToken: undefined }; - }, - undefined, - { - equals: (prev, next) => prev?.token === next.token && prev?.hostToken === next.hostToken, - }, - ); - - const openworkServerClient = createMemo(() => { - const baseUrl = openworkServerBaseUrl().trim(); - if (!baseUrl) return null; - const auth = openworkServerAuth(); - return createOpenworkServerClient({ baseUrl, token: auth.token, hostToken: auth.hostToken }); - }); - - const openworkServerReady = createMemo(() => openworkServerStatus() === "connected"); - const openworkServerWorkspaceReady = createMemo(() => Boolean(options.runtimeWorkspaceId())); - const resolvedOpenworkCapabilities = createMemo(() => openworkServerCapabilities()); - const openworkServerCanWriteSkills = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.skills?.write ?? false), - ); - const openworkServerCanWritePlugins = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.plugins?.write ?? false), - ); - - const updateOpenworkServerSettings = (next: OpenworkServerSettings) => { - const stored = writeOpenworkServerSettings(next); - setOpenworkServerSettings(stored); - }; - - const resetOpenworkServerSettings = () => { - clearOpenworkServerSettings(); - setOpenworkServerSettings({}); - }; - - const checkOpenworkServer = async (url: string, token?: string, hostToken?: string) => { - const client = createOpenworkServerClient({ baseUrl: url, token, hostToken }); - try { - await client.health(); - } catch (error) { - const resolved = error as OpenworkServerError | Error; - if ("status" in resolved && (resolved.status === 401 || resolved.status === 403)) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; - } - - if (!token) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - - try { - const caps = await client.capabilities(); - return { status: "connected" as OpenworkServerStatus, capabilities: caps }; - } catch (error) { - const resolved = error as OpenworkServerError | Error; - if ("status" in resolved && (resolved.status === 401 || resolved.status === 403)) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; - } - }; - - 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((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 { - 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, - }; -} diff --git a/apps/app/src/app/connections/provider.tsx b/apps/app/src/app/connections/provider.tsx deleted file mode 100644 index 723d083b..00000000 --- a/apps/app/src/app/connections/provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { ConnectionsStore } from "./store"; - -const ConnectionsContext = createContext(); - -export function ConnectionsProvider(props: ParentProps<{ store: ConnectionsStore }>) { - return ( - - {props.children} - - ); -} - -export function useConnections() { - const context = useContext(ConnectionsContext); - if (!context) { - throw new Error("useConnections must be used within a ConnectionsProvider"); - } - return context; -} diff --git a/apps/app/src/app/context/automations.ts b/apps/app/src/app/context/automations.ts deleted file mode 100644 index 8ad926ff..00000000 --- a/apps/app/src/app/context/automations.ts +++ /dev/null @@ -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; - -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([]); - const [scheduledJobsStatus, setScheduledJobsStatus] = createSignal(null); - const [scheduledJobsBusy, setScheduledJobsBusy] = createSignal(false); - const [scheduledJobsUpdatedAt, setScheduledJobsUpdatedAt] = createSignal(null); - const [pendingRefreshContextKey, setPendingRefreshContextKey] = createSignal(null); - - const serverBacked = createMemo(() => { - const client = options.openworkServer.openworkServerClient(); - const runtimeWorkspaceId = (options.runtimeWorkspaceId() ?? "").trim(); - return options.openworkServer.openworkServerStatus() === "connected" && Boolean(client && runtimeWorkspaceId); - }); - - const scheduledJobsSource = createMemo<"local" | "remote">(() => - serverBacked() ? "remote" : "local", - ); - - const scheduledJobsContextKey = createWorkspaceContextKey({ - selectedWorkspaceId: options.selectedWorkspaceId, - selectedWorkspaceRoot: options.selectedWorkspaceRoot, - runtimeWorkspaceId: options.runtimeWorkspaceId, - }); - - const scheduledJobsPollingAvailable = createMemo(() => { - if (scheduledJobsSource() === "remote") return true; - return isTauriRuntime() && options.schedulerPluginInstalled(); - }); - - const refreshScheduledJobs = async ( - _options?: { force?: boolean }, - ): Promise<"success" | "error" | "unavailable" | "skipped"> => { - const requestContextKey = scheduledJobsContextKey(); - if (!requestContextKey) return "skipped"; - - if (scheduledJobsBusy()) { - setPendingRefreshContextKey(requestContextKey); - return "skipped"; - } - - if (scheduledJobsSource() === "remote") { - const client = options.openworkServer.openworkServerClient(); - const workspaceId = (options.runtimeWorkspaceId() ?? "").trim(); - if (!client || options.openworkServer.openworkServerStatus() !== "connected" || !workspaceId) { - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - const status = - options.openworkServer.openworkServerStatus() === "disconnected" - ? 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, - }; -} diff --git a/apps/app/src/app/context/global-sdk.tsx b/apps/app/src/app/context/global-sdk.tsx deleted file mode 100644 index 7075ad4c..00000000 --- a/apps/app/src/app/context/global-sdk.tsx +++ /dev/null @@ -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; - event: ReturnType>; -}; - -const GlobalSDKContext = createContext(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 = []; - const coalesced = new Map(); - let timer: ReturnType | 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) { - 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((resolve) => setTimeout(resolve, 0)); - } - })() - .finally(stop) - .catch(() => undefined); - - onCleanup(() => { - abort.abort(); - stop(); - }); - }); - - const value: GlobalSDKContextValue = { - url, - client, - event: emitter, - }; - - return {props.children}; -} - -export function useGlobalSDK() { - const context = useContext(GlobalSDKContext); - if (!context) { - throw new Error("Global SDK context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/global-sync.tsx b/apps/app/src/app/context/global-sync.tsx deleted file mode 100644 index 4b73134b..00000000 --- a/apps/app/src/app/context/global-sync.tsx +++ /dev/null @@ -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; - message: Record; - part: Record; - todo: Record; -}; - -type WorkspaceStore = [Store, SetStoreFunction]; - -type ProjectMeta = { - name?: string; - icon?: Project["icon"]; -}; - -type GlobalState = { - ready: boolean; - error?: string; - serverVersion?: string; - config: Config; - provider: ProviderListResponse; - providerAuth: ProviderAuthResponse; - mcp: Record; - lsp: Record; - project: Project[]; - projectMeta: Record; - vcs: Record; -}; - -type GlobalSyncContextValue = { - data: Store; - set: SetStoreFunction; - child: (directory: string) => WorkspaceStore; - refresh: () => Promise; - refreshDirectory: (directory: string) => Promise; -}; - -const GlobalSyncContext = createContext(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({ - ready: false, - error: undefined, - serverVersion: undefined, - config: {}, - provider: defaultProvider, - providerAuth: {}, - mcp: {}, - lsp: {}, - project: [], - projectMeta: {}, - vcs: {}, - }); - const children = new Map(); - const subscriptions = new Map 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 = {}; - 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(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 {props.children}; -} - -export function useGlobalSync() { - const context = useContext(GlobalSyncContext); - if (!context) { - throw new Error("Global sync context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/local.tsx b/apps/app/src/app/context/local.tsx deleted file mode 100644 index 268a260f..00000000 --- a/apps/app/src/app/context/local.tsx +++ /dev/null @@ -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; - setUi: SetStoreFunction; - prefs: Store; - setPrefs: SetStoreFunction; - ready: () => boolean; -}; - -const LocalContext = createContext(undefined); - -export function LocalProvider(props: ParentProps) { - const [ui, setUi, , uiReady] = persisted( - Persist.global("local.ui", ["openwork.ui"]), - createStore({ - view: "settings", - tab: "general", - }), - ); - - const [prefs, setPrefs, , prefsReady] = persisted( - Persist.global("local.preferences", ["openwork.preferences"]), - createStore({ - 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 {props.children}; -} - -export function useLocal() { - const context = useContext(LocalContext); - if (!context) { - throw new Error("Local context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/model-config.ts b/apps/app/src/app/context/model-config.ts deleted file mode 100644 index 887fce89..00000000 --- a/apps/app/src/app/context/model-config.ts +++ /dev/null @@ -1,1434 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"; - -import { parse } from "jsonc-parser"; - -import { currentLocale, t } from "../../i18n"; -import { DEFAULT_MODEL, MODEL_PREF_KEY, SESSION_MODEL_PREF_KEY, VARIANT_PREF_KEY } from "../constants"; -import { - isDesktopModelBlocked, - type DesktopAppRestrictionChecker, -} from "../cloud/desktop-app-restrictions"; -import { readOpencodeConfig, writeOpencodeConfig } from "../lib/tauri"; -import { - formatGenericBehaviorLabel, - getModelBehaviorSummary, - normalizeModelBehaviorValue, - sanitizeModelBehaviorValue, -} from "../lib/model-behavior"; -import type { - OpenworkServerCapabilities, - OpenworkServerClient, - OpenworkServerStatus, -} from "../lib/openwork-server"; -import type { - Client, - MessageWithParts, - ModelOption, - ModelRef, - ProviderListItem, - WorkspaceDisplay, -} from "../types"; -import { - addOpencodeCacheHint, - formatModelLabel, - formatModelRef, - isTauriRuntime, - lastUserModelFromMessages, - modelEquals, - parseModelRef, - safeStringify, -} from "../utils"; -import { compareProviders, providerPriorityRank } from "../utils/providers"; - -export type SessionChoiceOverride = { - model?: ModelRef | null; - variant?: string | null; -}; - -export type SessionModelState = { - overrides: Record; - resolved: Record; -}; - -export type ModelPickerTarget = "default" | "session"; -export type PromptFocusReturnTarget = "none" | "composer"; - -const hasOwn = (value: object, key: K): value is Record => - Object.prototype.hasOwnProperty.call(value, key); - -const normalizeVariantOverride = (value: unknown) => { - if (typeof value === "string") return normalizeModelBehaviorValue(value); - if (value == null) return null; - return null; -}; - -const parseStoredModel = (value: unknown) => { - if (typeof value === "string") return parseModelRef(value); - if (!value || typeof value !== "object") return null; - - const record = value as Record; - if (typeof record.providerID === "string" && typeof record.modelID === "string") { - return { - providerID: record.providerID, - modelID: record.modelID, - }; - } - - return null; -}; - -const normalizeSessionChoice = (value: SessionChoiceOverride | null | undefined) => { - if (!value || typeof value !== "object") return null; - - const next: SessionChoiceOverride = {}; - if (value.model) { - next.model = value.model; - } - - if (hasOwn(value, "variant")) { - next.variant = normalizeModelBehaviorValue(value.variant ?? null); - } - - return hasOwn(next, "variant") || next.model ? next : null; -}; - -const deriveSessionModelOverrides = (choices: Record) => { - const next: Record = {}; - for (const [sessionId, choice] of Object.entries(choices)) { - if (choice.model) next[sessionId] = choice.model; - } - return next; -}; - -const applySessionModelState = ( - currentChoices: Record, - nextState: SessionModelState, -) => { - const nextChoices: Record = {}; - - for (const [sessionId, choice] of Object.entries(currentChoices)) { - if (hasOwn(choice, "variant") && !nextState.overrides[sessionId]) { - nextChoices[sessionId] = { variant: choice.variant ?? null }; - } - } - - for (const [sessionId, model] of Object.entries(nextState.overrides)) { - const current = currentChoices[sessionId]; - const nextChoice = normalizeSessionChoice({ - model, - ...(current && hasOwn(current, "variant") ? { variant: current.variant ?? null } : {}), - }); - if (nextChoice) nextChoices[sessionId] = nextChoice; - } - - return nextChoices; -}; - -const parseDefaultModelFromConfig = (content: string | null) => { - if (!content) return null; - try { - const parsed = parse(content) as Record | undefined; - const rawModel = typeof parsed?.model === "string" ? parsed.model : null; - return parseModelRef(rawModel); - } catch { - return null; - } -}; - -const formatConfigWithDefaultModel = (content: string | null, model: ModelRef) => { - let config: Record = {}; - if (content?.trim()) { - try { - const parsed = parse(content) as Record | undefined; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - config = { ...parsed }; - } - } catch { - config = {}; - } - } - - if (!config["$schema"]) { - config["$schema"] = "https://opencode.ai/config.json"; - } - - config.model = formatModelRef(model); - return `${JSON.stringify(config, null, 2)}\n`; -}; - -const parseAutoCompactContextFromConfig = (content: string | null) => { - if (!content) return null; - try { - const parsed = parse(content) as Record | undefined; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - const compaction = parsed.compaction; - if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) { - return null; - } - return typeof (compaction as Record).auto === "boolean" - ? ((compaction as Record).auto as boolean) - : null; - } catch { - return null; - } -}; - -const formatConfigWithAutoCompactContext = (content: string | null, enabled: boolean) => { - let config: Record = {}; - if (content?.trim()) { - try { - const parsed = parse(content) as Record | undefined; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - config = { ...parsed }; - } - } catch { - config = {}; - } - } - - if (!config["$schema"]) { - config["$schema"] = "https://opencode.ai/config.json"; - } - - const compaction = - typeof config.compaction === "object" && config.compaction && !Array.isArray(config.compaction) - ? { ...(config.compaction as Record) } - : {}; - - compaction.auto = enabled; - config.compaction = compaction; - return `${JSON.stringify(config, null, 2)}\n`; -}; - -const getConfigSnapshot = (content: string | null) => { - if (!content?.trim()) return ""; - try { - const parsed = parse(content) as Record; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const copy = { ...parsed }; - delete copy.model; - return JSON.stringify(copy); - } - return content; - } catch { - return content; - } -}; - -const ensureRecord = (value: unknown): Record => { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - return value as Record; -}; - -const readAutoCompactContextFromRecord = (value: unknown) => { - const compaction = ensureRecord(ensureRecord(value).compaction); - return typeof compaction.auto === "boolean" ? compaction.auto : null; -}; - -const readStoredDefaultModel = () => { - if (typeof window === "undefined") return DEFAULT_MODEL; - try { - const stored = window.localStorage.getItem(MODEL_PREF_KEY); - return parseModelRef(stored) ?? DEFAULT_MODEL; - } catch { - return DEFAULT_MODEL; - } -}; - -export const sessionModelOverridesKey = (workspaceId: string) => - `${SESSION_MODEL_PREF_KEY}.${workspaceId}`; - -export const workspaceModelVariantsKey = (workspaceId: string) => - `${VARIANT_PREF_KEY}.${workspaceId}`; - -export const parseSessionChoiceOverrides = (raw: string | null) => { - if (!raw) return {} as Record; - - try { - const parsed = JSON.parse(raw) as Record; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {} as Record; - } - - const next: Record = {}; - for (const [sessionId, value] of Object.entries(parsed)) { - if (typeof value === "string") { - const model = parseModelRef(value); - if (model) next[sessionId] = { model }; - continue; - } - - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const record = value as Record; - const model = parseStoredModel(record.model ?? record); - const choice = normalizeSessionChoice({ - ...(model ? { model } : {}), - ...(hasOwn(record, "variant") ? { variant: normalizeVariantOverride(record.variant) } : {}), - }); - - if (choice) next[sessionId] = choice; - } - - return next; - } catch { - return {} as Record; - } -}; - -export const serializeSessionChoiceOverrides = ( - overrides: Record, -) => { - const entries = Object.entries(overrides) - .map(([sessionId, choice]) => [sessionId, normalizeSessionChoice(choice)] as const) - .filter((entry): entry is readonly [string, SessionChoiceOverride] => Boolean(entry[1])); - - if (!entries.length) return null; - - const payload: Record = {}; - for (const [sessionId, choice] of entries) { - const next: { model?: string; variant?: string | null } = {}; - if (choice.model) next.model = formatModelRef(choice.model); - if (hasOwn(choice, "variant")) next.variant = choice.variant ?? null; - payload[sessionId] = next; - } - - return JSON.stringify(payload); -}; - -export const parseWorkspaceModelVariants = ( - raw: string | null, - fallbackModel: ModelRef = DEFAULT_MODEL, -) => { - if (!raw || !raw.trim()) return {} as Record; - - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - const normalized = normalizeModelBehaviorValue(raw); - return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; - } - - const next: Record = {}; - for (const [key, value] of Object.entries(parsed as Record)) { - const normalized = normalizeVariantOverride(value); - if (normalized) next[key] = normalized; - } - return next; - } catch { - const normalized = normalizeModelBehaviorValue(raw); - return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; - } -}; - -export function createModelConfigStore(options: { - client: Accessor; - selectedSessionId: Accessor; - messages: Accessor; - providers: Accessor; - providerDefaults: Accessor>; - providerConnectedIds: Accessor; - selectedWorkspaceId: Accessor; - selectedWorkspaceDisplay: Accessor; - selectedWorkspacePath: Accessor; - openworkServerClient: Accessor; - openworkServerStatus: Accessor; - openworkServerCapabilities: Accessor; - runtimeWorkspaceId: Accessor; - checkDesktopAppRestriction: DesktopAppRestrictionChecker; - focusSessionPromptSoon: () => void; - setError: (value: string | null) => void; - setLastKnownConfigSnapshot: (value: string) => void; - markOpencodeConfigReloadRequired: () => void; -}) { - const initialDefaultModel = readStoredDefaultModel(); - - const [sessionChoiceOverrideById, setSessionChoiceOverrideById] = createSignal< - Record - >({}); - const [sessionModelById, setSessionModelById] = createSignal>({}); - const [pendingSessionChoice, setPendingSessionChoice] = createSignal( - null, - ); - const [sessionModelOverridesReady, setSessionModelOverridesReady] = createSignal(false); - const [workspaceVariantMap, setWorkspaceVariantMap] = createSignal>({}); - - const [defaultModel, setDefaultModel] = createSignal(initialDefaultModel); - const [legacyDefaultModel, setLegacyDefaultModel] = createSignal(initialDefaultModel); - const [defaultModelExplicit, setDefaultModelExplicit] = createSignal(false); - const [workspaceDefaultModelReady, setWorkspaceDefaultModelReady] = createSignal(false); - const [pendingDefaultModelByWorkspace, setPendingDefaultModelByWorkspace] = createSignal< - Record - >({}); - - const [autoCompactContextReady, setAutoCompactContextReady] = createSignal(false); - const [autoCompactContextDirty, setAutoCompactContextDirty] = createSignal(false); - const [autoCompactContextApplied, setAutoCompactContextApplied] = createSignal(true); - const [autoCompactContextSaving, setAutoCompactContextSaving] = createSignal(false); - const [autoCompactContext, setAutoCompactContext] = createSignal(true); - - const [modelPickerOpen, setModelPickerOpen] = createSignal(false); - const [modelPickerTarget, setModelPickerTarget] = createSignal("session"); - const [modelPickerQuery, setModelPickerQuery] = createSignal(""); - const [modelPickerReturnFocusTarget, setModelPickerReturnFocusTarget] = - createSignal("none"); - - const sessionModelState = createMemo(() => ({ - overrides: deriveSessionModelOverrides(sessionChoiceOverrideById()), - resolved: sessionModelById(), - })); - - const setSessionModelState = ( - updater: (current: SessionModelState) => SessionModelState, - ) => { - const next = updater(sessionModelState()); - setSessionChoiceOverrideById((current) => applySessionModelState(current, next)); - setSessionModelById(next.resolved); - return next; - }; - - const setWorkspaceVariant = (ref: ModelRef, value: string | null) => { - const key = formatModelRef(ref); - const normalized = normalizeModelBehaviorValue(value); - - setWorkspaceVariantMap((current) => { - const next = { ...current }; - if (normalized) next[key] = normalized; - else delete next[key]; - return next; - }); - }; - - const setPendingSessionModel = (model: ModelRef) => { - setPendingSessionChoice((current) => - normalizeSessionChoice({ - model, - ...(current && hasOwn(current, "variant") ? { variant: current.variant ?? null } : {}), - }), - ); - }; - - const setPendingSessionVariant = (value: string | null) => { - setPendingSessionChoice((current) => - normalizeSessionChoice({ - ...(current?.model ? { model: current.model } : {}), - variant: normalizeModelBehaviorValue(value), - }), - ); - }; - - const clearPendingSessionChoice = () => setPendingSessionChoice(null); - - const applyPendingSessionChoice = (sessionId: string) => { - const pending = normalizeSessionChoice(pendingSessionChoice()); - if (!pending) return; - - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - const next = normalizeSessionChoice({ - ...(existing?.model ? { model: existing.model } : {}), - ...(pending.model ? { model: pending.model } : {}), - ...(hasOwn(existing ?? {}, "variant") ? { variant: existing?.variant ?? null } : {}), - ...(hasOwn(pending, "variant") ? { variant: pending.variant ?? null } : {}), - }); - if (!next) return current; - return { ...current, [sessionId]: next }; - }); - - setPendingSessionChoice(null); - }; - - const setSessionModelOverride = (sessionId: string, model: ModelRef) => { - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - const preserveVariant = - existing?.model && - modelEquals(existing.model, model) && - hasOwn(existing, "variant") - ? { variant: existing.variant ?? null } - : hasOwn(existing ?? {}, "variant") && existing?.variant == null - ? { variant: null } - : {}; - - const next = normalizeSessionChoice({ model, ...preserveVariant }); - if (!next) return current; - return { ...current, [sessionId]: next }; - }); - }; - - const clearSessionModelOverride = (sessionId: string) => { - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - if (!existing) return current; - - const next = normalizeSessionChoice( - hasOwn(existing, "variant") ? { variant: existing.variant ?? null } : null, - ); - - const copy = { ...current }; - if (next) copy[sessionId] = next; - else delete copy[sessionId]; - return copy; - }); - }; - - const setSessionVariantOverride = (sessionId: string, value: string | null) => { - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - const next = normalizeSessionChoice({ - ...(existing?.model ? { model: existing.model } : {}), - variant: normalizeModelBehaviorValue(value), - }); - - if (!next) { - const copy = { ...current }; - delete copy[sessionId]; - return copy; - } - - return { ...current, [sessionId]: next }; - }); - }; - - const getWorkspaceVariantFor = (ref: ModelRef) => - workspaceVariantMap()[formatModelRef(ref)] ?? null; - - const isBlockedModelRef = (ref: ModelRef) => - isDesktopModelBlocked({ - model: ref, - checkRestriction: options.checkDesktopAppRestriction, - }); - - const restrictToInstalledModels = () => - options.checkDesktopAppRestriction({ restriction: "disallowNonCloudModels" }); - - const isInstalledProvider = (providerId: string) => - options.providerConnectedIds().some((id) => id.trim() === providerId.trim()); - - const isRestrictedModelRef = (ref: ModelRef) => { - if (isBlockedModelRef(ref)) { - return true; - } - - if (restrictToInstalledModels() && !isInstalledProvider(ref.providerID)) { - return true; - } - - return false; - }; - - const listAllowedModelRefs = () => { - const sortedProviders = options.providers().slice().sort(compareProviders); - const next: ModelRef[] = []; - - for (const provider of sortedProviders) { - const providerId = provider.id?.trim(); - if (!providerId) continue; - if (restrictToInstalledModels() && !isInstalledProvider(providerId)) continue; - - const models = Object.values(provider.models ?? {}) - .filter((model) => model.status !== "deprecated") - .sort((a, b) => { - const aFree = a.cost?.input === 0 && a.cost?.output === 0; - const bFree = b.cost?.input === 0 && b.cost?.output === 0; - if (aFree !== bFree) return aFree ? -1 : 1; - return (a.name ?? a.id).localeCompare(b.name ?? b.id); - }); - - for (const model of models) { - const ref = { providerID: providerId, modelID: model.id }; - if (isRestrictedModelRef(ref)) continue; - next.push(ref); - } - } - - return next; - }; - - const resolveAllowedModelFallback = () => { - if (!isRestrictedModelRef(defaultModel())) { - return defaultModel(); - } - - const allowed = listAllowedModelRefs(); - if (allowed.length > 0) { - return allowed[0]; - } - - return isRestrictedModelRef(DEFAULT_MODEL) ? null : DEFAULT_MODEL; - }; - - const getVariantFor = (ref: ModelRef, sessionId?: string | null) => { - if (sessionId) { - const choice = sessionChoiceOverrideById()[sessionId]; - if (choice && hasOwn(choice, "variant")) { - return choice.variant ?? null; - } - } else { - const pending = pendingSessionChoice(); - if (pending && hasOwn(pending, "variant")) { - return pending.variant ?? null; - } - } - - return getWorkspaceVariantFor(ref); - }; - - const selectedSessionModel = createMemo(() => { - const id = options.selectedSessionId(); - const pendingChoice = pendingSessionChoice(); - if (!id) { - if (pendingChoice?.model && !isRestrictedModelRef(pendingChoice.model)) { - return pendingChoice.model; - } - return defaultModel(); - } - - const override = sessionChoiceOverrideById()[id]?.model; - if (override && !isRestrictedModelRef(override)) return override; - - const known = sessionModelById()[id]; - if (known && !isRestrictedModelRef(known)) return known; - - const fromMessages = lastUserModelFromMessages(options.messages()); - if (fromMessages && !isRestrictedModelRef(fromMessages)) return fromMessages; - - return defaultModel(); - }); - - const modelVariant = createMemo(() => - getVariantFor(selectedSessionModel(), options.selectedSessionId()), - ); - - const resolveCodexReasoningEffort = (modelID: string, variant: string | null) => { - if (!modelID.trim().toLowerCase().includes("codex")) return undefined; - const normalized = normalizeModelBehaviorValue(variant); - if (!normalized || normalized === "none") return undefined; - if (normalized === "minimal") return "low"; - if (normalized === "xhigh" || normalized === "max") return "high"; - if (!["low", "medium", "high"].includes(normalized)) return undefined; - return normalized; - }; - - const findProviderModel = (ref: ModelRef) => { - const provider = options.providers().find((entry) => entry.id === ref.providerID); - return provider?.models?.[ref.modelID] ?? null; - }; - - const sanitizeModelVariantForRef = (ref: ModelRef, value: string | null) => { - const provider = options.providers().find((entry) => entry.id === ref.providerID) ?? null; - const modelInfo = findProviderModel(ref); - if (!modelInfo) return normalizeModelBehaviorValue(value); - return sanitizeModelBehaviorValue(ref.providerID, modelInfo, value, provider?.name); - }; - - const getModelBehaviorCopy = (ref: ModelRef, value: string | null) => { - const provider = options.providers().find((entry) => entry.id === ref.providerID) ?? null; - const modelInfo = findProviderModel(ref); - if (!modelInfo) { - return { - title: t("app.model_behavior_title", currentLocale()), - label: formatGenericBehaviorLabel(value), - description: t("app.model_behavior_desc", currentLocale()), - options: [], - }; - } - return getModelBehaviorSummary(ref.providerID, modelInfo, value, provider?.name); - }; - - const selectedSessionModelLabel = createMemo(() => - formatModelLabel(selectedSessionModel(), options.providers()), - ); - - const sessionModelVariantLabel = createMemo( - () => getModelBehaviorCopy(selectedSessionModel(), modelVariant()).label, - ); - - const sessionModelBehaviorOptions = createMemo( - () => getModelBehaviorCopy(selectedSessionModel(), modelVariant()).options, - ); - - const defaultModelLabel = createMemo(() => formatModelLabel(defaultModel(), options.providers())); - const defaultModelRef = createMemo(() => formatModelRef(defaultModel())); - const defaultModelVariantLabel = createMemo( - () => getModelBehaviorCopy(defaultModel(), getWorkspaceVariantFor(defaultModel())).label, - ); - - const modelPickerCurrent = createMemo(() => - modelPickerTarget() === "default" ? defaultModel() : selectedSessionModel(), - ); - - const isHeroModel = (id: string) => id.toLowerCase().includes("gpt-5"); - - const modelOptions = createMemo(() => { - const allProviders = options.providers(); - const defaults = options.providerDefaults(); - const currentDefault = defaultModel(); - - if (!allProviders.length) { - const behavior = getModelBehaviorCopy(DEFAULT_MODEL, getWorkspaceVariantFor(DEFAULT_MODEL)); - return [ - { - providerID: DEFAULT_MODEL.providerID, - modelID: DEFAULT_MODEL.modelID, - title: DEFAULT_MODEL.modelID, - description: DEFAULT_MODEL.providerID, - footer: t("settings.model_fallback", currentLocale()), - behaviorTitle: behavior.title, - behaviorLabel: behavior.label, - behaviorDescription: behavior.description, - behaviorValue: normalizeModelBehaviorValue(getWorkspaceVariantFor(DEFAULT_MODEL)), - behaviorOptions: behavior.options, - isFree: true, - isConnected: false, - }, - ]; - } - - const sortedProviders = allProviders.slice().sort(compareProviders); - const next: ModelOption[] = []; - - for (const provider of sortedProviders) { - const defaultModelID = defaults[provider.id]; - const isConnected = options.providerConnectedIds().includes(provider.id); - if (restrictToInstalledModels() && !isConnected) continue; - const models = Object.values(provider.models ?? {}).filter((m) => m.status !== "deprecated"); - - models.sort((a, b) => { - const aFree = a.cost?.input === 0 && a.cost?.output === 0; - const bFree = b.cost?.input === 0 && b.cost?.output === 0; - if (aFree !== bFree) return aFree ? -1 : 1; - return (a.name ?? a.id).localeCompare(b.name ?? b.id); - }); - - for (const model of models) { - const isFree = model.cost?.input === 0 && model.cost?.output === 0; - const isDefault = - provider.id === currentDefault.providerID && model.id === currentDefault.modelID; - const ref = { providerID: provider.id, modelID: model.id }; - if (isRestrictedModelRef(ref)) continue; - const activeVariant = - modelPickerTarget() === "session" && modelEquals(ref, selectedSessionModel()) - ? modelVariant() - : getWorkspaceVariantFor(ref); - const behavior = getModelBehaviorSummary(provider.id, model, activeVariant, provider.name); - const behaviorValue = sanitizeModelBehaviorValue(provider.id, model, activeVariant, provider.name); - const footerBits: string[] = []; - if (defaultModelID === model.id || isDefault) { - footerBits.push(t("settings.model_default", currentLocale())); - } - if (model.capabilities?.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale())); - - next.push({ - providerID: provider.id, - modelID: model.id, - title: model.name ?? model.id, - description: provider.name, - footer: footerBits.length ? footerBits.slice(0, 2).join(" · ") : undefined, - behaviorTitle: behavior.title, - behaviorLabel: behavior.label, - behaviorDescription: behavior.description, - behaviorValue, - behaviorOptions: behavior.options, - disabled: !isConnected, - isFree, - isConnected, - isRecommended: isHeroModel(model.id), - }); - } - } - - next.sort((a, b) => { - if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; - if (a.isFree !== b.isFree) return a.isFree ? -1 : 1; - const providerRankDiff = providerPriorityRank(a.providerID) - providerPriorityRank(b.providerID); - if (providerRankDiff !== 0) return providerRankDiff; - return a.title.localeCompare(b.title); - }); - - return next; - }); - - const filteredModelOptions = createMemo(() => { - const q = modelPickerQuery().trim().toLowerCase(); - const optionsList = modelOptions(); - if (!q) return optionsList; - - return optionsList.filter((opt) => { - const haystack = [ - opt.title, - opt.description ?? "", - opt.footer ?? "", - opt.behaviorTitle, - opt.behaviorLabel, - opt.behaviorDescription, - `${opt.providerID}/${opt.modelID}`, - opt.isConnected ? "connected" : "disconnected", - opt.isFree ? "free" : "paid", - ] - .join(" ") - .toLowerCase(); - return haystack.includes(q); - }); - }); - - const setPendingDefaultModelForWorkspace = (workspaceId: string, model: ModelRef | null) => { - const id = workspaceId.trim(); - if (!id) return; - setPendingDefaultModelByWorkspace((current) => { - const next = { ...current }; - if (model) { - next[id] = formatModelRef(model); - } else { - delete next[id]; - } - return next; - }); - }; - - const pendingDefaultModelForWorkspace = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return null; - return pendingDefaultModelByWorkspace()[id] ?? null; - }; - - const applyDefaultModelChoice = (next: ModelRef) => { - const workspaceId = options.selectedWorkspaceId().trim(); - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, next); - } - setDefaultModelExplicit(true); - setDefaultModel(next); - setLegacyDefaultModel(next); - }; - - const closeModelPicker = (opts?: { restorePromptFocus?: boolean }) => { - const shouldFocusPrompt = - opts?.restorePromptFocus ?? modelPickerReturnFocusTarget() === "composer"; - setModelPickerOpen(false); - setModelPickerReturnFocusTarget("none"); - if (shouldFocusPrompt) { - options.focusSessionPromptSoon(); - } - }; - - const openSessionModelPicker = (opts?: { - returnFocusTarget?: PromptFocusReturnTarget; - }) => { - setModelPickerTarget("session"); - setModelPickerQuery(""); - setModelPickerReturnFocusTarget(opts?.returnFocusTarget ?? "composer"); - setModelPickerOpen(true); - }; - - const openDefaultModelPicker = () => { - setModelPickerTarget("default"); - setModelPickerQuery(""); - setModelPickerReturnFocusTarget("none"); - setModelPickerOpen(true); - }; - - const applyModelSelection = (next: ModelRef) => { - const target = modelPickerTarget(); - const restorePromptFocus = target === "session"; - - if (target === "default") { - applyDefaultModelChoice(next); - closeModelPicker({ restorePromptFocus: false }); - return; - } - - const id = options.selectedSessionId(); - if (!id) { - setPendingSessionModel(next); - closeModelPicker({ restorePromptFocus }); - return; - } - - setSessionModelOverride(id, next); - closeModelPicker({ restorePromptFocus }); - }; - - const setModelPickerBehavior = (model: ModelRef, value: string | null) => { - const nextValue = sanitizeModelVariantForRef(model, value); - if (modelPickerTarget() === "default") { - setWorkspaceVariant(model, nextValue); - return; - } - - const sessionId = options.selectedSessionId(); - if (sessionId) { - setSessionVariantOverride(sessionId, nextValue); - return; - } - - setPendingSessionVariant(nextValue); - }; - - const setSessionModelVariant = (value: string | null) => { - const sessionId = options.selectedSessionId(); - const nextValue = sanitizeModelVariantForRef(selectedSessionModel(), value); - if (sessionId) { - setSessionVariantOverride(sessionId, nextValue); - return; - } - setPendingSessionVariant(nextValue); - }; - - const toggleAutoCompactContext = () => { - if (autoCompactContextSaving()) return; - setAutoCompactContext((value) => !value); - setAutoCompactContextDirty(true); - }; - - const resetAppDefaults = () => { - if (typeof window !== "undefined") { - try { - const sessionOverridePrefix = `${SESSION_MODEL_PREF_KEY}.`; - const workspaceVariantPrefix = `${VARIANT_PREF_KEY}.`; - const keysToRemove: string[] = []; - for (let index = 0; index < window.localStorage.length; index += 1) { - const key = window.localStorage.key(index); - if (!key) continue; - if ( - key.startsWith(sessionOverridePrefix) || - key.startsWith(workspaceVariantPrefix) || - key === VARIANT_PREF_KEY - ) { - keysToRemove.push(key); - } - } - for (const key of keysToRemove) { - window.localStorage.removeItem(key); - } - } catch { - // ignore - } - } - - setDefaultModel(DEFAULT_MODEL); - setLegacyDefaultModel(DEFAULT_MODEL); - setDefaultModelExplicit(false); - setWorkspaceDefaultModelReady(false); - setPendingDefaultModelByWorkspace({}); - setAutoCompactContext(false); - setAutoCompactContextApplied(false); - setAutoCompactContextDirty(false); - setAutoCompactContextReady(false); - setAutoCompactContextSaving(false); - clearPendingSessionChoice(); - setSessionChoiceOverrideById({}); - setSessionModelById({}); - setWorkspaceVariantMap({}); - closeModelPicker({ restorePromptFocus: false }); - }; - - const reconcileRestrictedModels = () => { - const isRestrictedChoice = (model: ModelRef | null | undefined) => { - if (!model) return false; - return isRestrictedModelRef(model); - }; - - const fallback = resolveAllowedModelFallback(); - - if (isRestrictedChoice(defaultModel()) && fallback && !modelEquals(defaultModel(), fallback)) { - applyDefaultModelChoice(fallback); - } - - setPendingSessionChoice((current) => { - if (!current?.model || !isRestrictedChoice(current.model)) { - return current; - } - - return hasOwn(current, "variant") - ? { variant: current.variant ?? null } - : null; - }); - - setSessionChoiceOverrideById((current) => { - const next: Record = {}; - let changed = false; - - for (const [sessionId, choice] of Object.entries(current)) { - if (!choice.model || !isRestrictedChoice(choice.model)) { - next[sessionId] = choice; - continue; - } - - changed = true; - const stripped = normalizeSessionChoice( - hasOwn(choice, "variant") ? { variant: choice.variant ?? null } : null, - ); - if (stripped) { - next[sessionId] = stripped; - } - } - - return changed ? next : current; - }); - - setSessionModelById((current) => { - const next = Object.fromEntries( - Object.entries(current).filter(([, model]) => !isRestrictedChoice(model)), - ); - return Object.keys(next).length === Object.keys(current).length ? current : next; - }); - - setWorkspaceVariantMap((current) => { - const next = Object.fromEntries( - Object.entries(current).filter(([ref]) => { - const parsed = parseModelRef(ref); - return !parsed || !isRestrictedChoice(parsed); - }), - ); - return Object.keys(next).length === Object.keys(current).length ? current : next; - }); - }; - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) return; - - setSessionModelOverridesReady(false); - const raw = window.localStorage.getItem(sessionModelOverridesKey(workspaceId)); - setSessionChoiceOverrideById(parseSessionChoiceOverrides(raw)); - setSessionModelOverridesReady(true); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!sessionModelOverridesReady()) return; - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) return; - - const payload = serializeSessionChoiceOverrides(sessionChoiceOverrideById()); - try { - if (payload) { - window.localStorage.setItem(sessionModelOverridesKey(workspaceId), payload); - } else { - window.localStorage.removeItem(sessionModelOverridesKey(workspaceId)); - } - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId().trim(); - if (!workspaceId) { - setWorkspaceVariantMap({}); - return; - } - - const scopedRaw = window.localStorage.getItem(workspaceModelVariantsKey(workspaceId)); - const legacyRaw = scopedRaw == null ? window.localStorage.getItem(VARIANT_PREF_KEY) : null; - setWorkspaceVariantMap(parseWorkspaceModelVariants(scopedRaw ?? legacyRaw, defaultModel())); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId().trim(); - if (!workspaceId) return; - - try { - const map = workspaceVariantMap(); - const key = workspaceModelVariantsKey(workspaceId); - if (Object.keys(map).length > 0) { - window.localStorage.setItem(key, JSON.stringify(map)); - } else { - window.localStorage.removeItem(key); - } - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(MODEL_PREF_KEY, formatModelRef(defaultModel())); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) return; - - setWorkspaceDefaultModelReady(false); - const workspace = options.selectedWorkspaceDisplay(); - const workspaceRoot = options.selectedWorkspacePath().trim(); - const activeClient = options.client(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.read; - - let cancelled = false; - - const applyDefault = async () => { - let configDefault: ModelRef | null = null; - let configFileContent: string | null = null; - - if (workspace.workspaceType === "local" && workspaceRoot) { - if (canUseOpenworkServer) { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const model = typeof config.opencode?.model === "string" ? config.opencode.model : null; - configDefault = parseModelRef(model); - } catch { - // ignore - } - } else if (isTauriRuntime()) { - try { - const configFile = await readOpencodeConfig("project", workspaceRoot); - configFileContent = configFile.content; - configDefault = parseDefaultModelFromConfig(configFile.content); - } catch { - // ignore - } - } - } else if (activeClient) { - try { - const config = await activeClient.config.get({ directory: workspaceRoot || undefined }); - const payload = "data" in config ? config.data : config; - if (typeof payload?.model === "string") { - configDefault = parseModelRef(payload.model); - } - } catch { - // ignore - } - } - - const pendingModelRef = pendingDefaultModelForWorkspace(workspaceId); - const loadedModelRef = configDefault ? formatModelRef(configDefault) : null; - - if (pendingModelRef && pendingModelRef !== loadedModelRef) { - if (workspace.workspaceType === "local" && workspaceRoot) { - options.setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); - } - - if (!cancelled) { - setWorkspaceDefaultModelReady(true); - } - return; - } - - if (pendingModelRef && loadedModelRef === pendingModelRef) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - - setDefaultModelExplicit(Boolean(configDefault)); - const nextDefault = configDefault ?? legacyDefaultModel(); - const currentDefault = defaultModel(); - if (nextDefault && !modelEquals(currentDefault, nextDefault)) { - setDefaultModel(nextDefault); - } - const currentLegacyDefault = legacyDefaultModel(); - if (nextDefault && !modelEquals(currentLegacyDefault, nextDefault)) { - setLegacyDefaultModel(nextDefault); - } - - if (workspace.workspaceType === "local" && workspaceRoot) { - options.setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); - } - - if (!cancelled) { - setWorkspaceDefaultModelReady(true); - } - }; - - void applyDefault(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - if (!workspaceDefaultModelReady()) return; - if (!isTauriRuntime()) return; - if (!defaultModelExplicit()) return; - - const workspace = options.selectedWorkspaceDisplay(); - const workspaceId = options.selectedWorkspaceId().trim(); - if (workspace.workspaceType !== "local") return; - - const root = options.selectedWorkspacePath().trim(); - if (!root) return; - const nextModel = defaultModel(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.write; - let cancelled = false; - - const writeConfig = async () => { - try { - if (canUseOpenworkServer) { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const currentModel = - typeof config.opencode?.model === "string" ? parseModelRef(config.opencode.model) : null; - if (currentModel && modelEquals(currentModel, nextModel)) { - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - return; - } - - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { model: formatModelRef(nextModel) }, - }); - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - options.markOpencodeConfigReloadRequired(); - return; - } - - const configFile = await readOpencodeConfig("project", root); - const existingModel = parseDefaultModelFromConfig(configFile.content); - if (existingModel && modelEquals(existingModel, nextModel)) { - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - return; - } - - const content = formatConfigWithDefaultModel(configFile.content, nextModel); - const result = await writeOpencodeConfig("project", root, content); - if (!result.ok) { - throw new Error(result.stderr || result.stdout || t("app.error_update_opencode_json", currentLocale())); - } - options.setLastKnownConfigSnapshot(getConfigSnapshot(content)); - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - options.markOpencodeConfigReloadRequired(); - } catch (error) { - if (cancelled) return; - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } - }; - - void writeConfig(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) { - setAutoCompactContext(true); - setAutoCompactContextApplied(true); - setAutoCompactContextDirty(false); - setAutoCompactContextReady(false); - setAutoCompactContextSaving(false); - return; - } - - const workspace = options.selectedWorkspaceDisplay(); - const root = options.selectedWorkspacePath().trim(); - const activeClient = options.client(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.read; - - let cancelled = false; - setAutoCompactContextReady(false); - setAutoCompactContextDirty(false); - - const loadAutoCompactContext = async () => { - let nextValue = true; - - if (canUseOpenworkServer) { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - nextValue = readAutoCompactContextFromRecord(config.opencode) ?? true; - } catch { - // ignore - } - } else if (workspace.workspaceType === "local" && root && isTauriRuntime()) { - try { - const configFile = await readOpencodeConfig("project", root); - nextValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; - } catch { - // ignore - } - } else if (activeClient) { - try { - const config = await activeClient.config.get({ directory: root || undefined }); - const payload = "data" in config ? config.data : config; - nextValue = readAutoCompactContextFromRecord(payload) ?? true; - } catch { - // ignore - } - } - - if (cancelled) return; - setAutoCompactContext(nextValue); - setAutoCompactContextApplied(nextValue); - setAutoCompactContextReady(true); - }; - - void loadAutoCompactContext(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - if (!autoCompactContextReady()) return; - if (!autoCompactContextDirty()) return; - - const nextValue = autoCompactContext(); - const appliedValue = autoCompactContextApplied(); - const workspace = options.selectedWorkspaceDisplay(); - const root = options.selectedWorkspacePath().trim(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.write; - - let cancelled = false; - setAutoCompactContextSaving(true); - - const persistAutoCompactContext = async () => { - try { - if (canUseOpenworkServer) { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const currentValue = readAutoCompactContextFromRecord(config.opencode) ?? true; - if (currentValue !== nextValue) { - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { - compaction: { - auto: nextValue, - }, - }, - }); - options.markOpencodeConfigReloadRequired(); - } - if (cancelled) return; - setAutoCompactContextApplied(nextValue); - setAutoCompactContextDirty(false); - return; - } - - if (workspace.workspaceType !== "local" || !root || !isTauriRuntime()) { - throw new Error( - t("app.error_auto_compact_scope", currentLocale()), - ); - } - - const configFile = await readOpencodeConfig("project", root); - const currentValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; - if (currentValue !== nextValue) { - const content = formatConfigWithAutoCompactContext(configFile.content, nextValue); - const result = await writeOpencodeConfig("project", root, content); - if (!result.ok) { - throw new Error(result.stderr || result.stdout || t("app.error_update_opencode_json", currentLocale())); - } - options.setLastKnownConfigSnapshot(getConfigSnapshot(content)); - options.markOpencodeConfigReloadRequired(); - } - - if (cancelled) return; - setAutoCompactContextApplied(nextValue); - setAutoCompactContextDirty(false); - } catch (error) { - if (cancelled) return; - setAutoCompactContext(appliedValue); - setAutoCompactContextDirty(false); - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } finally { - setAutoCompactContextSaving(false); - } - }; - - void persistAutoCompactContext(); - - onCleanup(() => { - cancelled = true; - }); - }); - - return { - sessionChoiceOverrideById, - setSessionChoiceOverrideById, - sessionModelById, - setSessionModelById, - sessionModelState, - setSessionModelState, - pendingSessionChoice, - setPendingSessionModel, - setPendingSessionVariant, - clearPendingSessionChoice, - applyPendingSessionChoice, - sessionModelOverridesReady, - setSessionModelOverridesReady, - workspaceVariantMap, - setWorkspaceVariantMap, - setWorkspaceVariant, - setSessionModelOverride, - clearSessionModelOverride, - setSessionVariantOverride, - getWorkspaceVariantFor, - getVariantFor, - defaultModel, - selectedSessionModel, - selectedSessionModelLabel, - defaultModelLabel, - defaultModelRef, - defaultModelVariantLabel, - modelVariant, - sessionModelVariantLabel, - sessionModelBehaviorOptions, - setSessionModelVariant, - sanitizeModelVariantForRef, - resolveCodexReasoningEffort, - modelPickerOpen, - modelPickerQuery, - setModelPickerQuery, - modelPickerTarget, - modelPickerCurrent, - modelOptions, - filteredModelOptions, - openSessionModelPicker, - openDefaultModelPicker, - closeModelPicker, - applyModelSelection, - setModelPickerBehavior, - reconcileRestrictedModels, - autoCompactContext, - toggleAutoCompactContext, - autoCompactContextSaving, - resetAppDefaults, - }; -} diff --git a/apps/app/src/app/context/platform.tsx b/apps/app/src/app/context/platform.tsx deleted file mode 100644 index 78df67a2..00000000 --- a/apps/app/src/app/context/platform.tsx +++ /dev/null @@ -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; - setItem(key: string, value: string): Promise; - removeItem(key: string): Promise; -}; - -export type Platform = { - platform: "web" | "desktop"; - os?: "macos" | "windows" | "linux"; - version?: string; - openLink(url: string): void; - restart(): Promise; - notify(title: string, description?: string, href?: string): Promise; - storage?: (name?: string) => SyncStorage | AsyncStorage; - checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>; - update?: () => Promise; - fetch?: typeof fetch; - getDefaultServerUrl?: () => Promise; - setDefaultServerUrl?: (url: string | null) => Promise; -}; - -const PlatformContext = createContext(undefined); - -export function PlatformProvider(props: ParentProps & { value: Platform }) { - return ( - - {props.children} - - ); -} - -export function usePlatform() { - const context = useContext(PlatformContext); - if (!context) { - throw new Error("Platform context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/providers/index.ts b/apps/app/src/app/context/providers/index.ts deleted file mode 100644 index c5e52092..00000000 --- a/apps/app/src/app/context/providers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createProvidersStore } from "./store"; -export type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store"; -export { default as ProviderAuthModal } from "./provider-auth-modal"; diff --git a/apps/app/src/app/context/providers/provider-auth-modal.tsx b/apps/app/src/app/context/providers/provider-auth-modal.tsx deleted file mode 100644 index b2f14125..00000000 --- a/apps/app/src/app/context/providers/provider-auth-modal.tsx +++ /dev/null @@ -1,1056 +0,0 @@ -import { CheckCircle2, Loader2, X, Search, ChevronRight } from "lucide-solid"; -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; - -import { isTauriRuntime } from "../../utils"; -import { compareProviders } from "../../utils/providers"; -import Button from "../../components/button"; -import ProviderIcon from "../../components/provider-icon"; -import TextInput from "../../components/text-input"; -import { useDesktopConfig } from "../../cloud/desktop-config-provider"; -import type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store"; - -type ProviderAuthEntry = { - id: string; - name: string; - methods: ProviderAuthMethod[]; - connected: boolean; - env: string[]; -}; - -type ProviderOAuthSession = ProviderOAuthStartResult & { - providerId: string; - methodLabel: string; -}; - -const PROVIDER_LABELS: Record = { - opencode: "OpenCode", - openai: "OpenAI", - anthropic: "Anthropic", - google: "Google", - openrouter: "OpenRouter", -}; - -export type ProviderAuthModalProps = { - open: boolean; - loading: boolean; - submitting: boolean; - error: string | null; - restricted?: boolean; - restrictedMessage?: string | null; - preferredProviderId?: string | null; - workerType?: "local" | "remote"; - providers: ProviderAuthProvider[]; - connectedProviderIds: string[]; - authMethods: Record; - onSelect: (providerId: string, methodIndex?: number) => Promise; - onSubmitApiKey: (providerId: string, apiKey: string) => Promise; - onConnectCloudProvider: (cloudProviderId: string) => Promise; - onSubmitOAuth: ( - providerId: string, - methodIndex: number, - code?: string - ) => Promise<{ connected: boolean; pending?: boolean; message?: string }>; - onRefreshProviders?: () => Promise; - onClose: () => void; -}; - -export default function ProviderAuthModal(props: ProviderAuthModalProps) { - const desktopConfig = useDesktopConfig(); - const workerType = createMemo(() => (props.workerType === "remote" ? "remote" : "local")); - const isRemoteWorker = createMemo(() => workerType() === "remote"); - const restricted = createMemo(() => - props.restricted ?? desktopConfig.checkRestriction({ restriction: "disallowNonCloudModels" }), - ); - const restrictionMessage = createMemo( - () => - props.restrictedMessage?.trim() || - "Your administrator has restricted which providers and models are allowed. Please reach out to them to add new providers and models.", - ); - - const formatProviderName = (id: string, fallback?: string) => { - const named = fallback?.trim(); - if (named) return named; - - const normalized = id.trim(); - const mapped = PROVIDER_LABELS[normalized.toLowerCase()]; - if (mapped) return mapped; - - const cleaned = normalized.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); - if (!cleaned) return id; - - return cleaned - .split(" ") - .filter(Boolean) - .map((word) => { - if (/\d/.test(word) || word.length <= 3) { - return word.toUpperCase(); - } - const lower = word.toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); - }) - .join(" "); - }; - - const isOpenAiHeadlessMethod = (method: ProviderAuthMethod) => { - const label = method.label.toLowerCase(); - return method.type === "oauth" && (label.includes("headless") || label.includes("device")); - }; - - const isOpenAiProvider = (id: string, fallbackName?: string) => { - const normalizedId = id.trim().toLowerCase(); - const normalizedName = fallbackName?.trim().toLowerCase() ?? ""; - return normalizedId === "openai" || normalizedName.includes("openai"); - }; - - // TODO: remove once we upgrade to opencode 1.3.0 — the Claude Pro/Max OAuth - // method is dropped upstream there, so this client-side filter is no longer needed. - const isAnthropicProvider = (id: string, fallbackName?: string) => { - const normalizedId = id.trim().toLowerCase(); - const normalizedName = fallbackName?.trim().toLowerCase() ?? ""; - return normalizedId === "anthropic" || normalizedName.includes("anthropic"); - }; - - const isClaudeProMaxMethod = (method: ProviderAuthMethod) => { - const label = method.label.toLowerCase(); - return method.type === "oauth" && (label.includes("pro/max") || label.includes("create an api key")); - }; - - const entries = createMemo(() => { - const methods = props.authMethods ?? {}; - const connected = new Set(props.connectedProviderIds ?? []); - const providers = props.providers ?? []; - - return Object.keys(methods) - .map((id): ProviderAuthEntry => { - const provider = providers.find((item) => item.id === id); - const entryMethods = (methods[id] ?? []).filter((method) => { - if (isAnthropicProvider(id, provider?.name) && isClaudeProMaxMethod(method)) { - return false; - } - if (!isOpenAiProvider(id, provider?.name)) return true; - if (method.type !== "oauth") return true; - if (isRemoteWorker()) return isOpenAiHeadlessMethod(method); - return !isOpenAiHeadlessMethod(method); - }); - return { - id, - name: formatProviderName(id, provider?.name), - methods: entryMethods, - connected: connected.has(id), - env: Array.isArray(provider?.env) ? provider.env : [], - }; - }) - .filter((entry) => entry.methods.length > 0) - .sort(compareProviders); - }); - - const methodLabel = (method: ProviderAuthMethod) => - method.label || (method.type === "oauth" ? "OAuth" : "API key"); - - const actionDisabled = () => props.loading || props.submitting; - - const [view, setView] = createSignal<"list" | "method" | "api" | "cloud" | "oauth-code" | "oauth-auto">("list"); - const [selectedProviderId, setSelectedProviderId] = createSignal(null); - const [selectedCloudMethod, setSelectedCloudMethod] = createSignal(null); - const [apiKeyInput, setApiKeyInput] = createSignal(""); - const [oauthCodeInput, setOauthCodeInput] = createSignal(""); - const [oauthSession, setOauthSession] = createSignal(null); - const [searchQuery, setSearchQuery] = createSignal(""); - const [activeEntryIndex, setActiveEntryIndex] = createSignal(0); - const [localError, setLocalError] = createSignal(null); - const [pollingBusy, setPollingBusy] = createSignal(false); - const [oauthAutoBusy, setOauthAutoBusy] = createSignal(false); - const [oauthCodeCopied, setOauthCodeCopied] = createSignal(false); - const [oauthBrowserOpened, setOauthBrowserOpened] = createSignal(false); - const [autoOpenedPreferredProviderId, setAutoOpenedPreferredProviderId] = createSignal(null); - let searchInputEl: HTMLInputElement | undefined; - let providerPoll: number | null = null; - let oauthAutoPoll: number | null = null; - let oauthCodeCopiedReset: number | null = null; - - const selectedEntry = createMemo(() => - entries().find((entry) => entry.id === selectedProviderId()) ?? null, - ); - - const resolvedView = createMemo(() => (selectedEntry() ? view() : "list")); - const errorMessage = createMemo(() => localError() ?? props.error); - - const filteredEntries = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - if (!query) return entries(); - return entries().filter((entry) => { - const methodText = entry.methods.map((method) => methodLabel(method)).join(" "); - return `${entry.name} ${entry.id} ${methodText}`.toLowerCase().includes(query); - }); - }); - - const oauthInstructions = createMemo(() => oauthSession()?.authorization.instructions?.trim() ?? ""); - const isOpenAiHeadlessSession = createMemo(() => { - const session = oauthSession(); - if (!session) return false; - return session.providerId === "openai" && session.methodLabel.toLowerCase().includes("headless"); - }); - const shouldStartOauthAutoPolling = createMemo(() => { - if (!props.open || resolvedView() !== "oauth-auto" || !oauthSession()) { - return false; - } - if (!isOpenAiHeadlessSession()) return true; - return oauthBrowserOpened(); - }); - - const oauthDisplayCode = createMemo(() => { - const instructions = oauthInstructions(); - if (!instructions) return ""; - const matched = instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0]; - if (matched) return matched; - if (instructions.includes(":")) { - return instructions.split(":").slice(1).join(":").trim(); - } - return instructions; - }); - - const resetState = () => { - if (oauthCodeCopiedReset !== null && typeof window !== "undefined") { - window.clearTimeout(oauthCodeCopiedReset); - oauthCodeCopiedReset = null; - } - setView("list"); - setSelectedProviderId(null); - setSelectedCloudMethod(null); - setApiKeyInput(""); - setOauthCodeInput(""); - setOauthSession(null); - setSearchQuery(""); - setActiveEntryIndex(0); - setLocalError(null); - setOauthCodeCopied(false); - setOauthBrowserOpened(false); - }; - - createEffect(() => { - if (!props.open) { - setAutoOpenedPreferredProviderId(null); - resetState(); - } - }); - - createEffect(() => { - if (!props.open || resolvedView() !== "list") return; - const total = filteredEntries().length; - if (total <= 0) { - setActiveEntryIndex(0); - return; - } - setActiveEntryIndex((current) => Math.max(0, Math.min(current, total - 1))); - }); - - createEffect(() => { - if (!props.open || resolvedView() !== "list") return; - queueMicrotask(() => { - searchInputEl?.focus(); - }); - }); - - createEffect(() => { - if (!props.open || props.loading || resolvedView() !== "list") return; - - const preferredId = props.preferredProviderId?.trim().toLowerCase() ?? ""; - if (!preferredId || autoOpenedPreferredProviderId() === preferredId) return; - - const entry = entries().find((item) => item.id.trim().toLowerCase() === preferredId); - if (!entry) return; - - setAutoOpenedPreferredProviderId(preferredId); - queueMicrotask(() => { - handleEntrySelect(entry); - }); - }); - - const handleClose = () => { - void props.onRefreshProviders?.(); - if (oauthAutoPoll !== null) { - window.clearInterval(oauthAutoPoll); - oauthAutoPoll = null; - } - if (providerPoll !== null) { - window.clearInterval(providerPoll); - providerPoll = null; - } - resetState(); - props.onClose(); - }; - - onCleanup(() => { - if (oauthAutoPoll !== null) { - window.clearInterval(oauthAutoPoll); - oauthAutoPoll = null; - } - if (providerPoll !== null) { - window.clearInterval(providerPoll); - providerPoll = null; - } - if (oauthCodeCopiedReset !== null) { - window.clearTimeout(oauthCodeCopiedReset); - oauthCodeCopiedReset = null; - } - }); - - const isOauthView = () => resolvedView() === "oauth-code" || resolvedView() === "oauth-auto"; - const activeProviderId = () => oauthSession()?.providerId ?? selectedProviderId(); - - const isActiveProviderConnected = () => { - const id = activeProviderId(); - if (!id) return false; - return (props.connectedProviderIds ?? []).includes(id); - }; - - const pollProviders = async () => { - const id = activeProviderId(); - if (!id) return; - if (pollingBusy()) return; - setPollingBusy(true); - try { - await props.onRefreshProviders?.(); - } finally { - setPollingBusy(false); - } - if (isActiveProviderConnected()) { - handleClose(); - } - }; - - const startProviderPolling = () => { - if (typeof window === "undefined") return; - if (providerPoll !== null) return; - void pollProviders(); - providerPoll = window.setInterval(() => { - void pollProviders(); - }, 2000); - }; - - const stopProviderPolling = () => { - if (providerPoll !== null) { - window.clearInterval(providerPoll); - providerPoll = null; - } - }; - - createEffect(() => { - if (!props.open || !isOauthView()) { - stopProviderPolling(); - return; - } - if (isActiveProviderConnected()) { - handleClose(); - return; - } - startProviderPolling(); - }); - - createEffect(() => { - if (!shouldStartOauthAutoPolling()) { - stopOauthAutoPolling(); - return; - } - startOauthAutoPolling(); - }); - - const openOauthUrl = async (url: string) => { - if (!url) return; - if (isTauriRuntime()) { - const { openUrl } = await import("@tauri-apps/plugin-opener"); - await openUrl(url); - setOauthBrowserOpened(true); - return; - } - window.open(url, "_blank", "noopener,noreferrer"); - setOauthBrowserOpened(true); - }; - - const copyOauthDisplayCode = async () => { - const code = oauthDisplayCode().trim(); - if (!code) return; - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - setLocalError("Clipboard is unavailable in this environment."); - return; - } - await navigator.clipboard.writeText(code); - setOauthCodeCopied(true); - if (typeof window === "undefined") return; - if (oauthCodeCopiedReset !== null) { - window.clearTimeout(oauthCodeCopiedReset); - } - oauthCodeCopiedReset = window.setTimeout(() => { - setOauthCodeCopied(false); - oauthCodeCopiedReset = null; - }, 2000); - }; - - const submitOauth = async (providerId: string, methodIndex: number, code?: string) => { - const trimmedCode = code?.trim(); - setLocalError(null); - try { - return await props.onSubmitOAuth(providerId, methodIndex, trimmedCode || undefined); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to complete OAuth"; - setLocalError(message); - throw error instanceof Error ? error : new Error(message); - } - }; - - const stopOauthAutoPolling = () => { - if (oauthAutoPoll !== null) { - window.clearInterval(oauthAutoPoll); - oauthAutoPoll = null; - } - }; - - const attemptOauthAutoCompletion = async () => { - const session = oauthSession(); - if (!session || oauthAutoBusy()) return; - setOauthAutoBusy(true); - try { - const result = await submitOauth(session.providerId, session.methodIndex); - if (result?.connected) { - stopOauthAutoPolling(); - } - } finally { - setOauthAutoBusy(false); - } - }; - - const startOauthAutoPolling = () => { - if (typeof window === "undefined") return; - if (oauthAutoPoll !== null) return; - void attemptOauthAutoCompletion(); - oauthAutoPoll = window.setInterval(() => { - void attemptOauthAutoCompletion(); - }, 2000); - }; - - const startOauth = async (entry: ProviderAuthEntry, methodIndex?: number) => { - if (actionDisabled()) return; - if (!Number.isInteger(methodIndex) || methodIndex === undefined) { - setLocalError(`No OAuth flow available for ${entry.name}.`); - return; - } - setLocalError(null); - setOauthCodeInput(""); - setOauthSession(null); - setOauthCodeCopied(false); - setOauthBrowserOpened(false); - try { - const started = await props.onSelect(entry.id, methodIndex); - const selectedMethod = entry.methods.find((method) => method.methodIndex === methodIndex); - if (!selectedMethod) { - throw new Error(`Selected auth method is unavailable for ${entry.name}.`); - } - const nextSession: ProviderOAuthSession = { - providerId: entry.id, - methodIndex: started.methodIndex, - methodLabel: selectedMethod.label, - authorization: started.authorization, - }; - setOauthSession(nextSession); - - if (started.authorization.method === "code") { - await openOauthUrl(started.authorization.url); - setView("oauth-code"); - return; - } - - if (!isOpenAiHeadlessMethod(selectedMethod)) { - await openOauthUrl(started.authorization.url); - } - - setView("oauth-auto"); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to start OAuth"; - setLocalError(message); - } - }; - - const handleEntrySelect = (entry: ProviderAuthEntry) => { - if (actionDisabled()) return; - setLocalError(null); - setSelectedProviderId(entry.id); - - if (entry.methods.length === 1) { - void handleMethodSelect(entry.methods[0]); - return; - } - - if (entry.methods.length > 1) { - setView("method"); - return; - } - - setLocalError(`No authentication methods available for ${entry.name}.`); - }; - - const handleMethodSelect = async (method: ProviderAuthMethod) => { - const entry = selectedEntry(); - if (!entry || actionDisabled()) return; - setLocalError(null); - setSelectedCloudMethod(null); - - if (method.type === "oauth") { - await startOauth(entry, method.methodIndex); - return; - } - - if (method.type === "cloud") { - setSelectedCloudMethod(method); - setView("cloud"); - return; - } - - setView("api"); - }; - - const handleApiSubmit = async () => { - const entry = selectedEntry(); - if (!entry || actionDisabled()) return; - - const trimmed = apiKeyInput().trim(); - if (!trimmed) { - setLocalError("API key is required."); - return; - } - - setLocalError(null); - try { - await props.onSubmitApiKey(entry.id, trimmed); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to save API key"; - setLocalError(message); - } - }; - - const handleCloudSubmit = async () => { - const method = selectedCloudMethod(); - if (!method?.cloudProviderId || actionDisabled()) return; - - setLocalError(null); - try { - await props.onConnectCloudProvider(method.cloudProviderId); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to connect organization provider"; - setLocalError(message); - } - }; - - const handleOauthCodeSubmit = async () => { - const entry = selectedEntry(); - const session = oauthSession(); - if (!entry || !session || actionDisabled()) return; - - const trimmed = oauthCodeInput().trim(); - if (!trimmed) { - setLocalError("Authorization code is required."); - return; - } - - await submitOauth(entry.id, session.methodIndex, trimmed); - }; - - const handleBack = () => { - if (resolvedView() === "oauth-code" || resolvedView() === "oauth-auto") { - if ((selectedEntry()?.methods.length ?? 0) > 1) { - setView("method"); - } else { - setView("list"); - } - setOauthSession(null); - setOauthCodeInput(""); - setOauthCodeCopied(false); - setOauthBrowserOpened(false); - setLocalError(null); - return; - } - - if (resolvedView() === "api" && (selectedEntry()?.methods.length ?? 0) > 1) { - setView("method"); - setSelectedCloudMethod(null); - setApiKeyInput(""); - setLocalError(null); - return; - } - if (resolvedView() === "cloud" && (selectedEntry()?.methods.length ?? 0) > 1) { - setView("method"); - setSelectedCloudMethod(null); - setLocalError(null); - return; - } - resetState(); - }; - - const submittingLabel = () => { - if (!props.submitting) return null; - if (resolvedView() === "api") return "Saving API key..."; - if (resolvedView() === "cloud") return "Connecting organization provider..."; - if (resolvedView() === "oauth-code") return "Verifying authorization code..."; - if (resolvedView() === "oauth-auto") return "Waiting for OAuth confirmation..."; - return "Opening authentication..."; - }; - - const stepEntryIndex = (delta: number) => { - const total = filteredEntries().length; - if (total <= 0) { - setActiveEntryIndex(0); - return; - } - setActiveEntryIndex((current) => { - const normalized = ((current % total) + total) % total; - return (normalized + delta + total) % total; - }); - }; - - const handleListKeyDown = (event: KeyboardEvent) => { - if (resolvedView() !== "list") return; - if (event.key === "ArrowDown") { - event.preventDefault(); - stepEntryIndex(1); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - stepEntryIndex(-1); - return; - } - if (event.key === "Enter") { - if (event.isComposing || (event as KeyboardEvent & { keyCode?: number }).keyCode === 229) return; - const entry = filteredEntries()[activeEntryIndex()]; - if (!entry) return; - event.preventDefault(); - handleEntrySelect(entry); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - handleClose(); - } - }; - - const methodDescription = (entry: ProviderAuthEntry, method: ProviderAuthMethod) => { - const label = methodLabel(method).toLowerCase(); - if (isOpenAiProvider(entry.id, entry.name) && (label.includes("headless") || label.includes("device"))) { - return isRemoteWorker() - ? "Use OpenAI's device flow for remote workers, where the browser callback may not resolve on your local machine." - : "Use OpenAI's device flow when the local browser callback is unreliable."; - } - if (method.type === "oauth") { - return "Continue in the browser and let OpenWork finish the connection automatically."; - } - if (method.type === "cloud") { - return method.description ?? "Use the provider and credential managed by your organization."; - } - return "Paste a secret key that OpenWork stores locally on this device."; - }; - - return ( - -
-
-
-
-

Connect providers

-

Sign in to services or use providers managed by your organization.

-
- -
- -
-
- -
- Loading providers... -
-
- } - > -
- {errorMessage()} -
- -
- - -
-

{restrictionMessage()}

-
- -
-
-
- - -
- -
-
- - { - setSearchQuery(event.currentTarget.value); - setActiveEntryIndex(0); - }} - autocomplete="off" - autocapitalize="off" - spellcheck={false} - disabled={actionDisabled()} - class="w-full rounded-xl bg-gray-2 px-9 py-2.5 text-[13px] text-gray-12 placeholder:text-gray-9 border border-gray-6/60 focus:border-gray-8 focus:bg-gray-1 focus:outline-none transition-colors shadow-sm" - /> -
- - - {entries().length ? "No providers match your search." : "No providers available."} -
- } - > - - {(entry, index) => { - const idx = () => index(); - return ( -
- - ); - }} - -
- -
Arrow keys to navigate, Enter to select.
-
- - - -
-
-
-
{selectedEntry()!.name}
-
Choose how you'd like to connect.
-
- -
-
- - {(method) => ( - - )} - -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Paste your API key to connect.
-
- -
- { - setApiKeyInput(event.currentTarget.value); - if (localError()) setLocalError(null); - }} - autocomplete="off" - autocapitalize="off" - spellcheck={false} - disabled={actionDisabled()} - /> - 0}> -
- Env vars: {selectedEntry()!.env.join(", ")} -
-
-
-
- Keys are stored locally by OpenCode. -
- -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Connect with the provider managed by your organization.
-
- -
-
- {selectedCloudMethod()!.description ?? "Use the provider and credential managed by your organization."} -
- 0}> -
- {(selectedCloudMethod()!.modelCount ?? 0)} curated model{(selectedCloudMethod()!.modelCount ?? 0) === 1 ? "" : "s"} will be added to this workspace. -
-
- 0}> -
- Env vars: {selectedCloudMethod()!.env!.join(", ")} -
-
-
-
- OpenWork will install the provider config and use the credential stored for your org. -
- -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Finish OAuth by pasting the authorization code.
-
- -
-
- Complete sign-in in your browser, then paste the code here. -
- -
- {oauthInstructions()} -
-
- { - setOauthCodeInput(event.currentTarget.value); - if (localError()) setLocalError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - void handleOauthCodeSubmit(); - }} - autocomplete="off" - autocapitalize="off" - spellcheck={false} - disabled={actionDisabled()} - /> -
- - -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Waiting for browser confirmation.
-
- -
- Sign in in the browser tab we just opened. We will complete the connection automatically.
- } - > -
-
You'll need to sign in to your OpenAI account and provide the code below.
-
- The first time you do this you'll need to enable Device auth in your account settings. -
-
ChatGPT > Account Settings > Security > Enable device code authorization
-
When you're ready, copy the code below, and click "Open Browser".
-
-
- -
-
-
Confirmation code
-
{oauthDisplayCode()}
-
- -
-
- - - Checking connection status automatically... -
- } - > -
- Authorization checks will start after you click Open Browser. -
- -
- -
This window will close once the provider is connected.
-
-
-
-
- -
- -
-
- {submittingLabel()} -
- -
-
- -
- ); -} diff --git a/apps/app/src/app/context/sandbox-create-mode.ts b/apps/app/src/app/context/sandbox-create-mode.ts deleted file mode 100644 index b9bb703e..00000000 --- a/apps/app/src/app/context/sandbox-create-mode.ts +++ /dev/null @@ -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...", - }; -} diff --git a/apps/app/src/app/context/server.tsx b/apps/app/src/app/context/server.tsx deleted file mode 100644 index 6fb2edee..00000000 --- a/apps/app/src/app/context/server.tsx +++ /dev/null @@ -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(undefined); - -export function ServerProvider(props: ParentProps & { defaultUrl: string }) { - const [list, setList] = createSignal([]); - const [active, setActiveRaw] = createSignal(""); - const [healthy, setHealthy] = createSignal(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 {props.children}; -} - -export function useServer() { - const context = useContext(ServerContext); - if (!context) { - throw new Error("Server context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/session.ts b/apps/app/src/app/context/session.ts deleted file mode 100644 index 7e7ad83c..00000000 --- a/apps/app/src/app/context/session.ts +++ /dev/null @@ -1,2143 +0,0 @@ -import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import { t, currentLocale } from "../../i18n"; -import { createStore, produce, reconcile } from "solid-js/store"; - -import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; - -import type { - Client, - MessageInfo, - MessageWithParts, - ModelRef, - OpencodeEvent, - PendingPermission, - PendingQuestion, - PlaceholderAssistantMessage, - PlaceholderMessageInfo, - ReloadReason, - ReloadTrigger, - SessionCompactionState, - SessionErrorTurn, - TodoItem, -} from "../types"; -import { - addOpencodeCacheHint, - isVisibleTextPart, - modelFromUserMessage, - normalizeDirectoryPath, - normalizeEvent, - normalizeSessionStatus, - safeStringify, -} from "../utils"; -import { unwrap } from "../lib/opencode"; -import { recordDevLog } from "../lib/dev-log"; -import { abortSessionSafe } from "../lib/opencode-session"; -import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log"; -import { describeDirectoryScope, toSessionTransportDirectory } from "../lib/session-scope"; -import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "../types"; - -export type SessionModelState = { - overrides: Record; - resolved: Record; -}; - -export type SessionStore = ReturnType; - -type BlueprintSeedMessage = { role?: "assistant" | "user" | null; text?: string | null }; - -const SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX = "blueprint-seed:"; - -type StoreState = { - sessions: Session[]; - sessionInfoById: Record; - sessionStatus: Record; - sessionErrorTurns: Record; - messages: Record; - parts: Record; - todos: Record; - pendingPermissions: PendingPermission[]; - pendingQuestions: PendingQuestion[]; - events: OpencodeEvent[]; - sessionCompaction: Record; -}; - -const sortById = (list: T[]) => - list.slice().sort((a, b) => a.id.localeCompare(b.id)); - -const sessionActivity = (session: Session) => - session.time?.updated ?? session.time?.created ?? 0; - -const sortSessionsByActivity = (list: Session[]) => - list - .slice() - .sort((a, b) => { - const delta = sessionActivity(b) - sessionActivity(a); - if (delta !== 0) return delta; - return a.id.localeCompare(b.id); - }); - -const SYNTHETIC_CONTINUE_CONTROL_PATTERN = - /^\s*continue if you have next steps,\s*or stop and ask for clarification if you are unsure how to proceed\.?\s*$/i; -const SYNTHETIC_TASK_SUMMARY_CONTROL_PATTERN = - /^\s*summarize the task tool output above and continue with your task\.?\s*$/i; -const COMPACTION_DIAGNOSTIC_WINDOW_MS = 60_000; -const COMPACTION_LOOP_WARN_THRESHOLD = 3; -const COMPACTION_LOOP_WARN_MIN_INTERVAL_MS = 10_000; -const SYNTHETIC_TASK_SUMMARY_LOOP_ABORT_THRESHOLD = 5; -const SYNTHETIC_CONTROL_LOOP_ABORT_MIN_INTERVAL_MS = 30_000; -const INITIAL_SESSION_MESSAGE_LIMIT = 140; -const SESSION_MESSAGE_LOAD_CHUNK = 120; - -const createPlaceholderMessage = (part: Part): PlaceholderAssistantMessage => ({ - id: part.messageID, - sessionID: part.sessionID, - role: "assistant", - time: { created: Date.now() }, - parentID: "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, -}); - -const upsertSession = (list: Session[], next: Session) => { - const index = list.findIndex((session) => session.id === next.id); - if (index === -1) return sortSessionsByActivity([...list, next]); - const copy = list.slice(); - copy[index] = next; - return sortSessionsByActivity(copy); -}; - -const removeSession = (list: Session[], sessionID: string) => list.filter((session) => session.id !== sessionID); - -const upsertMessageInfo = (list: MessageInfo[], next: MessageInfo) => { - const index = list.findIndex((message) => message.id === next.id); - if (index === -1) return sortById([...list, next]); - const copy = list.slice(); - copy[index] = next; - return copy; -}; - -const removeMessageInfo = (list: MessageInfo[], messageID: string) => - list.filter((message) => message.id !== messageID); - -const upsertPartInfo = (list: Part[], next: Part) => { - const index = list.findIndex((part) => part.id === next.id); - if (index === -1) return sortById([...list, next]); - const copy = list.slice(); - const existing = copy[index] as Part & Record; - const incoming = next as Part & Record; - if ((incoming.type === "text" || incoming.type === "reasoning") && typeof existing.text === "string") { - const nextText = typeof incoming.text === "string" ? incoming.text : ""; - copy[index] = { ...existing, ...incoming, text: nextText || existing.text } as Part; - } else { - copy[index] = next; - } - return copy; -}; - -const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID); - -const appendPartDelta = (list: Part[], messageID: string, sessionID: string | null, partID: string, field: string, delta: string) => { - if (!delta) return list; - const index = list.findIndex((part) => part.id === partID); - if (index === -1) { - if (field !== "text" && field !== "reasoning") return list; - const synthetic = { - id: partID, - messageID, - sessionID: sessionID ?? "", - type: field === "reasoning" ? "reasoning" : "text", - text: delta, - } as Part; - return sortById([...list, synthetic]); - } - - const existing = list[index] as Part & Record; - const current = existing[field]; - if (current !== undefined && typeof current !== "string") { - return list; - } - - const nextValue = `${typeof current === "string" ? current : ""}${delta}`; - if (nextValue === current) return list; - - const copy = list.slice(); - copy[index] = { ...existing, [field]: nextValue } as Part; - return copy; -}; - -export function createSessionStore(options: { - client: () => Client | null; - selectedWorkspaceRoot: () => string; - selectedSessionId: () => string | null; - setSelectedSessionId: (id: string | null) => void; - setPrompt: (value: string) => void; - sessionModelState: () => SessionModelState; - setSessionModelState: (updater: (current: SessionModelState) => SessionModelState) => SessionModelState; - lastUserModelFromMessages: (messages: MessageWithParts[]) => ModelRef | null; - developerMode: () => boolean; - setError: (message: string | null) => void; - setSseConnected: (connected: boolean) => void; - markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void; - onHotReloadApplied?: () => void; -}) { - - const sessionDebugEnabled = () => options.developerMode(); - - const sessionDebug = (label: string, payload?: unknown) => { - if (!sessionDebugEnabled()) return; - try { - recordDevLog(true, { level: "debug", source: "session", label, payload }); - if (payload === undefined) { - console.log(`[WSDBG] ${label}`); - } else { - console.log(`[WSDBG] ${label}`, payload); - } - } catch { - // ignore - } - }; - - const sessionWarn = (label: string, payload?: unknown) => { - if (!sessionDebugEnabled()) return; - try { - recordDevLog(true, { level: "warn", source: "session", label, payload }); - if (payload === undefined) { - console.warn(`[WSWARN] ${label}`); - } else { - console.warn(`[WSWARN] ${label}`, payload); - } - } catch { - // ignore - } - }; - const MAX_RELOAD_DETECTION_KEYS = 5000; - - const [store, setStore] = createStore({ - sessions: [], - sessionInfoById: {}, - sessionStatus: {}, - sessionErrorTurns: {}, - messages: {}, - parts: {}, - todos: {}, - pendingPermissions: [], - pendingQuestions: [], - events: [], - sessionCompaction: {}, - }); - const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false); - const [blueprintSeedMessagesBySessionId, setBlueprintSeedMessagesBySessionId] = createSignal< - Record - >({}); - const [messageLimitBySession, setMessageLimitBySession] = createSignal>({}); - const [messageCompleteBySession, setMessageCompleteBySession] = createSignal>({}); - const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal>({}); - const [loadedScopeRoot, setLoadedScopeRoot] = createSignal(""); - const reloadDetectionSet = new Set(); - const invalidToolDetectionSet = new Set(); - const pendingCompactionModeBySession = new Map(); - const syntheticContinueEventTimesBySession = new Map(); - const syntheticTaskSummaryEventTimesBySession = new Map(); - const syntheticContinueLoopLastWarnAtBySession = new Map(); - const syntheticLoopLastAbortAtByKey = new Map(); - - const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i; - const skillNamePattern = /[\\/]\.opencode[\\/](?:skill|skills)[\\/]+([^\\/]+)/i; - const commandPathPattern = /[\\/]\.opencode[\\/](command|commands)[\\/]/i; - const commandNamePattern = /[\\/]\.opencode[\\/](?:command|commands)[\\/]+([^\\/]+)/i; - const agentPathPattern = /[\\/]\.opencode[\\/](agent|agents)[\\/]/i; - const agentNamePattern = /[\\/]\.opencode[\\/](?:agent|agents)[\\/]+([^\\/]+)/i; - const opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i; - const opencodePathPattern = /(?:^|[\\/])\.opencode[\\/]/i; - const openworkConfigPattern = /[\\/]\.opencode[\\/]openwork\.json\b/i; - const mutatingTools = new Set(["write", "edit", "apply_patch"]); - - const extractSearchText = (value: unknown) => { - if (!value) return ""; - if (typeof value === "string") return value; - if (typeof value === "number") return String(value); - return safeStringify(value); - }; - - const detectReloadReason = (value: unknown): ReloadReason | null => { - const text = extractSearchText(value); - if (!text) return null; - if (openworkConfigPattern.test(text)) return null; - if (skillPathPattern.test(text)) return "skills"; - if (commandPathPattern.test(text)) return "commands"; - if (agentPathPattern.test(text)) return "agents"; - if (opencodeConfigPattern.test(text)) return "config"; - if (opencodePathPattern.test(text)) return "config"; - return null; - }; - - const detectReloadTriggerFromText = (text: string): ReloadTrigger | null => { - if (openworkConfigPattern.test(text)) { - return null; - } - if (skillPathPattern.test(text)) { - const match = text.match(skillNamePattern); - return { - type: "skill", - name: match?.[1], - action: "updated", - path: match?.[0], - }; - } - - if (commandPathPattern.test(text)) { - const match = text.match(commandNamePattern); - const raw = match?.[1]; - const name = raw ? raw.replace(/\.md$/i, "") : undefined; - return { - type: "command", - name, - action: "updated", - path: match?.[0], - }; - } - - if (agentPathPattern.test(text)) { - const match = text.match(agentNamePattern); - return { - type: "agent", - name: match?.[1], - action: "updated", - path: match?.[0], - }; - } - - if (opencodeConfigPattern.test(text) || opencodePathPattern.test(text)) { - return { - type: "config", - action: "updated", - }; - } - return null; - }; - - const detectReloadReasonDeep = (value: unknown): ReloadReason | null => { - if (!value) return null; - if (typeof value === "string" || typeof value === "number") { - return detectReloadReason(value); - } - if (Array.isArray(value)) { - for (const entry of value) { - const reason = detectReloadReasonDeep(entry); - if (reason) return reason; - } - return null; - } - if (typeof value === "object") { - for (const entry of Object.values(value as Record)) { - const reason = detectReloadReasonDeep(entry); - if (reason) return reason; - } - } - return null; - }; - - const detectReloadTriggerDeep = (value: unknown): ReloadTrigger | null => { - if (!value) return null; - if (typeof value === "string" || typeof value === "number") { - return detectReloadTriggerFromText(String(value)); - } - if (Array.isArray(value)) { - for (const entry of value) { - const trigger = detectReloadTriggerDeep(entry); - if (trigger) return trigger; - } - return null; - } - if (typeof value === "object") { - for (const entry of Object.values(value as Record)) { - const trigger = detectReloadTriggerDeep(entry); - if (trigger) return trigger; - } - } - return null; - }; - - const detectReloadFromPart = (part: Part): { reason: ReloadReason; trigger?: ReloadTrigger } | null => { - if (part.type !== "tool") return null; - const record = part as Record; - const toolName = typeof record.tool === "string" ? record.tool : ""; - if (!mutatingTools.has(toolName)) return null; - const state = (record.state ?? {}) as Record; - const reason = - detectReloadReasonDeep(state.input) || - detectReloadReasonDeep(state.patch) || - detectReloadReasonDeep(state.diff); - if (!reason) return null; - const trigger = - detectReloadTriggerDeep(state.input) || - detectReloadTriggerDeep(state.patch) || - detectReloadTriggerDeep(state.diff); - return { reason, trigger: trigger ?? undefined }; - }; - - const maybeMarkReloadRequired = (part: Part) => { - if (!options.markReloadRequired) return; - if (!part?.id || !part.messageID) return; - - const root = normalizeDirectoryPath(options.selectedWorkspaceRoot()); - if (root) { - const session = store.sessions.find((candidate) => candidate.id === part.sessionID) ?? null; - const sessionRoot = normalizeDirectoryPath(session?.directory ?? ""); - if (!sessionRoot || sessionRoot !== root) { - return; - } - } - - const key = `${part.messageID}:${part.id}`; - if (reloadDetectionSet.has(key)) return; - const detection = detectReloadFromPart(part); - if (!detection) return; - reloadDetectionSet.add(key); - options.markReloadRequired(detection.reason, detection.trigger); - }; - - const toolErrorText = (part: Part) => { - if (part.type !== "tool") return ""; - const record = part as any; - const state = (record.state ?? {}) as Record; - const title = typeof state.title === "string" ? state.title : ""; - const error = typeof state.error === "string" ? state.error : ""; - const detail = typeof state.detail === "string" ? state.detail : ""; - return [title, error, detail].filter(Boolean).join("\n"); - }; - - const isInvalidToolError = (part: Part) => { - if (part.type !== "tool") return false; - const haystack = toolErrorText(part).toLowerCase(); - if (!haystack) return false; - return ( - haystack.includes("invalid tool") || - haystack.includes("model tried to call") || - haystack.includes("unavailable tool") || - haystack.includes("unknown tool") || - haystack.includes("tool not found") - ); - }; - - const invalidToolNextStepHint = (part: Part) => { - const record = part as any; - const name = typeof record.tool === "string" ? record.tool : ""; - const lower = name.toLowerCase(); - if (lower.includes("browser") || lower.includes("chrome") || lower.includes("devtools")) { - return "Chrome MCP is not ready yet. Open the MCP tab, connect `Control Chrome`, then retry."; - } - return "Try again, or switch to an agent/prompt that only uses available tools in this worker."; - }; - - const maybeHandleInvalidToolError = (part: Part) => { - if (!options.setError) return; - if (!isInvalidToolError(part)) return; - if (!part?.id || !part.messageID) return; - - const key = `${part.messageID}:${part.id}`; - if (invalidToolDetectionSet.has(key)) return; - invalidToolDetectionSet.add(key); - - // Ensure the UI doesn't get stuck in a "Responding" state when the model - // tries to call a tool that isn't available. - if (part.sessionID) { - setStore("sessionStatus", part.sessionID, "idle"); - } - - const record = part as any; - const tool = typeof record.tool === "string" && record.tool.trim() ? record.tool.trim() : "(unknown tool)"; - const hint = invalidToolNextStepHint(part); - options.setError(`Invalid tool call: ${tool}.\n\n${hint}`); - }; - - const isSyntheticContinueControlPart = (part: Part) => { - if (part.type !== "text") return false; - const record = part as Part & { text?: unknown; synthetic?: unknown; ignored?: unknown }; - if (record.synthetic !== true) return false; - if (record.ignored === true) return false; - const text = typeof record.text === "string" ? record.text.trim() : ""; - if (!text) return false; - return SYNTHETIC_CONTINUE_CONTROL_PATTERN.test(text); - }; - - const isSyntheticTaskSummaryControlPart = (part: Part) => { - if (part.type !== "text") return false; - const record = part as Part & { text?: unknown; synthetic?: unknown; ignored?: unknown }; - if (record.synthetic !== true) return false; - if (record.ignored === true) return false; - const text = typeof record.text === "string" ? record.text.trim() : ""; - if (!text) return false; - return SYNTHETIC_TASK_SUMMARY_CONTROL_PATTERN.test(text); - }; - - const recordSyntheticContinueDiagnostic = (part: Part) => { - if (!isSyntheticContinueControlPart(part)) return; - const sessionID = part.sessionID; - const now = Date.now(); - const windowStart = now - COMPACTION_DIAGNOSTIC_WINDOW_MS; - const previous = syntheticContinueEventTimesBySession.get(sessionID) ?? []; - const next = previous.filter((timestamp) => timestamp >= windowStart); - next.push(now); - syntheticContinueEventTimesBySession.set(sessionID, next); - - const countInWindow = next.length; - recordPerfLog(sessionDebugEnabled(), "session.compaction", "synthetic-continue", { - sessionID, - messageID: part.messageID, - partID: part.id, - countPerMinute: countInWindow, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - - if (countInWindow < COMPACTION_LOOP_WARN_THRESHOLD) return; - - const lastWarnAt = syntheticContinueLoopLastWarnAtBySession.get(sessionID) ?? 0; - if (now - lastWarnAt < COMPACTION_LOOP_WARN_MIN_INTERVAL_MS) return; - syntheticContinueLoopLastWarnAtBySession.set(sessionID, now); - sessionWarn("compaction:synthetic-continue-loop", { - sessionID, - countPerMinute: countInWindow, - }); - recordPerfLog(sessionDebugEnabled(), "session.compaction", "synthetic-continue-loop-suspected", { - sessionID, - countPerMinute: countInWindow, - threshold: COMPACTION_LOOP_WARN_THRESHOLD, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - }; - - const recordSyntheticTaskSummaryDiagnostic = (part: Part) => { - if (!isSyntheticTaskSummaryControlPart(part)) return; - const sessionID = part.sessionID; - const now = Date.now(); - const windowStart = now - COMPACTION_DIAGNOSTIC_WINDOW_MS; - const previous = syntheticTaskSummaryEventTimesBySession.get(sessionID) ?? []; - const next = previous.filter((timestamp) => timestamp >= windowStart); - next.push(now); - syntheticTaskSummaryEventTimesBySession.set(sessionID, next); - - recordPerfLog(sessionDebugEnabled(), "session.task", "synthetic-task-summary-control", { - sessionID, - messageID: part.messageID, - partID: part.id, - countPerMinute: next.length, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - }; - - const addError = (error: unknown, fallback = t("app.unknown_error", currentLocale())) => { - const message = error instanceof Error ? error.message : fallback; - if (!message) return; - options.setError(addOpencodeCacheHint(message)); - }; - - const appendSessionErrorTurn = (sessionID: string, message: string | null) => { - const text = message?.trim() ?? ""; - if (!sessionID || !text) return; - - const list = store.messages[sessionID] ?? []; - const lastMessage = list.length > 0 ? list[list.length - 1] : null; - const afterMessageID = lastMessage?.id ?? null; - - setStore("sessionErrorTurns", sessionID, (current) => { - const existing = current ?? []; - const previous = existing[existing.length - 1]; - if (previous && previous.text === text && previous.afterMessageID === afterMessageID) { - return existing; - } - - return existing.concat({ - id: `${SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX}${sessionID}:${Date.now()}:${existing.length}`, - text, - afterMessageID, - time: Date.now(), - }); - }); - }; - - const maybeAbortSyntheticControlLoop = (part: Part) => { - const sessionID = part.sessionID; - if (!sessionID) return; - - const kind = isSyntheticTaskSummaryControlPart(part) - ? "task-summary" - : isSyntheticContinueControlPart(part) - ? "compaction-continue" - : null; - if (!kind) return; - - const events = - kind === "task-summary" - ? syntheticTaskSummaryEventTimesBySession.get(sessionID) ?? [] - : syntheticContinueEventTimesBySession.get(sessionID) ?? []; - const threshold = - kind === "task-summary" - ? SYNTHETIC_TASK_SUMMARY_LOOP_ABORT_THRESHOLD - : COMPACTION_LOOP_WARN_THRESHOLD; - if (events.length < threshold) return; - - const key = `${kind}:${sessionID}`; - const now = Date.now(); - const lastAbortAt = syntheticLoopLastAbortAtByKey.get(key) ?? 0; - if (now - lastAbortAt < SYNTHETIC_CONTROL_LOOP_ABORT_MIN_INTERVAL_MS) return; - syntheticLoopLastAbortAtByKey.set(key, now); - - const message = - kind === "task-summary" - ? "OpenWork stopped this run after detecting a likely synthetic task-summary loop. The engine kept asking itself to summarize task output and continue, which can repeat Goal/Instructions/Discoveries summaries without making progress." - : "OpenWork stopped this run after detecting a likely auto-compaction continuation loop. The engine kept injecting synthetic continue prompts after compaction, which can burn tokens without advancing the task."; - - sessionWarn("session.synthetic-loop.abort", { - sessionID, - kind, - countPerMinute: events.length, - }); - recordPerfLog(sessionDebugEnabled(), "session.loop", "abort-suspected-synthetic-loop", { - sessionID, - kind, - countPerMinute: events.length, - threshold, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - - const c = options.client(); - if (!c) { - appendSessionErrorTurn(sessionID, message); - options.setError(message); - setStore("sessionStatus", sessionID, "idle"); - return; - } - - void abortSessionSafe(c, sessionID).finally(() => { - appendSessionErrorTurn(sessionID, message); - options.setError(message); - setStore("sessionStatus", sessionID, "idle"); - }); - }; - - const truncateErrorField = (value: unknown, max = 500) => { - if (typeof value !== "string") return null; - const text = value.trim(); - if (!text) return null; - if (text.length <= max) return text; - return `${text.slice(0, Math.max(0, max - 3))}...`; - }; - - const inferHttpStatus = (value: string | null) => { - if (!value) return null; - const match = value.match(/\b(?:status|code|http)\s*(?:=|:)?\s*(401|403|413|429)\b/i) || - value.match(/\b(401|403|413|429)\b/); - if (!match) return null; - const parsed = Number.parseInt(match[1], 10); - if (!Number.isFinite(parsed)) return null; - return parsed; - }; - - const getNestedRecords = (source: Record) => { - const records: Record[] = [source]; - const data = source.data; - if (data && typeof data === "object") records.push(data as Record); - const cause = source.cause; - if (cause && typeof cause === "object") { - const causeRecord = cause as Record; - records.push(causeRecord); - const causeData = causeRecord.data; - if (causeData && typeof causeData === "object") records.push(causeData as Record); - } - return records; - }; - - const firstStringField = (records: Record[], keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = truncateErrorField(record[key], 800); - if (value) return value; - } - } - return null; - }; - - const firstNumberField = (records: Record[], keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = record[key]; - if (typeof value !== "number" || !Number.isFinite(value)) continue; - return value; - } - } - return null; - }; - - const firstBooleanField = (records: Record[], keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = record[key]; - if (typeof value !== "boolean") continue; - return value; - } - } - return null; - }; - - const formatSessionError = (errorObj: Record) => { - const records = getNestedRecords(errorObj); - const errorName = typeof errorObj.name === "string" ? errorObj.name : "UnknownError"; - const rawMessage = firstStringField(records, ["message", "detail", "reason"]); - const responseBody = firstStringField(records, ["responseBody", "body", "response"]); - const providerID = firstStringField(records, ["providerID", "providerId", "provider"]); - const code = firstStringField(records, ["code", "errorCode"]); - const statusCode = firstNumberField(records, ["statusCode", "status"]); - const inferred = inferHttpStatus(rawMessage) ?? inferHttpStatus(responseBody); - const effectiveStatus = statusCode ?? inferred; - const isRetryable = firstBooleanField(records, ["isRetryable", "retryable"]); - - const heading = (() => { - if (errorName === "ProviderAuthError") return `Provider auth error${providerID ? ` (${providerID})` : ""}`; - if (errorName === "APIError") { - if (effectiveStatus === 401 || effectiveStatus === 403) return t("app.error_auth_failed", currentLocale()); - if (effectiveStatus === 413) return "Context too large"; - if (effectiveStatus === 429) return t("app.error_rate_limit", currentLocale()); - return `API error${effectiveStatus ? ` (${effectiveStatus})` : ""}`; - } - if (effectiveStatus === 401 || effectiveStatus === 403) return t("app.error_auth_failed", currentLocale()); - if (effectiveStatus === 413) return "Context too large"; - if (effectiveStatus === 429) return t("app.error_rate_limit", currentLocale()); - if (errorName === "MessageOutputLengthError") return "Output length limit exceeded"; - return errorName.replace(/([a-z])([A-Z])/g, "$1 $2"); - })(); - - const lines = [heading]; - if (rawMessage && rawMessage !== heading) lines.push(rawMessage); - if (effectiveStatus === 413) { - lines.push("Tip: Try compacting the session, or start a new session if the issue persists."); - } - if (providerID && errorName !== "ProviderAuthError") lines.push(`Provider: ${providerID}`); - if (effectiveStatus && errorName !== "APIError") lines.push(`Status: ${effectiveStatus}`); - if (code) lines.push(`Code: ${code}`); - if (isRetryable !== null) lines.push(`Retryable: ${isRetryable ? "yes" : "no"}`); - if (responseBody) lines.push(`Response: ${responseBody}`); - return lines.join("\n"); - }; - - const withTimeout = async (promise: Promise, ms: number, label: string) => { - let timeoutId: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), ms); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - }; - - let selectRunCounter = 0; - let selectVersion = 0; - const selectInFlightBySession = new Map>(); - const ensureInFlightBySession = new Map>(); - - const rememberSession = (session: Session) => { - setStore("sessionInfoById", session.id, session); - }; - - const rememberSessions = (list: Session[]) => { - if (!list.length) return; - batch(() => { - list.forEach((session) => { - setStore("sessionInfoById", session.id, session); - }); - }); - }; - - const sessionById = (id: string | null) => { - if (!id) return null; - return store.sessionInfoById[id] ?? store.sessions.find((session) => session.id === id) ?? null; - }; - - const messageIdFromInfo = (message: MessageWithParts) => { - const id = (message.info as { id?: string | number }).id; - if (typeof id === "string") return id; - if (typeof id === "number") return String(id); - return ""; - }; - - const createSyntheticSessionErrorMessage = ( - sessionID: string, - errorTurn: SessionErrorTurn, - ): MessageWithParts => { - const info: PlaceholderAssistantMessage = { - id: errorTurn.id, - sessionID, - role: "assistant", - time: { created: errorTurn.time, completed: errorTurn.time }, - parentID: errorTurn.afterMessageID ?? "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }; - - return { - info, - parts: [ - { - id: `${errorTurn.id}:text`, - sessionID, - messageID: errorTurn.id, - type: "text", - text: errorTurn.text, - } as Part, - ], - }; - }; - - const createSyntheticBlueprintSeedMessage = ( - sessionID: string, - index: number, - seed: BlueprintSeedMessage, - ): MessageWithParts => { - const messageId = `${SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX}${sessionID}:${index}`; - const role = seed.role === "user" ? "user" : "assistant"; - const text = seed.text?.trim() ?? ""; - const createdAt = Math.max(1, index + 1); - const info: PlaceholderMessageInfo = { - id: messageId, - sessionID, - role, - time: { created: createdAt, completed: createdAt }, - parentID: index > 0 ? `${SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX}${sessionID}:${index - 1}` : "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }; - - return { - info, - parts: [ - { - id: `${messageId}:text`, - sessionID, - messageID: messageId, - type: "text", - text, - } as Part, - ], - }; - }; - - const insertSyntheticBlueprintSeedMessages = ( - list: MessageWithParts[], - sessionID: string | null, - seeds: BlueprintSeedMessage[], - ) => { - if (!sessionID || seeds.length === 0) return list; - if (list.length > 0) return list; - const existingIds = new Set(list.map((message) => messageIdFromInfo(message))); - const synthetic = seeds - .map((seed, index) => createSyntheticBlueprintSeedMessage(sessionID, index, seed)) - .filter((message) => !existingIds.has(messageIdFromInfo(message))); - if (!synthetic.length) return list; - return [...synthetic, ...list]; - }; - - const insertSyntheticSessionErrors = ( - list: MessageWithParts[], - sessionID: string | null, - errorTurns: SessionErrorTurn[], - ) => { - if (!sessionID || errorTurns.length === 0) return list; - - const next = list.slice(); - errorTurns.forEach((errorTurn) => { - if (next.some((message) => messageIdFromInfo(message) === errorTurn.id)) return; - const syntheticMessage = createSyntheticSessionErrorMessage(sessionID, errorTurn); - const anchorIndex = errorTurn.afterMessageID - ? next.findIndex((message) => messageIdFromInfo(message) === errorTurn.afterMessageID) - : -1; - - if (anchorIndex === -1) { - next.push(syntheticMessage); - return; - } - - next.splice(anchorIndex + 1, 0, syntheticMessage); - }); - - return next; - }; - - const upsertLocalSession = (next: Session | null | undefined) => { - const id = (next as { id?: string } | null)?.id ?? ""; - if (!id) return; - - const current = sessions(); - const index = current.findIndex((session) => session.id === id); - if (index === -1) { - setStore("sessions", sortSessionsByActivity([...current, next as Session])); - rememberSession(next as Session); - return; - } - - const copy = current.slice(); - copy[index] = next as Session; - rememberSession(next as Session); - setStore("sessions", sortSessionsByActivity(copy)); - }; - - const messagesBySessionId = (id: string | null): MessageWithParts[] => { - if (!id) return []; - const list = store.messages[id] ?? []; - return list.map((info) => ({ info, parts: store.parts[info.id] ?? [] })); - }; - - const sessions = () => store.sessions; - const sessionStatusById = () => store.sessionStatus; - const pendingPermissions = () => store.pendingPermissions; - const pendingQuestions = () => store.pendingQuestions; - const events = () => store.events; - - const selectedSession = createMemo(() => { - return sessionById(options.selectedSessionId()); - }); - - const selectedSessionStatus = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return "idle"; - return store.sessionStatus[id] ?? "idle"; - }); - - const messages = createMemo(() => { - return messagesBySessionId(options.selectedSessionId()); - }); - - const blueprintSeedMessagesForSelectedSession = createMemo(() => { - const sessionID = options.selectedSessionId(); - if (!sessionID) return [] as BlueprintSeedMessage[]; - return blueprintSeedMessagesBySessionId()[sessionID] ?? []; - }); - - const visibleMessages = createMemo(() => { - const sessionID = options.selectedSessionId(); - const errorTurns = sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []; - const blueprintSeeds = blueprintSeedMessagesForSelectedSession(); - const list = messages().filter((message) => { - const id = messageIdFromInfo(message); - return !id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX) && !id.startsWith(SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX); - }); - const revert = selectedSession()?.revert?.messageID ?? null; - const visible = !revert - ? list - : list.filter((message) => { - const id = messageIdFromInfo(message); - return Boolean(id) && id < revert; - }); - return insertSyntheticSessionErrors( - insertSyntheticBlueprintSeedMessages(visible, sessionID, blueprintSeeds), - sessionID, - errorTurns, - ); - }); - - const restorePromptFromUserMessage = (message: MessageWithParts) => { - const text = message.parts - .filter(isVisibleTextPart) - .map((part) => String((part as { text?: string }).text ?? "")) - .join(""); - options.setPrompt(text); - }; - - const todos = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return []; - return store.todos[id] ?? []; - }); - - const selectedSessionHasEarlierMessages = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return false; - return !messageCompleteBySession()[id]; - }); - - const selectedSessionLoadingEarlierMessages = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return false; - return Boolean(messageLoadBusyBySession()[id]); - }); - - async function loadSessions(scopeRoot?: string) { - const c = options.client(); - if (!c) return; - - // IMPORTANT: OpenCode's session.list() supports server-side filtering by directory. - // Use it to avoid fetching every session across every workspace root. - // - // Note: Use the same transport path format we send for create/delete so the - // server-side strict directory equality checks hit the same stored value. - const queryDirectory = toSessionTransportDirectory(scopeRoot) || undefined; - - sessionDebug("sessions:load:request", { - scopeRoot: scopeRoot ?? null, - scopeScope: describeDirectoryScope(scopeRoot), - queryDirectory: queryDirectory ?? null, - queryScope: describeDirectoryScope(queryDirectory), - selectedWorkspaceRoot: options.selectedWorkspaceRoot?.() ?? null, - activeWorkspaceScope: describeDirectoryScope(options.selectedWorkspaceRoot?.() ?? null), - }); - - const start = Date.now(); - sessionDebug("sessions:load:start", { - scopeRoot: scopeRoot ?? null, - scopeScope: describeDirectoryScope(scopeRoot), - queryDirectory: queryDirectory ?? null, - queryScope: describeDirectoryScope(queryDirectory), - }); - const list = unwrap(await c.session.list({ directory: queryDirectory, roots: true })); - sessionDebug("sessions:load:response", { - count: list.length, - sessions: list.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - directoryScope: describeDirectoryScope(session.directory), - parentID: session.parentID, - })), - }); - sessionDebug("sessions:load:raw", { count: list.length, ms: Date.now() - start }); - - // Defensive client-side filter in case the server returns sessions spanning - // multiple roots (e.g. older servers or proxies). - const root = normalizeDirectoryPath(scopeRoot); - const filtered = root - ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) - : list; - sessionDebug("sessions:load:filtered-list", { - root: root || null, - count: filtered.length, - sessions: filtered.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - parentID: session.parentID, - })), - }); - sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length }); - setLoadedScopeRoot(root); - rememberSessions(filtered); - setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" })); - } - - async function renameSession(sessionID: string, title: string) { - const c = options.client(); - if (!c) return; - const trimmed = title.trim(); - if (!trimmed) { - throw new Error(t("app.error_session_name_required", currentLocale())); - } - const next = unwrap(await c.session.update({ sessionID, title: trimmed })); - rememberSession(next); - setStore("sessions", (current) => upsertSession(current, next)); - } - - async function refreshPendingPermissions() { - const c = options.client(); - if (!c) return; - const list = unwrap(await c.permission.list()); - const now = Date.now(); - const byId = new Map(store.pendingPermissions.map((perm) => [perm.id, perm] as const)); - const next = list.map((perm) => ({ ...perm, receivedAt: byId.get(perm.id)?.receivedAt ?? now })); - setStore("pendingPermissions", next); - } - - async function refreshPendingQuestions() { - const c = options.client(); - if (!c) return; - const list = unwrap(await c.question.list()); - const now = Date.now(); - const byId = new Map(store.pendingQuestions.map((q) => [q.id, q] as const)); - const next = list.map((q) => ({ ...q, receivedAt: byId.get(q.id)?.receivedAt ?? now })); - setStore("pendingQuestions", next); - } - - function setMessagesForSession(sessionID: string, list: MessageWithParts[]) { - const infos = list - .map((msg) => msg.info) - .filter((info) => !!info?.id) - .map((info) => info as MessageInfo); - - const isStreaming = (store.sessionStatus[sessionID] ?? "idle") !== "idle"; - - batch(() => { - setStore("messages", sessionID, reconcile(sortById(infos), { key: "id" })); - for (const message of list) { - const parts = message.parts.filter((part) => !!part?.id); - - if (isStreaming) { - // During active streaming, the server snapshot may have empty/stale - // text fields for in-progress parts while the local store already - // accumulated text via message.part.delta events. Merge carefully - // so we never overwrite longer local text with shorter server text. - const existingParts = store.parts[message.info.id] ?? []; - const merged = sortById(parts).map((incoming) => { - const existing = existingParts.find((p) => p.id === incoming.id); - if (!existing) return incoming; - const incomingRecord = incoming as Part & Record; - const existingRecord = existing as Part & Record; - if ( - (incoming.type === "text" || incoming.type === "reasoning") && - typeof existingRecord.text === "string" && - typeof incomingRecord.text === "string" && - existingRecord.text.length > incomingRecord.text.length - ) { - return { ...incoming, text: existingRecord.text } as Part; - } - return incoming; - }); - // Also keep any local-only parts (created from early deltas) that - // the server snapshot doesn't know about yet. - for (const existing of existingParts) { - if (!merged.find((p) => p.id === existing.id)) { - merged.push(existing); - } - } - setStore("parts", message.info.id, reconcile(sortById(merged), { key: "id" })); - } else { - setStore("parts", message.info.id, reconcile(sortById(parts), { key: "id" })); - } - } - }); - } - - async function ensureSessionLoaded(sessionID: string) { - const id = sessionID.trim(); - if (!id) return; - if ((store.messages[id]?.length ?? 0) > 0) return; - if (sessionById(id) && messageLimitBySession()[id] !== undefined) return; - - const existing = ensureInFlightBySession.get(id); - if (existing) return existing; - - const c = options.client(); - if (!c) return; - - const run = (async () => { - setMessageLoadBusyBySession((prev) => ({ ...prev, [id]: true })); - try { - const [info, msgs] = await Promise.all([ - withTimeout(c.session.get({ sessionID: id }), 8000, "session.get"), - withTimeout(c.session.messages({ sessionID: id, limit: INITIAL_SESSION_MESSAGE_LIMIT }), 12000, "session.messages"), - ]); - const nextSession = unwrap(info); - const nextMessages = unwrap(msgs); - rememberSession(nextSession); - setStore("sessions", (current) => upsertSession(current, nextSession)); - setMessagesForSession(id, nextMessages); - setMessageLimitBySession((prev) => ({ ...prev, [id]: INITIAL_SESSION_MESSAGE_LIMIT })); - setMessageCompleteBySession((prev) => ({ ...prev, [id]: nextMessages.length < INITIAL_SESSION_MESSAGE_LIMIT })); - } catch (error) { - sessionWarn("session.ensure.failed", { - sessionID: id, - error: error instanceof Error ? error.message : safeStringify(error), - }); - } finally { - setMessageLoadBusyBySession((prev) => ({ ...prev, [id]: false })); - } - })(); - - ensureInFlightBySession.set(id, run); - try { - await run; - } finally { - if (ensureInFlightBySession.get(id) === run) { - ensureInFlightBySession.delete(id); - } - } - } - - async function selectSession( - sessionID: string, - selectOptions?: { skipHealthCheck?: boolean; source?: string }, - ) { - const c = options.client(); - if (!c) return; - - const perfEnabled = options.developerMode(); - batch(() => { - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: true })); - options.setSelectedSessionId(sessionID); - options.setError(null); - }); - - const existing = selectInFlightBySession.get(sessionID); - if (existing) { - recordPerfLog(perfEnabled, "session.select", "dedupe join", { - sessionID, - }); - return existing; - } - - const runId = ++selectRunCounter; - const version = ++selectVersion; - const startedAt = perfNow(); - const mark = (event: string, payload?: Record) => { - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - recordPerfLog(perfEnabled, "session.select", event, { - runId, - sessionID, - elapsedMs, - ...(payload ?? {}), - }); - }; - const isStale = () => version !== selectVersion || options.selectedSessionId() !== sessionID; - const abortIfStale = (reason: string) => { - if (!isStale()) return false; - mark(`aborting: ${reason}`); - return true; - }; - - const run = (async () => { - mark("start"); - - const skipHealthCheck = selectOptions?.skipHealthCheck === true; - if (skipHealthCheck) { - mark("health skipped", { source: selectOptions?.source ?? "unknown" }); - } else { - mark("checking health"); - try { - await withTimeout(c.global.health(), 3000, "health"); - mark("health ok"); - } catch (error) { - mark("health FAILED", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - throw new Error(t("app.connection_lost", currentLocale())); - } - } - if (abortIfStale("selection changed after health")) return; - - const existingLimit = messageLimitBySession()[sessionID] ?? 0; - const requestLimit = Math.max(INITIAL_SESSION_MESSAGE_LIMIT, existingLimit); - mark("calling session.messages", { limit: requestLimit }); - const msgs = unwrap( - await withTimeout(c.session.messages({ sessionID, limit: requestLimit }), 12000, "session.messages"), - ); - mark("session.messages done", { limit: requestLimit, count: msgs.length }); - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - if (abortIfStale("selection changed before messages applied")) return; - setMessagesForSession(sessionID, msgs); - setMessageLimitBySession((prev) => ({ ...prev, [sessionID]: requestLimit })); - setMessageCompleteBySession((prev) => ({ ...prev, [sessionID]: msgs.length < requestLimit })); - - const model = options.lastUserModelFromMessages(msgs); - if (model) { - if (abortIfStale("selection changed before model applied")) return; - options.setSessionModelState((current) => ({ - overrides: current.overrides, - resolved: { ...current.resolved, [sessionID]: model }, - })); - - options.setSessionModelState((current) => { - if (!current.overrides[sessionID]) return current; - const copy = { ...current.overrides }; - delete copy[sessionID]; - return { ...current, overrides: copy }; - }); - } - - try { - mark("calling session.todo"); - const list = unwrap(await withTimeout(c.session.todo({ sessionID }), 8000, "session.todo")); - mark("session.todo done"); - if (abortIfStale("selection changed before todos applied")) return; - setStore("todos", sessionID, list as TodoItem[]); - } catch (error) { - mark("session.todo failed/timeout", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - if (abortIfStale("selection changed before todo fallback")) return; - setStore("todos", sessionID, []); - } - - try { - mark("calling permission.list"); - await withTimeout(refreshPendingPermissions(), 6000, "permission.list"); - mark("permission.list done"); - if (abortIfStale("selection changed before permissions applied")) return; - } catch (error) { - mark("permission.list failed/timeout", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - if (abortIfStale("selection changed after permission failure")) return; - } - - finishPerf(perfEnabled, "session.select", "complete", startedAt, { - runId, - sessionID, - messageCount: msgs.length, - todoCount: (store.todos[sessionID] ?? []).length, - }); - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - })(); - - selectInFlightBySession.set(sessionID, run); - try { - await run; - } finally { - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - if (selectInFlightBySession.get(sessionID) === run) { - selectInFlightBySession.delete(sessionID); - } - } - } - - async function loadEarlierMessages(sessionID: string, chunk = SESSION_MESSAGE_LOAD_CHUNK) { - const c = options.client(); - if (!c) return; - if (!sessionID) return; - if (messageLoadBusyBySession()[sessionID]) return; - if (messageCompleteBySession()[sessionID]) return; - - const currentLimit = Math.max(INITIAL_SESSION_MESSAGE_LIMIT, messageLimitBySession()[sessionID] ?? 0); - const nextLimit = currentLimit + Math.max(1, chunk); - - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: true })); - try { - const msgs = unwrap(await withTimeout(c.session.messages({ sessionID, limit: nextLimit }), 12000, "session.messages")); - setMessagesForSession(sessionID, msgs); - setMessageLimitBySession((prev) => ({ ...prev, [sessionID]: nextLimit })); - setMessageCompleteBySession((prev) => ({ ...prev, [sessionID]: msgs.length < nextLimit })); - } catch (error) { - addError(error); - } finally { - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - } - } - - async function respondPermission(requestID: string, reply: "once" | "always" | "reject") { - const c = options.client(); - if (!c || permissionReplyBusy()) return; - - setPermissionReplyBusy(true); - options.setError(null); - - try { - unwrap(await c.permission.reply({ requestID, reply })); - await refreshPendingPermissions(); - } catch (e) { - addError(e); - } finally { - setPermissionReplyBusy(false); - } - } - - async function respondQuestion(requestID: string, answers: string[][]) { - const c = options.client(); - if (!c || questionReplyBusy()) return; - - setQuestionReplyBusy(true); - options.setError(null); - - try { - unwrap(await c.question.reply({ requestID, answers })); - await refreshPendingQuestions(); - } catch (e) { - addError(e); - } finally { - setQuestionReplyBusy(false); - } - } - - async function rejectQuestion(requestID: string) { - const c = options.client(); - if (!c || questionReplyBusy()) return; - - setQuestionReplyBusy(true); - options.setError(null); - - try { - unwrap(await c.question.reject({ requestID })); - await refreshPendingQuestions(); - } catch (e) { - addError(e); - } finally { - setQuestionReplyBusy(false); - } - } - - const setSessions = (next: Session[]) => { - rememberSessions(next); - setStore("sessions", reconcile(sortSessionsByActivity(next), { key: "id" })); - }; - - const setSessionStatusById = (next: Record) => { - setStore("sessionStatus", next); - }; - - const setMessages = (next: MessageWithParts[]) => { - const id = options.selectedSessionId(); - if (!id) return; - setMessagesForSession(id, next); - }; - - const setTodos = (next: TodoItem[]) => { - const id = options.selectedSessionId(); - if (!id) return; - setStore("todos", id, next); - }; - - const setPendingPermissions = (next: PendingPermission[]) => { - setStore("pendingPermissions", next); - }; - - const setPendingQuestions = (next: PendingQuestion[]) => { - setStore("pendingQuestions", next); - }; - - const activePermission = createMemo(() => { - const id = options.selectedSessionId(); - if (id) { - const scoped = store.pendingPermissions.find((perm) => perm.sessionID === id) ?? null; - if (scoped) return scoped; - } - return store.pendingPermissions[0] ?? null; - }); - - const activeQuestion = createMemo(() => { - const id = options.selectedSessionId(); - if (id) { - const scoped = store.pendingQuestions.find((q) => q.sessionID === id) ?? null; - if (scoped) return scoped; - } - return store.pendingQuestions[0] ?? null; - }); - - const [questionReplyBusy, setQuestionReplyBusy] = createSignal(false); - let lastPartDebugEventAt = 0; - let suppressedPartDebugEvents = 0; - - const appendDebugEvent = (event: { type: string; properties?: unknown }) => { - setStore("events", (current) => { - const next = [event, ...current]; - return next.slice(0, 150); - }); - }; - - const setSessionCompaction = (sessionID: string, next: SessionCompactionState) => { - setStore("sessionCompaction", sessionID, next); - }; - - const stopSessionCompaction = (sessionID: string) => { - const current = store.sessionCompaction[sessionID]; - pendingCompactionModeBySession.delete(sessionID); - if (!current?.running) return; - setSessionCompaction(sessionID, { - ...current, - running: false, - messageID: null, - }); - }; - - const startSessionCompaction = (sessionID: string, messageID: string) => { - const current = store.sessionCompaction[sessionID]; - if (current?.running && current.messageID === messageID) return; - const startedAt = Date.now(); - const mode = pendingCompactionModeBySession.get(sessionID) ?? current?.mode ?? null; - pendingCompactionModeBySession.delete(sessionID); - setSessionCompaction(sessionID, { - running: true, - startedAt, - finishedAt: null, - mode, - messageID, - }); - if (options.developerMode()) { - appendDebugEvent({ - type: "session.compaction.started", - properties: { sessionID, messageID, mode, startedAt }, - }); - } - }; - - const finishSessionCompaction = (sessionID: string) => { - const current = store.sessionCompaction[sessionID]; - const finishedAt = Date.now(); - pendingCompactionModeBySession.delete(sessionID); - setSessionCompaction(sessionID, { - running: false, - startedAt: current?.startedAt ?? null, - finishedAt, - mode: current?.mode ?? null, - messageID: null, - }); - if (options.developerMode()) { - appendDebugEvent({ - type: "session.compaction.finished", - properties: { - sessionID, - mode: current?.mode ?? null, - startedAt: current?.startedAt ?? null, - finishedAt, - durationMs: - typeof current?.startedAt === "number" ? Math.max(0, finishedAt - current.startedAt) : null, - }, - }); - } - }; - - const compactDebugEvent = (event: OpencodeEvent) => { - if (event.type === "message.part.updated") { - const record = event.properties as Record | undefined; - const part = record?.part as Part | undefined; - const delta = typeof record?.delta === "string" ? record.delta : ""; - const textLength = - part?.type === "text" && typeof (part as { text?: unknown }).text === "string" - ? String((part as { text?: string }).text).length - : null; - return { - type: event.type, - properties: { - sessionID: part?.sessionID ?? null, - messageID: part?.messageID ?? null, - partID: part?.id ?? null, - partType: part?.type ?? null, - deltaLength: delta.length, - textLength, - }, - }; - } - - if (event.type === "message.part.delta") { - const record = event.properties as Record | undefined; - const delta = typeof record?.delta === "string" ? record.delta : ""; - return { - type: event.type, - properties: { - sessionID: typeof record?.sessionID === "string" ? record.sessionID : null, - messageID: typeof record?.messageID === "string" ? record.messageID : null, - partID: typeof record?.partID === "string" ? record.partID : null, - field: typeof record?.field === "string" ? record.field : null, - deltaLength: delta.length, - }, - }; - } - - return { - type: event.type, - properties: event.properties, - }; - }; - - const applyEvent = async (event: OpencodeEvent) => { - if (event.type === "server.connected") { - options.setSseConnected(true); - } - - if (options.developerMode()) { - const compact = compactDebugEvent(event); - if (event.type === "message.part.updated" || event.type === "message.part.delta") { - const now = Date.now(); - if (now - lastPartDebugEventAt < 250) { - suppressedPartDebugEvents += 1; - } else { - lastPartDebugEventAt = now; - if (suppressedPartDebugEvents > 0) { - compact.properties = { - ...(compact.properties ?? {}), - suppressed: suppressedPartDebugEvents, - }; - suppressedPartDebugEvents = 0; - } - appendDebugEvent(compact); - } - } else { - if (suppressedPartDebugEvents > 0) { - appendDebugEvent({ - type: "message.part.stream.sample", - properties: { suppressed: suppressedPartDebugEvents }, - }); - suppressedPartDebugEvents = 0; - } - appendDebugEvent(compact); - } - } - - if (event.type === "session.updated" || event.type === "session.created") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - if (record.info && typeof record.info === "object") { - const info = record.info as Session; - rememberSession(info); - setStore("sessions", (current) => upsertSession(current, info)); - } - } - } - - if (event.type === "session.deleted") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const info = record.info as Session | undefined; - if (info?.id) { - syntheticContinueEventTimesBySession.delete(info.id); - syntheticTaskSummaryEventTimesBySession.delete(info.id); - syntheticContinueLoopLastWarnAtBySession.delete(info.id); - syntheticLoopLastAbortAtByKey.delete(`task-summary:${info.id}`); - syntheticLoopLastAbortAtByKey.delete(`compaction-continue:${info.id}`); - pendingCompactionModeBySession.delete(info.id); - setStore( - produce((draft: StoreState) => { - delete draft.sessionInfoById[info.id]; - delete draft.sessionCompaction[info.id]; - }), - ); - setStore("sessions", (current) => removeSession(current, info.id)); - setStore( - produce((draft: StoreState) => { - delete draft.sessionErrorTurns[info.id]; - }), - ); - } - } - } - - if (event.type === "session.status") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - const normalized = normalizeSessionStatus(record.status); - setStore("sessionStatus", sessionID, normalized); - if (normalized === "idle") { - stopSessionCompaction(sessionID); - } - if (sessionID === options.selectedSessionId() && normalized !== "idle") { - options.setError(null); - } - } - } - } - - if (event.type === "session.idle") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - setStore("sessionStatus", sessionID, "idle"); - stopSessionCompaction(sessionID); - const c = options.client(); - if (c) { - try { - const latest = unwrap(await c.session.get({ sessionID })); - rememberSession(latest); - setStore("sessions", (current) => upsertSession(current, latest)); - } catch { - // ignore - } - } - } - } - } - - if (event.type === "opencode.hotreload.applied") { - options.onHotReloadApplied?.(); - } - - if (event.type === "session.error") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - setStore("sessionStatus", sessionID, "idle"); - stopSessionCompaction(sessionID); - } - const errorObj = record.error as Record | undefined; - if (errorObj) { - const errorName = typeof errorObj.name === "string" ? errorObj.name : "UnknownError"; - if (errorName === "MessageAbortedError") { - // Cancellation is a user-driven control flow. Don't treat it as a - // fatal error banner; the session UI already provides local UX. - if (!sessionID) { - options.setError(null); - } - return; - } - if (sessionID) { - appendSessionErrorTurn(sessionID, addOpencodeCacheHint(formatSessionError(errorObj))); - } else { - options.setError(addOpencodeCacheHint(formatSessionError(errorObj))); - } - return; - } - - const fallback = truncateErrorField(record.error, 700) ?? "An unexpected error occurred"; - if (sessionID) { - appendSessionErrorTurn(sessionID, addOpencodeCacheHint(fallback)); - } else { - options.setError(addOpencodeCacheHint(fallback)); - } - } - } - - if (event.type === "message.updated") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - if (record.info && typeof record.info === "object") { - const info = record.info as Message; - const messageRecord = info as Message & Record; - const model = modelFromUserMessage(info as MessageInfo); - if (model) { - options.setSessionModelState((current) => ({ - overrides: current.overrides, - resolved: { ...current.resolved, [info.sessionID]: model }, - })); - - options.setSessionModelState((current) => { - if (!current.overrides[info.sessionID]) return current; - const copy = { ...current.overrides }; - delete copy[info.sessionID]; - return { ...current, overrides: copy }; - }); - } - - setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info)); - - if ( - messageRecord.role === "assistant" && - messageRecord.mode === "compaction" && - messageRecord.summary === true - ) { - startSessionCompaction(info.sessionID, info.id); - } - } - } - } - - if (event.type === "message.removed") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - const messageID = typeof record.messageID === "string" ? record.messageID : null; - if (sessionID && messageID) { - setStore("messages", sessionID, (current = []) => removeMessageInfo(current, messageID)); - setStore("parts", messageID, []); - } - } - } - - if (event.type === "message.part.updated") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - if (record.part && typeof record.part === "object") { - const part = record.part as Part; - const delta = typeof record.delta === "string" ? record.delta : null; - const partUpdatedStartedAt = perfNow(); - - if (part.type === "compaction") { - pendingCompactionModeBySession.set( - part.sessionID, - (part as Part & { auto?: unknown }).auto === true ? "auto" : "manual", - ); - } - - setStore( - produce((draft: StoreState) => { - const list = draft.messages[part.sessionID] ?? []; - if (!list.find((message) => message.id === part.messageID)) { - draft.messages[part.sessionID] = upsertMessageInfo(list, createPlaceholderMessage(part)); - } - - const parts = draft.parts[part.messageID] ?? []; - const existingIndex = parts.findIndex((item) => item.id === part.id); - - if (delta && part.type === "text" && existingIndex !== -1) { - const existing = parts[existingIndex] as Part & { text?: string }; - if (typeof existing.text === "string" && !existing.text.endsWith(delta)) { - const next = { ...existing, text: `${existing.text}${delta}` } as Part; - parts[existingIndex] = next; - draft.parts[part.messageID] = parts; - return; - } - } - - draft.parts[part.messageID] = upsertPartInfo(parts, part); - }), - ); - const resolvedPart = - store.parts[part.messageID]?.find((item) => item.id === part.id) ?? - part; - recordSyntheticContinueDiagnostic(resolvedPart); - recordSyntheticTaskSummaryDiagnostic(resolvedPart); - maybeAbortSyntheticControlLoop(resolvedPart); - const partUpdatedMs = Math.round((perfNow() - partUpdatedStartedAt) * 100) / 100; - if (sessionDebugEnabled() && (partUpdatedMs >= 8 || (delta?.length ?? 0) >= 120)) { - const textLength = - part.type === "text" && typeof (part as { text?: unknown }).text === "string" - ? String((part as { text?: string }).text).length - : null; - recordPerfLog(true, "session.event", "message.part.updated", { - sessionID: part.sessionID, - messageID: part.messageID, - partID: part.id, - partType: part.type, - deltaLength: delta?.length ?? 0, - textLength, - ms: partUpdatedMs, - }); - } - maybeMarkReloadRequired(part); - maybeHandleInvalidToolError(part); - } - } - } - - if (event.type === "message.part.delta") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - const messageID = typeof record.messageID === "string" ? record.messageID : null; - const partID = typeof record.partID === "string" ? record.partID : null; - const field = typeof record.field === "string" ? record.field : null; - const delta = typeof record.delta === "string" ? record.delta : null; - const partDeltaStartedAt = perfNow(); - - if (messageID && partID && field && delta) { - setStore("parts", messageID, (current = []) => appendPartDelta(current, messageID, sessionID, partID, field, delta)); - const partDeltaMs = Math.round((perfNow() - partDeltaStartedAt) * 100) / 100; - if (sessionDebugEnabled() && (partDeltaMs >= 8 || delta.length >= 120)) { - recordPerfLog(true, "session.event", "message.part.delta", { - sessionID, - messageID, - partID, - field, - deltaLength: delta.length, - ms: partDeltaMs, - }); - } - } - } - } - - if (event.type === "message.part.removed") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const messageID = typeof record.messageID === "string" ? record.messageID : null; - const partID = typeof record.partID === "string" ? record.partID : null; - if (messageID && partID) { - setStore("parts", messageID, (current = []) => removePartInfo(current, partID)); - } - } - } - - if (event.type === "todo.updated") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID && Array.isArray(record.todos)) { - setStore("todos", sessionID, record.todos as TodoItem[]); - } - } - } - - if (event.type === "session.compacted") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - finishSessionCompaction(sessionID); - } - } - } - - if (event.type === "permission.asked" || event.type === "permission.replied") { - try { - await refreshPendingPermissions(); - } catch { - // ignore - } - } - - if ( - event.type === "question.asked" || - event.type === "question.replied" || - event.type === "question.rejected" - ) { - try { - await refreshPendingQuestions(); - } catch { - // ignore - } - } - }; - - createEffect(() => { - const c = options.client(); - if (!c) return; - - let cancelled = false; - let reconnectAttempt = 0; - let reconnectTimer: ReturnType | undefined; - - let queue: Array = []; - const coalesced = new Map(); - let timer: ReturnType | undefined; - let last = 0; - let queueStartedAt = 0; - let peakQueueDepth = 0; - let queueHasPartUpdates = false; - let coalescedReplaced = 0; - - const keyForEvent = (event: OpencodeEvent) => { - if (event.type === "session.status" || event.type === "session.idle") { - const record = event.properties as Record | undefined; - const sessionID = typeof record?.sessionID === "string" ? record.sessionID : ""; - return sessionID ? `${event.type}:${sessionID}` : undefined; - } - if (event.type === "message.part.updated") { - const record = event.properties as Record | undefined; - const part = record?.part as Part | undefined; - if (part?.messageID && part.id) { - return `message.part.updated:${part.messageID}:${part.id}`; - } - } - if (event.type === "todo.updated") { - const record = event.properties as Record | undefined; - const sessionID = typeof record?.sessionID === "string" ? record.sessionID : ""; - return sessionID ? `todo.updated:${sessionID}` : undefined; - } - return undefined; - }; - - const flush = () => { - if (timer) clearTimeout(timer); - timer = undefined; - - const eventsToApply = queue; - queue = []; - coalesced.clear(); - if (eventsToApply.length === 0) return; - - const queueWaitMs = queueStartedAt > 0 ? Date.now() - queueStartedAt : 0; - queueStartedAt = 0; - const peakDepth = peakQueueDepth; - peakQueueDepth = 0; - queueHasPartUpdates = false; - const replaced = coalescedReplaced; - coalescedReplaced = 0; - - last = Date.now(); - const startedAt = perfNow(); - let applied = 0; - let partUpdates = 0; - let messageUpdates = 0; - batch(() => { - for (const event of eventsToApply) { - if (!event) continue; - if (event.type === "message.part.updated" || event.type === "message.part.delta") partUpdates += 1; - if (event.type === "message.updated") messageUpdates += 1; - applied += 1; - void applyEvent(event); - } - }); - - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - const dropped = eventsToApply.length - applied; - if ( - sessionDebugEnabled() && - (elapsedMs >= 10 || queueWaitMs >= 40 || peakDepth >= 25 || applied >= 30 || dropped >= 12) - ) { - recordPerfLog(true, "session.sse", "flush", { - queued: eventsToApply.length, - applied, - dropped, - queueWaitMs, - peakQueueDepth: peakDepth, - coalescedReplaced: replaced, - messageUpdates, - partUpdates, - ms: elapsedMs, - }); - } - }; - - const schedule = () => { - if (timer) return; - const elapsed = Date.now() - last; - const interval = queueHasPartUpdates ? 48 : 16; - timer = setTimeout(flush, Math.max(0, interval - elapsed)); - }; - - const connectSse = async (controller: AbortController) => { - try { - const sub = await c.event.subscribe(undefined, { signal: controller.signal }); - let yielded = Date.now(); - let lastArrivalAt = Date.now(); - - // Reset reconnect counter on successful connection - reconnectAttempt = 0; - recordPerfLog(sessionDebugEnabled(), "session.sse", "connected"); - - for await (const raw of sub.stream) { - if (cancelled) break; - - const event = normalizeEvent(raw); - if (!event) continue; - - const arrivedAt = Date.now(); - const arrivalGapMs = arrivedAt - lastArrivalAt; - lastArrivalAt = arrivedAt; - if (sessionDebugEnabled() && arrivalGapMs >= 220) { - recordPerfLog(true, "session.sse", "arrival-gap", { - ms: arrivalGapMs, - type: event.type, - }); - } - - const key = keyForEvent(event); - if (key) { - const existing = coalesced.get(key); - if (existing !== undefined) { - if (queue[existing] !== undefined) { - coalescedReplaced += 1; - } - queue[existing] = undefined; - } - coalesced.set(key, queue.length); - } - - if (queue.length === 0) { - queueStartedAt = Date.now(); - } - if (event.type === "message.part.updated" || event.type === "message.part.delta") { - queueHasPartUpdates = true; - } - queue.push(event); - if (queue.length > peakQueueDepth) { - peakQueueDepth = queue.length; - } - schedule(); - - if (Date.now() - yielded < 8) continue; - yielded = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - // Stream ended normally - attempt reconnect unless cancelled - if (!cancelled) { - options.setSseConnected(false); - recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-ended"); - scheduleReconnect(controller); - } - } catch (e) { - if (cancelled) return; - - const message = e instanceof Error ? e.message : String(e); - if (message.toLowerCase().includes("abort")) return; - - // Mark SSE as disconnected and schedule reconnect - options.setSseConnected(false); - recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-error", { - error: message, - }); - scheduleReconnect(controller); - } - }; - - const scheduleReconnect = (oldController: AbortController) => { - if (cancelled) return; - oldController.abort(); - - // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s - reconnectAttempt++; - const delay = Math.min(1000 * Math.pow(2, reconnectAttempt - 1), 30000); - recordPerfLog(sessionDebugEnabled(), "session.sse", "reconnect-scheduled", { - attempt: reconnectAttempt, - delayMs: delay, - }); - - reconnectTimer = setTimeout(() => { - if (cancelled) return; - const newController = new AbortController(); - void connectSse(newController); - }, delay); - }; - - const controller = new AbortController(); - void connectSse(controller); - - onCleanup(() => { - cancelled = true; - controller.abort(); - if (reconnectTimer) clearTimeout(reconnectTimer); - flush(); - }); - }); - - return { - sessions, - loadedScopeRoot, - sessionById, - sessionErrorTurnsById: (sessionID: string | null) => (sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []), - selectedSessionErrorTurns: createMemo(() => { - const sessionID = options.selectedSessionId(); - return sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []; - }), - sessionStatusById, - selectedSession, - selectedSessionStatus, - messageIdFromInfo, - visibleMessages, - blueprintSeedMessagesForSelectedSession, - restorePromptFromUserMessage, - upsertLocalSession, - setBlueprintSeedMessagesBySessionId, - selectedSessionCompactionState: createMemo(() => { - const sessionID = options.selectedSessionId(); - return sessionID ? store.sessionCompaction[sessionID] ?? null : null; - }), - messages, - messagesBySessionId, - sessionCompactionById: (sessionID: string | null) => (sessionID ? store.sessionCompaction[sessionID] ?? null : null), - todos, - pendingPermissions, - permissionReplyBusy, - pendingQuestions, - activeQuestion, - questionReplyBusy, - events, - activePermission, - loadSessions, - ensureSessionLoaded, - refreshPendingPermissions, - refreshPendingQuestions, - selectSession, - loadEarlierMessages, - renameSession, - respondPermission, - respondQuestion, - rejectQuestion, - appendSessionErrorTurn, - setSessions, - setSessionStatusById, - setMessages, - setTodos, - setPendingPermissions, - setPendingQuestions, - selectedSessionHasEarlierMessages, - selectedSessionLoadingEarlierMessages, - sessionLoadingById: (sessionID: string | null) => (sessionID ? Boolean(messageLoadBusyBySession()[sessionID]) : false), - }; -} diff --git a/apps/app/src/app/context/sidebar-sessions.ts b/apps/app/src/app/context/sidebar-sessions.ts deleted file mode 100644 index 372d9880..00000000 --- a/apps/app/src/app/context/sidebar-sessions.ts +++ /dev/null @@ -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 - >({}); - const [statusByWorkspaceId, setStatusByWorkspaceId] = createSignal< - Record - >({}); - const [errorByWorkspaceId, setErrorByWorkspaceId] = createSignal>({}); - - const pruneState = (workspaceIds: Set) => { - setSessionsByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, list] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = list; - } - return changed ? next : prev; - }); - setStatusByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, status] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = status; - } - return changed ? next : prev; - }); - setErrorByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, error] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = error; - } - return changed ? next : prev; - }); - }; - - const 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 = {}; - 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 = {}; - 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 = {}; - 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(() => { - 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, - }; -} diff --git a/apps/app/src/app/context/updater.ts b/apps/app/src/app/context/updater.ts deleted file mode 100644 index 54c88591..00000000 --- a/apps/app/src/app/context/updater.ts +++ /dev/null @@ -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({ state: "idle", lastCheckedAt: null }); - const [pendingUpdate, setPendingUpdate] = createSignal(null); - const [updateEnv, setUpdateEnv] = createSignal(null); - - return { - updateAutoCheck, - setUpdateAutoCheck, - updateAutoDownload, - setUpdateAutoDownload, - updateStatus, - setUpdateStatus, - pendingUpdate, - setPendingUpdate, - updateEnv, - setUpdateEnv, - } as const; -} diff --git a/apps/app/src/app/context/workspace-context.ts b/apps/app/src/app/context/workspace-context.ts deleted file mode 100644 index 2f187348..00000000 --- a/apps/app/src/app/context/workspace-context.ts +++ /dev/null @@ -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}`; - }); -} diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts deleted file mode 100644 index 01c39957..00000000 --- a/apps/app/src/app/context/workspace.ts +++ /dev/null @@ -1,4151 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { listen, type Event as TauriEvent } from "@tauri-apps/api/event"; -import type { Session } from "@opencode-ai/sdk/v2/client"; - -import type { - Client, - StartupPreference, - OnboardingStep, - WorkspaceDisplay, - WorkspaceOpenworkConfig, - WorkspacePreset, - WorkspaceConnectionState, - EngineRuntime, -} from "../types"; -import { - addOpencodeCacheHint, - clearStartupPreference, - isTauriRuntime, - normalizeDirectoryPath, - readStartupPreference, - safeStringify, - writeStartupPreference, -} from "../utils"; -import { unwrap } from "../lib/opencode"; -import { recordDevLog } from "../lib/dev-log"; -import { describeDirectoryScope, resolveScopedClientDirectory, toSessionTransportDirectory } from "../lib/session-scope"; -import { blueprintMaterializedSessions, blueprintSessions, defaultBlueprintSessionsForPreset } from "../lib/workspace-blueprints"; -import { - buildOpenworkWorkspaceBaseUrl, - createOpenworkServerClient, - normalizeOpenworkServerUrl, - parseOpenworkWorkspaceIdFromUrl, - OpenworkServerError, - type OpenworkServerClient, - type OpenworkWorkspaceInfo, -} from "../lib/openwork-server"; -import { downloadDir, homeDir } from "@tauri-apps/api/path"; -import { - engineDoctor, - engineInfo, - engineInstall, - engineStart, - engineStop, - sandboxDoctor, - orchestratorInstanceDispose, - orchestratorStartDetached, - orchestratorWorkspaceActivate, - pickFile, - pickDirectory, - saveFile, - workspaceBootstrap, - workspaceCreate, - workspaceCreateRemote, - workspaceExportConfig, - workspaceForget, - workspaceImportConfig, - workspaceOpenworkRead, - workspaceOpenworkWrite, - workspaceSetRuntimeActive, - workspaceSetSelected, - workspaceUpdateDisplayName, - workspaceUpdateRemote, - resolveWorkspaceListSelectedId, - type EngineDoctorResult, - type EngineInfo, - type SandboxDoctorResult, - type WorkspaceInfo, -} from "../lib/tauri"; -import type { BootPhase, StartupBranch } from "../lib/startup-boot"; -import { waitForHealthy, createClient, type OpencodeAuth } from "../lib/opencode"; -import type { OpencodeConnectStatus, ProviderListItem } from "../types"; -import { t, currentLocale } from "../../i18n"; -import { filterProviderList, mapConfigProvidersToList } from "../utils/providers"; -import { buildDefaultWorkspaceBlueprint, normalizeWorkspaceOpenworkConfig } from "../lib/workspace-blueprints"; -import type { OpenworkServerStore } from "../connections/openwork-server-store"; -import { resolveSandboxCreateMode, type SandboxBackendType } from "./sandbox-create-mode"; - -export type WorkspaceStore = ReturnType; - -export type WorkspaceDebugEvent = { - at: number; - label: string; - payload?: unknown; -}; - -export type SandboxCreateProgressStepStatus = "pending" | "active" | "done" | "error"; - -export type SandboxCreateProgressStep = { - key: "docker" | "workspace" | "sandbox" | "health" | "connect"; - label: string; - status: SandboxCreateProgressStepStatus; - detail?: string | null; -}; - -export type SandboxCreateProgressState = { - runId: string; - startedAt: number; - stage: string; - steps: SandboxCreateProgressStep[]; - logs: string[]; - error: string | null; -}; - -export type SandboxCreatePhase = "idle" | "preflight" | "provisioning" | "finalizing"; - -type BlueprintSeedMessage = { role?: "assistant" | "user" | null; text?: string | null }; - -type RuntimeWorkspaceLookup = { - workspaceId?: string | null; - directoryHint?: string | null; - localRoot?: string | null; - strictMatch?: boolean; -}; - -export function createWorkspaceStore(options: { - startupPreference: () => StartupPreference | null; - setStartupPreference: (value: StartupPreference | null) => void; - onboardingStep: () => OnboardingStep; - setOnboardingStep: (step: OnboardingStep) => void; - rememberStartupChoice: () => boolean; - setRememberStartupChoice: (value: boolean) => void; - baseUrl: () => string; - setBaseUrl: (value: string) => void; - clientDirectory: () => string; - setClientDirectory: (value: string) => void; - client: () => Client | null; - setClient: (value: Client | null) => void; - setConnectedVersion: (value: string | null) => void; - setSseConnected: (value: boolean) => void; - setProviders: (value: ProviderListItem[]) => void; - setProviderDefaults: (value: Record) => void; - setProviderConnectedIds: (value: string[]) => void; - setError: (value: string | null) => void; - setBusy: (value: boolean) => void; - setBusyLabel: (value: string | null) => void; - setBusyStartedAt: (value: number | null) => void; - loadSessions: (scopeRoot?: string) => Promise; - refreshPendingPermissions: () => Promise; - refreshWorkspaceSessions?: (workspaceId: string) => Promise; - sessions: () => Session[]; - sessionsLoaded: () => boolean; - creatingSession: () => boolean; - readLastSessionByWorkspace?: () => Record; - selectedSessionId: () => string | null; - selectSession: (id: string, options?: { skipHealthCheck?: boolean; source?: string }) => Promise; - setBlueprintSeedMessagesBySessionId: ( - updater: (current: Record) => Record, - ) => void; - setSelectedSessionId: (value: string | null) => void; - setMessages: (value: any[]) => void; - setTodos: (value: any[]) => void; - setPendingPermissions: (value: any[]) => void; - setSessionStatusById: (value: Record) => void; - defaultModel: () => any; - modelVariant: () => string | null; - refreshSkills: (options?: { force?: boolean }) => Promise; - refreshPlugins: () => Promise; - engineSource: () => "path" | "sidecar" | "custom"; - engineCustomBinPath?: () => string; - opencodeEnableExa?: () => boolean; - setEngineSource: (value: "path" | "sidecar" | "custom") => void; - setView: (value: any, sessionId?: string) => void; - setSettingsTab: (value: any) => void; - isWindowsPlatform: () => boolean; - openworkServer: OpenworkServerStore; - openworkEnvWorkspaceId?: string | null; - setOpencodeConnectStatus?: (status: OpencodeConnectStatus | null) => void; - onEngineStable?: () => void; - onBootPhaseChange?: (phase: BootPhase, detail?: Record) => void; - onStartupBranch?: (branch: StartupBranch, detail?: Record) => void; - onStartupTrace?: (event: string, detail?: Record) => void; - engineRuntime?: () => EngineRuntime; - developerMode: () => boolean; - pendingInitialSessionSelection?: () => { workspaceId: string; title: string | null; readyAt: number } | null; - setPendingInitialSessionSelection?: (input: { workspaceId: string; title: string | null; readyAt: number } | null) => void; - useMicrosandboxCreateSandbox?: () => boolean; -}) { - - const wsDebugEnabled = () => options.developerMode(); - - const WORKSPACE_DEBUG_EVENT_LIMIT = 200; - const [workspaceDebugEvents, setWorkspaceDebugEvents] = createSignal([]); - const clearWorkspaceDebugEvents = () => setWorkspaceDebugEvents([]); - const pushWorkspaceDebugEvent = (label: string, payload?: unknown) => { - if (!wsDebugEnabled()) return; - const entry: WorkspaceDebugEvent = { at: Date.now(), label, payload }; - setWorkspaceDebugEvents((prev) => { - if (!prev.length) return [entry]; - const sliceStart = Math.max(0, prev.length - WORKSPACE_DEBUG_EVENT_LIMIT + 1); - const next = prev.slice(sliceStart); - next.push(entry); - return next; - }); - }; - - const wsDebug = (label: string, payload?: unknown) => { - if (!wsDebugEnabled()) return; - try { - recordDevLog(true, { level: "debug", source: "workspace", label, payload }); - if (payload === undefined) { - console.log(`[WSDBG] ${label}`); - } else { - console.log(`[WSDBG] ${label}`, payload); - } - pushWorkspaceDebugEvent(label, payload); - } catch { - // ignore - } - }; - - const connectInFlightByKey = new Map>(); - let createRemoteInFlight: Promise | null = null; - const DEFAULT_CONNECT_HEALTH_TIMEOUT_MS = 12_000; - const LOCAL_BOOT_CONNECT_HEALTH_TIMEOUT_MS = 180_000; - const LONG_BOOT_CONNECT_REASONS = new Set(["host-start", "bootstrap-local"]); - const DEFAULT_WORKSPACE_HOME_FOLDER_NAME = "OpenWork"; - const FIRST_RUN_WELCOME_WORKSPACE_NAME = "Welcome"; - const preferredInitialSessionTitleForPreset = (preset: WorkspacePreset) => { - const trimmed = defaultBlueprintSessionsForPreset(preset) - .find((session) => session.openOnFirstLoad === true)?.title?.trim(); - return trimmed || null; - }; - - const queuePendingInitialSessionSelection = (workspaceId: string | null, preset: WorkspacePreset) => { - const preferredInitialSessionTitle = preferredInitialSessionTitleForPreset(preset); - if (!workspaceId) { - options.setPendingInitialSessionSelection?.(null); - return; - } - options.setPendingInitialSessionSelection?.({ - workspaceId, - title: preferredInitialSessionTitle, - readyAt: Date.now() + 2_000, - }); - }; - - const connectRequestKey = ( - nextBaseUrl: string, - directory?: string, - context?: { - workspaceId?: string; - workspaceType?: WorkspaceInfo["workspaceType"]; - targetRoot?: string; - reason?: string; - }, - auth?: OpencodeAuth, - connectOptions?: { quiet?: boolean; navigate?: boolean }, - ) => - [ - nextBaseUrl.trim(), - (directory ?? "").trim(), - context?.workspaceId?.trim() ?? "", - context?.workspaceType ?? "", - context?.targetRoot?.trim() ?? "", - context?.reason ?? "", - auth?.mode ?? (auth ? "basic" : "none"), - String(connectOptions?.quiet ?? false), - String(connectOptions?.navigate ?? true), - ].join("::"); - - const resolveConnectHealthTimeoutMs = (reason?: string) => { - const normalizedReason = reason?.trim() ?? ""; - if (LONG_BOOT_CONNECT_REASONS.has(normalizedReason)) { - return LOCAL_BOOT_CONNECT_HEALTH_TIMEOUT_MS; - } - return DEFAULT_CONNECT_HEALTH_TIMEOUT_MS; - }; - - const [engine, setEngine] = createSignal(null); - const [engineAuth, setEngineAuth] = createSignal(null); - const [engineDoctorResult, setEngineDoctorResult] = createSignal(null); - const [engineDoctorCheckedAt, setEngineDoctorCheckedAt] = createSignal(null); - const [engineInstallLogs, setEngineInstallLogs] = createSignal(null); - const [sandboxDoctorResult, setSandboxDoctorResult] = createSignal(null); - const [sandboxDoctorCheckedAt, setSandboxDoctorCheckedAt] = createSignal(null); - const [sandboxDoctorBusy, setSandboxDoctorBusy] = createSignal(false); - const [sandboxPreflightBusy, setSandboxPreflightBusy] = createSignal(false); - const [sandboxCreatePhase, setSandboxCreatePhase] = createSignal("idle"); - - const [sandboxCreateProgress, setSandboxCreateProgress] = createSignal(null); - const [lastSandboxCreateProgress, setLastSandboxCreateProgress] = - createSignal(null); - const clearSandboxCreateProgress = () => { - const snapshot = sandboxCreateProgress(); - if (snapshot) { - setLastSandboxCreateProgress(snapshot); - } - setSandboxCreateProgress(null); - }; - - const pushSandboxCreateLog = (line: string) => { - const value = String(line ?? "").trim(); - if (!value) return; - setSandboxCreateProgress((prev) => { - if (!prev) return prev; - const nextLogs = prev.logs.length ? prev.logs.slice(-119) : []; - // Avoid rapid duplicates. - const last = nextLogs[nextLogs.length - 1] ?? ""; - if (last !== value) nextLogs.push(value); - return { ...prev, logs: nextLogs }; - }); - }; - - const setSandboxStep = (key: SandboxCreateProgressStep["key"], patch: Partial) => { - setSandboxCreateProgress((prev) => { - if (!prev) return prev; - return { - ...prev, - steps: prev.steps.map((step) => (step.key === key ? { ...step, ...patch } : step)), - }; - }); - }; - - const setSandboxStage = (stage: string) => { - const value = String(stage ?? "").trim(); - if (!value) return; - setSandboxCreateProgress((prev) => (prev ? { ...prev, stage: value } : prev)); - }; - - const setSandboxError = (message: string) => { - const value = String(message ?? "").trim() || "Sandbox failed to start"; - setSandboxCreateProgress((prev) => (prev ? { ...prev, error: value } : prev)); - }; - - const makeRunId = () => { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; - }; - let lastEngineReconnectAt = 0; - let reconnectingEngine = false; - - const [projectDir, setProjectDir] = createSignal(""); - const [workspaces, setWorkspaces] = createSignal([]); - const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal(""); - - const syncSelectedWorkspaceId = (id: string) => { - setSelectedWorkspaceId(id); - }; - - const pickSelectedWorkspaceId = ( - nextWorkspaces: WorkspaceInfo[], - preferredIds: Array = [], - fallbackList?: { selectedId?: string; activeId?: string | null } | null, - ) => { - for (const candidate of preferredIds) { - const id = candidate?.trim() ?? ""; - if (id && nextWorkspaces.some((workspace) => workspace.id === id)) { - return id; - } - } - - const responseId = resolveWorkspaceListSelectedId(fallbackList); - if (responseId && nextWorkspaces.some((workspace) => workspace.id === responseId)) { - return responseId; - } - - return nextWorkspaces[0]?.id ?? ""; - }; - - const applyServerLocalWorkspaces = (nextLocals: WorkspaceInfo[], nextActiveId: string | null | undefined) => { - const remotes = workspaces().filter((workspace) => workspace.workspaceType === "remote"); - const merged = [...nextLocals, ...remotes]; - setWorkspaces(merged); - - syncSelectedWorkspaceId( - pickSelectedWorkspaceId(merged, [selectedWorkspaceId()], { activeId: nextActiveId ?? null }), - ); - }; - - const [authorizedDirs, setAuthorizedDirs] = createSignal([]); - const [newAuthorizedDir, setNewAuthorizedDir] = createSignal(""); - - const [workspaceConfig, setWorkspaceConfig] = createSignal(null); - const [workspaceConfigLoaded, setWorkspaceConfigLoaded] = createSignal(false); - const [createWorkspaceOpen, setCreateWorkspaceOpen] = createSignal(false); - const [createRemoteWorkspaceOpen, setCreateRemoteWorkspaceOpen] = createSignal(false); - const [editRemoteWorkspaceOpen, setEditRemoteWorkspaceOpen] = createSignal(false); - const [editRemoteWorkspaceId, setEditRemoteWorkspaceId] = createSignal(null); - const [editRemoteWorkspaceError, setEditRemoteWorkspaceError] = createSignal(null); - const [renameWorkspaceOpen, setRenameWorkspaceOpen] = createSignal(false); - const [renameWorkspaceId, setRenameWorkspaceId] = createSignal(null); - const [renameWorkspaceName, setRenameWorkspaceName] = createSignal(""); - const [renameWorkspaceBusy, setRenameWorkspaceBusy] = createSignal(false); - const [connectingWorkspaceId, setConnectingWorkspaceId] = createSignal(null); - const [connectedWorkspaceId, setConnectedWorkspaceId] = createSignal(null); - const [runtimeWorkspaceId, setRuntimeWorkspaceId] = createSignal(null); - const [runtimeWorkspaceConfigById, setRuntimeWorkspaceConfigById] = createSignal< - Record - >({}); - const [workspaceConnectionStateById, setWorkspaceConnectionStateById] = createSignal< - Record - >({}); - const [blueprintSessionMaterializeBusyByWorkspaceId, setBlueprintSessionMaterializeBusyByWorkspaceId] = - createSignal>({}); - const [blueprintSessionMaterializeAttemptedByWorkspaceId, setBlueprintSessionMaterializeAttemptedByWorkspaceId] = - createSignal>({}); - const [exportingWorkspaceConfig, setExportingWorkspaceConfig] = createSignal(false); - const [importingWorkspaceConfig, setImportingWorkspaceConfig] = createSignal(false); - const selectedWorkspaceInfo = createMemo(() => workspaces().find((w) => w.id === selectedWorkspaceId()) ?? null); - const connectedWorkspaceInfo = createMemo(() => { - const id = connectedWorkspaceId()?.trim() ?? ""; - if (!id) return null; - return workspaces().find((workspace) => workspace.id === id) ?? null; - }); - - const selectedWorkspaceDisplay = createMemo(() => { - const ws = selectedWorkspaceInfo(); - if (!ws) { - return { - id: "", - name: "Worker", - path: "", - preset: "minimal", - workspaceType: "local", - remoteType: "opencode", - baseUrl: null, - directory: null, - displayName: null, - openworkHostUrl: null, - openworkWorkspaceId: null, - openworkWorkspaceName: null, - }; - } - const displayName = - ws.displayName?.trim() || - ws.openworkWorkspaceName?.trim() || - ws.name || - ws.openworkHostUrl || - ws.baseUrl || - ws.path || - "Worker"; - return { ...ws, name: displayName }; - }); - const normalizeRemoteType = (value?: WorkspaceInfo["remoteType"] | null) => - value === "openwork" ? "openwork" : "opencode"; - const isOpenworkRemote = (workspace: WorkspaceInfo | null) => - Boolean(workspace && workspace.workspaceType === "remote" && normalizeRemoteType(workspace.remoteType) === "openwork"); - const selectedWorkspacePath = createMemo(() => { - const ws = selectedWorkspaceInfo(); - if (!ws) return ""; - if (ws.workspaceType === "remote") return ws.directory?.trim() ?? ""; - return ws.path ?? ""; - }); - const selectedWorkspaceRoot = createMemo(() => selectedWorkspacePath().trim()); - const resolveWorkspaceRuntimeRoot = (workspace: WorkspaceInfo | null | undefined) => { - if (!workspace) return ""; - if (workspace.workspaceType === "remote") { - return workspace.directory?.trim() ?? workspace.path?.trim() ?? ""; - } - return workspace.path?.trim() ?? ""; - }; - const resolveCurrentRuntimeRoot = () => { - const connectedRoot = resolveWorkspaceRuntimeRoot(connectedWorkspaceInfo()); - if (connectedRoot) return connectedRoot; - const engineRoot = engine()?.projectDir?.trim() ?? ""; - if (engineRoot) return engineRoot; - return projectDir().trim(); - }; - const runtimeWorkspaceRoot = createMemo(() => resolveCurrentRuntimeRoot().trim()); - const workspaceRootForId = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return ""; - return resolveWorkspaceRuntimeRoot(workspaces().find((workspace) => workspace.id === id) ?? null); - }; - const runtimeWorkspaceConfig = createMemo(() => { - const id = runtimeWorkspaceId()?.trim() ?? ""; - if (!id) return null; - return runtimeWorkspaceConfigById()[id] ?? null; - }); - - const editRemoteWorkspaceDefaults = createMemo(() => { - const workspaceId = editRemoteWorkspaceId(); - if (!workspaceId) return null; - const workspace = workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace || workspace.workspaceType !== "remote") return null; - const openworkHostUrl = - normalizeRemoteType(workspace.remoteType) === "openwork" - ? buildOpenworkWorkspaceBaseUrl( - workspace.openworkHostUrl?.trim() ?? "", - workspace.openworkWorkspaceId, - ) || - workspace.openworkHostUrl?.trim() || - workspace.baseUrl?.trim() || - "" - : workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || ""; - return { - openworkHostUrl, - openworkToken: workspace.openworkToken ?? options.openworkServer.openworkServerSettings().token ?? "", - directory: workspace.directory ?? "", - displayName: workspace.displayName ?? "", - }; - }); - - const clearSelectedSessionSurface = () => { - options.setSelectedSessionId(null); - options.setMessages([]); - options.setTodos([]); - options.setPendingPermissions([]); - options.setSessionStatusById({}); - }; - - const resolveWorkspaceEntryId = (input: { - workspaceId?: string | null; - workspaceType?: WorkspaceInfo["workspaceType"]; - targetRoot?: string | null; - directory?: string | null; - }) => { - const explicit = input.workspaceId?.trim() ?? ""; - if (explicit && workspaces().some((workspace) => workspace.id === explicit)) { - return explicit; - } - - const scope = normalizeDirectoryPath(input.targetRoot ?? input.directory ?? ""); - if (!scope) return null; - - const match = workspaces().find((workspace) => { - const workspaceScope = normalizeDirectoryPath( - workspace.workspaceType === "remote" - ? workspace.directory?.trim() ?? workspace.path?.trim() ?? "" - : workspace.path?.trim() ?? "", - ); - if (!workspaceScope || workspaceScope !== scope) return false; - if (input.workspaceType && workspace.workspaceType !== input.workspaceType) return false; - return true; - }); - - return match?.id ?? null; - }; - - const applySelectedWorkspacePresentation = async (workspace: WorkspaceInfo) => { - syncSelectedWorkspaceId(workspace.id); - if (workspace.workspaceType === "remote") { - setProjectDir(workspace.directory?.trim() ?? ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - return; - } - - setProjectDir(workspace.path); - - if (isTauriRuntime()) { - setWorkspaceConfigLoaded(false); - try { - const cfg = await loadWorkspaceConfigFromOpenworkServer(workspace.path) - ?? await workspaceOpenworkRead({ workspacePath: workspace.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - if (roots.length) { - setAuthorizedDirs(roots); - } else { - setAuthorizedDirs([workspace.path]); - } - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([workspace.path]); - } - return; - } - - if (!authorizedDirs().includes(workspace.path)) { - const merged = authorizedDirs().length ? authorizedDirs().slice() : []; - if (!merged.includes(workspace.path)) merged.push(workspace.path); - setAuthorizedDirs(merged); - } - }; - - async function applyWorkspaceSelection(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((entry) => entry.id === id) ?? null; - if (!workspace) return false; - const changed = selectedWorkspaceId() !== id; - - await applySelectedWorkspacePresentation(workspace); - - if (changed) { - clearSelectedSessionSurface(); - } - - if (isTauriRuntime()) { - try { - await workspaceSetSelected(id); - } catch { - // ignore - } - } - - return true; - } - - async function selectWorkspace(workspaceId: string) { - return await applyWorkspaceSelection(workspaceId); - } - - async function switchWorkspace(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - const prevProjectDir = resolveCurrentRuntimeRoot(); - return await activateWorkspace(id, { prevProjectDir }); - } - - const updateWorkspaceConnectionState = ( - workspaceId: string, - next: Partial, - ) => { - const id = workspaceId.trim(); - if (!id) return; - setWorkspaceConnectionStateById((prev) => { - const current = prev[id] ?? { status: "idle", message: null, checkedAt: null }; - return { - ...prev, - [id]: { - ...current, - ...next, - checkedAt: Date.now(), - }, - }; - }); - }; - - const clearWorkspaceConnectionState = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return; - setWorkspaceConnectionStateById((prev) => { - if (!prev[id]) return prev; - const next = { ...prev }; - delete next[id]; - return next; - }); - }; - - createEffect(() => { - const ids = new Set(workspaces().map((workspace) => workspace.id)); - setWorkspaceConnectionStateById((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, state] of Object.entries(prev)) { - if (!ids.has(id)) { - changed = true; - continue; - } - next[id] = state; - } - return changed ? next : prev; - }); - }); - - createEffect(() => { - const client = options.openworkServer.openworkServerClient(); - const status = options.openworkServer.openworkServerStatus(); - const connectedWorkspace = connectedWorkspaceInfo(); - - if (!client || status !== "connected" || !connectedWorkspace) { - setRuntimeWorkspaceId(null); - return; - } - - const lookup = resolveRuntimeWorkspaceLookup(connectedWorkspace); - if (!lookup) { - setRuntimeWorkspaceId(null); - return; - } - - let cancelled = false; - void (async () => { - const resolved = await ensureRuntimeWorkspaceId(lookup); - if (cancelled) return; - if (!resolved) { - setRuntimeWorkspaceId(null); - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - const client = options.openworkServer.openworkServerClient(); - const status = options.openworkServer.openworkServerStatus(); - const workspaceId = runtimeWorkspaceId()?.trim() ?? ""; - - if (!client || status !== "connected" || !workspaceId) { - return; - } - - let cancelled = false; - void (async () => { - try { - await refreshRuntimeWorkspaceConfig(workspaceId); - } catch { - if (cancelled) return; - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - const workspaceId = (runtimeWorkspaceId() ?? "").trim(); - const client = options.openworkServer.openworkServerClient(); - const connected = options.openworkServer.openworkServerStatus() === "connected"; - const root = selectedWorkspaceRoot().trim(); - const config = runtimeWorkspaceConfig() ?? workspaceConfig(); - const templates = blueprintSessions(config); - const materialized = blueprintMaterializedSessions(config); - const currentSessions = options.sessions(); - const normalizedRoot = normalizeDirectoryPath(root); - const hasWorkspaceSessions = currentSessions.some((session) => { - const directory = typeof session.directory === "string" ? session.directory : ""; - return normalizeDirectoryPath(directory) === normalizedRoot; - }); - - if (!workspaceId || !client || !connected) return; - if (!root) return; - if (!options.sessionsLoaded()) return; - if (options.creatingSession()) return; - if (options.selectedSessionId()) return; - if (!templates.length) return; - if (materialized.length > 0) return; - if (hasWorkspaceSessions) return; - if (blueprintSessionMaterializeBusyByWorkspaceId()[workspaceId]) return; - if (blueprintSessionMaterializeAttemptedByWorkspaceId()[workspaceId]) return; - - setBlueprintSessionMaterializeBusyByWorkspaceId((current) => ({ - ...current, - [workspaceId]: true, - })); - - void (async () => { - try { - const result = await client.materializeBlueprintSessions(workspaceId); - const templateMessages = new Map( - templates.map((template) => [template.id?.trim(), (template.messages ?? []).filter((entry) => entry?.text?.trim())] as const), - ); - if (result.created.length > 0) { - options.setBlueprintSeedMessagesBySessionId((current) => { - const next = { ...current }; - result.created.forEach((entry) => { - const messages = templateMessages.get(entry.templateId?.trim()); - if (messages && messages.length > 0) { - next[entry.sessionId] = messages; - } - }); - return next; - }); - } - setBlueprintSessionMaterializeAttemptedByWorkspaceId((current) => ({ - ...current, - [workspaceId]: true, - })); - await refreshRuntimeWorkspaceConfig(workspaceId); - await options.loadSessions(root || undefined); - const pending = options.pendingInitialSessionSelection?.() ?? null; - const shouldDeferInitialOpen = Boolean(pending && pending.workspaceId === workspaceId); - if (result.openSessionId && !shouldDeferInitialOpen) { - options.setView("session", result.openSessionId); - await options.selectSession(result.openSessionId, { - skipHealthCheck: true, - source: "blueprint-open-session", - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } finally { - setBlueprintSessionMaterializeBusyByWorkspaceId((current) => { - const next = { ...current }; - delete next[workspaceId]; - return next; - }); - } - })(); - }); - - const resolveOpenworkHost = async (input: { - hostUrl: string; - token?: string | null; - workspaceId?: string | null; - directoryHint?: string | null; - }) => { - let normalizedHostUrl = normalizeOpenworkServerUrl(input.hostUrl) ?? ""; - if (!normalizedHostUrl) { - return { kind: "fallback" as const }; - } - - let inferredWorkspaceId: string | null = null; - try { - const url = new URL(normalizedHostUrl); - const segments = url.pathname.split("/").filter(Boolean); - const last = segments[segments.length - 1] ?? ""; - const prev = segments[segments.length - 2] ?? ""; - const alreadyMounted = prev === "w" && Boolean(last); - if (alreadyMounted) { - inferredWorkspaceId = decodeURIComponent(last); - const baseSegments = segments.slice(0, -2); - url.pathname = `/${baseSegments.join("/")}`; - normalizedHostUrl = url.toString().replace(/\/+$/, ""); - } - } catch { - // ignore - } - - const requestedWorkspaceId = (input.workspaceId?.trim() || inferredWorkspaceId || "").trim(); - const workspaceBaseUrl = buildOpenworkWorkspaceBaseUrl(normalizedHostUrl, requestedWorkspaceId) ?? normalizedHostUrl; - - const client = createOpenworkServerClient({ baseUrl: workspaceBaseUrl, token: input.token ?? undefined }); - - const trimmedToken = input.token?.trim() ?? ""; - - try { - const health = await client.health(); - if (!health?.ok) { - return { kind: "fallback" as const }; - } - } catch (error) { - if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) { - if (!trimmedToken) { - throw new Error("Access token required for OpenWork server."); - } - throw new Error("OpenWork server rejected the access token."); - } - return { kind: "fallback" as const }; - } - - if (!trimmedToken) { - throw new Error("Access token required for OpenWork server."); - } - - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const hint = normalizeDirectoryPath(input.directoryHint ?? ""); - const selectByHint = (entry: OpenworkWorkspaceInfo) => { - if (!hint) return false; - const entryPath = normalizeDirectoryPath( - (entry.opencode?.directory as string | undefined) ?? (entry.path as string | undefined) ?? "", - ); - return Boolean(entryPath && entryPath === hint); - }; - const selectById = (entry: OpenworkWorkspaceInfo) => Boolean(requestedWorkspaceId && entry?.id === requestedWorkspaceId); - - const workspaceById = requestedWorkspaceId - ? (items.find((item) => item?.id && selectById(item as any)) as OpenworkWorkspaceInfo | undefined) - : undefined; - if (requestedWorkspaceId && !workspaceById) { - throw new Error("OpenWork worker not found on that host."); - } - - const workspaceByHint = hint - ? (items.find((item) => item?.id && selectByHint(item as any)) as OpenworkWorkspaceInfo | undefined) - : undefined; - - const workspace = (workspaceById ?? workspaceByHint ?? items[0]) as OpenworkWorkspaceInfo | undefined; - if (!workspace?.id) { - throw new Error("OpenWork server did not return a worker."); - } - const opencodeUpstreamBaseUrl = workspace.opencode?.baseUrl?.trim() ?? workspace.baseUrl?.trim() ?? ""; - if (!opencodeUpstreamBaseUrl) { - throw new Error("OpenWork server did not provide an OpenCode URL."); - } - - const workspaceScopedBaseUrl = - buildOpenworkWorkspaceBaseUrl(normalizedHostUrl, workspace.id) ?? workspaceBaseUrl; - const opencodeBaseUrl = `${workspaceScopedBaseUrl.replace(/\/+$/, "")}/opencode`; - const opencodeAuth: OpencodeAuth | undefined = trimmedToken - ? { token: trimmedToken, mode: "openwork" } - : undefined; - - return { - kind: "openwork" as const, - hostUrl: normalizedHostUrl, - workspace, - opencodeBaseUrl, - directory: workspace.opencode?.directory?.trim() ?? workspace.directory?.trim() ?? "", - auth: opencodeAuth, - }; - }; - - const resolveEngineRuntime = () => options.engineRuntime?.() ?? "openwork-orchestrator"; - - const resolveWorkspacePaths = () => { - const active = selectedWorkspacePath().trim(); - const locals = workspaces() - .filter((ws) => ws.workspaceType === "local") - .map((ws) => ws.path) - .filter((path): path is string => Boolean(path && path.trim())) - .map((path) => path.trim()); - const resolved: string[] = []; - if (active) resolved.push(active); - for (const path of locals) { - if (!resolved.includes(path)) resolved.push(path); - } - return resolved; - }; - - const resolveConnectedOpenworkServer = () => { - const client = options.openworkServer.openworkServerClient(); - if (!client) return null; - if (options.openworkServer.openworkServerStatus() !== "connected") return null; - return client; - }; - - const resolveLocalOpenworkServer = async () => { - if (!isTauriRuntime()) return null; - try { - return (await options.openworkServer.ensureLocalOpenworkServerClient()) ?? null; - } catch (error) { - wsDebug("openwork:local-host:unavailable", { - message: error instanceof Error ? error.message : safeStringify(error), - }); - return null; - } - }; - - const resolveActiveOpenworkWorkspace = () => { - const client = resolveConnectedOpenworkServer(); - const workspaceId = runtimeWorkspaceId()?.trim() ?? ""; - if (!client || !workspaceId) return null; - return { client, workspaceId }; - }; - - const resolveRuntimeWorkspaceLookup = (workspace: WorkspaceInfo | null): RuntimeWorkspaceLookup | null => { - if (!workspace) return null; - if (workspace.workspaceType === "remote") { - if (normalizeRemoteType(workspace.remoteType) !== "openwork") return null; - return { - workspaceId: - workspace.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || - options.openworkEnvWorkspaceId?.trim() || - null, - directoryHint: workspace.directory?.trim() ?? workspace.path?.trim() ?? "", - }; - } - - return { - localRoot: workspace.path?.trim() ?? "", - }; - }; - - const resolveRuntimeWorkspaceIdFromResponse = ( - items: OpenworkWorkspaceInfo[], - activeId?: string | null, - target?: RuntimeWorkspaceLookup, - ) => { - const explicitId = target?.workspaceId?.trim() ?? ""; - if (explicitId) { - return items.find((entry) => entry?.id === explicitId)?.id ?? null; - } - - const hint = normalizeDirectoryPath(target?.directoryHint ?? target?.localRoot ?? ""); - if (hint) { - const match = items.find((entry) => { - const entryPath = normalizeDirectoryPath( - (entry.opencode?.directory as string | undefined) ?? - (entry.directory as string | undefined) ?? - (entry.path as string | undefined) ?? - "", - ); - return Boolean(entryPath && entryPath === hint); - }); - if (match?.id) return match.id; - if (target?.strictMatch) return null; - } - - const normalizedActiveId = activeId?.trim() ?? ""; - if (normalizedActiveId && items.some((entry) => entry?.id === normalizedActiveId)) { - return normalizedActiveId; - } - - return items[0]?.id ?? null; - }; - - async function ensureRuntimeWorkspaceId(target?: RuntimeWorkspaceLookup): Promise { - const explicitId = target?.workspaceId?.trim() ?? ""; - const pathHint = normalizeDirectoryPath(target?.directoryHint ?? target?.localRoot ?? ""); - const currentId = runtimeWorkspaceId()?.trim() ?? ""; - - if (!explicitId && !pathHint && currentId) { - return currentId; - } - - const client = resolveConnectedOpenworkServer(); - if (!client) { - setRuntimeWorkspaceId(null); - return null; - } - - try { - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const nextId = resolveRuntimeWorkspaceIdFromResponse(items, response.activeId, target); - setRuntimeWorkspaceId(nextId); - return nextId; - } catch (error) { - wsDebug("runtime-workspace:resolve:error", { - message: error instanceof Error ? error.message : safeStringify(error), - }); - setRuntimeWorkspaceId(null); - return null; - } - } - - const storeRuntimeWorkspaceConfig = (workspaceId: string, config: WorkspaceOpenworkConfig | null) => { - const id = workspaceId.trim(); - if (!id) return; - setRuntimeWorkspaceConfigById((current) => { - if (current[id] === config) return current; - return { - ...current, - [id]: config, - }; - }); - }; - - async function refreshRuntimeWorkspaceConfig(workspaceIdOverride?: string | null): Promise { - const client = resolveConnectedOpenworkServer(); - const workspaceId = (workspaceIdOverride ?? runtimeWorkspaceId() ?? "").trim(); - if (!client || !workspaceId) return null; - - if (options.openworkServer.openworkServerCapabilities()?.config?.read === false) { - storeRuntimeWorkspaceConfig(workspaceId, null); - return null; - } - - const workspace = connectedWorkspaceInfo() ?? selectedWorkspaceInfo(); - - try { - const config = await client.getConfig(workspaceId); - const normalized = normalizeWorkspaceOpenworkConfig( - config.openwork as WorkspaceOpenworkConfig | null | undefined, - workspace?.preset ?? "starter", - ); - const next = normalized.blueprint - ? normalized - : { - ...normalized, - blueprint: buildDefaultWorkspaceBlueprint( - normalized.workspace?.preset ?? workspace?.preset ?? "starter", - ), - }; - storeRuntimeWorkspaceConfig(workspaceId, next); - return next; - } catch (error) { - storeRuntimeWorkspaceConfig(workspaceId, null); - throw error; - } - } - - const findOpenworkWorkspaceByPathWithClient = async ( - client: OpenworkServerClient, - workspacePath: string, - ) => { - const targetPath = normalizeDirectoryPath(workspacePath); - if (!targetPath) return null; - - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath); - if (!match?.id) return null; - return { client, workspaceId: match.id, response }; - }; - - const findOpenworkWorkspaceByPath = async (workspacePath: string) => { - const client = resolveConnectedOpenworkServer(); - if (!client) return null; - return findOpenworkWorkspaceByPathWithClient(client, workspacePath); - }; - - const listWorkspaceSessions = async (workspacePath: string) => { - const client = options.client(); - if (!client) return []; - - const root = normalizeDirectoryPath(workspacePath); - const queryDirectory = resolveScopedClientDirectory({ - targetRoot: workspacePath, - workspaceType: "local", - }) || undefined; - const list = unwrap(await client.session.list({ directory: queryDirectory, roots: true })); - return root - ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) - : list; - }; - - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - const ensureBackendWorkspaceReady = async ( - workspacePath: string, - name: string, - preset: WorkspacePreset, - input?: { timeoutMs?: number; pollMs?: number }, - ) => { - const timeoutMs = input?.timeoutMs ?? 15_000; - const pollMs = input?.pollMs ?? 500; - const start = Date.now(); - const localServer = await resolveLocalOpenworkServer(); - if (!localServer) { - throw new Error("Local OpenWork server is unavailable after opening the workspace."); - } - - let createAttempted = false; - while (Date.now() - start < timeoutMs) { - const resolved = await findOpenworkWorkspaceByPathWithClient(localServer, workspacePath); - if (resolved?.workspaceId) { - return resolved; - } - - if (!createAttempted) { - createAttempted = true; - await localServer.createLocalWorkspace({ folderPath: workspacePath, name, preset }); - } - - await sleep(pollMs); - } - - throw new Error("Local OpenWork server never registered the created workspace."); - }; - - const materializeStarterSessions = async ( - workspacePath: string, - name: string, - preset: WorkspacePreset, - ) => { - if (preset !== "starter") return null; - const localWorkspace = await ensureBackendWorkspaceReady(workspacePath, name, preset); - return await localWorkspace.client.materializeBlueprintSessions(localWorkspace.workspaceId); - }; - - const waitForWorkspaceSessionsReady = async ( - workspacePath: string, - input?: { timeoutMs?: number; pollMs?: number }, - ) => { - const timeoutMs = input?.timeoutMs ?? 30_000; - const pollMs = input?.pollMs ?? 500; - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - try { - const sessions = await listWorkspaceSessions(workspacePath); - if (sessions.length > 0) { - await options.loadSessions(workspacePath); - return true; - } - } catch { - // keep polling while the local engine/session index settles - } - - await sleep(pollMs); - } - - try { - await options.loadSessions(workspacePath); - return (await listWorkspaceSessions(workspacePath)).length > 0; - } catch { - return false; - } - }; - - const loadWorkspaceConfigFromOpenworkServer = async (workspacePath: string): Promise => { - const resolved = await findOpenworkWorkspaceByPath(workspacePath); - if (!resolved) return null; - const config = await resolved.client.getConfig(resolved.workspaceId); - return (config.openwork as WorkspaceOpenworkConfig | null | undefined) ?? null; - }; - - const persistWorkspaceConfigToOpenworkServer = async (config: WorkspaceOpenworkConfig): Promise => { - const active = resolveActiveOpenworkWorkspace(); - if (!active) return false; - await active.client.patchConfig(active.workspaceId, { openwork: config as Record }); - return true; - }; - - const activateOpenworkHostWorkspace = async (workspacePath: string) => { - const resolved = await findOpenworkWorkspaceByPath(workspacePath); - if (!resolved) return; - try { - if (resolved.response.activeId === resolved.workspaceId) return; - await resolved.client.activateWorkspace(resolved.workspaceId); - } catch { - // ignore - } - }; - - async function testWorkspaceConnection(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace) return false; - - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - - if (workspace.workspaceType !== "remote") { - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } - - const remoteType = normalizeRemoteType(workspace.remoteType); - - if (remoteType === "openwork") { - const hostUrl = - workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || workspace.path?.trim() || ""; - if (!hostUrl) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server URL is required.", - }); - return false; - } - - const token = workspace.openworkToken?.trim() || options.openworkServer.openworkServerSettings().token || undefined; - try { - const resolved = await resolveOpenworkHost({ - hostUrl, - token, - workspaceId: workspace.openworkWorkspaceId ?? null, - }); - if (resolved.kind !== "openwork") { - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server unavailable. Check the URL and token.", - }); - return false; - } - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - } - - const baseUrl = workspace.baseUrl?.trim() || ""; - if (!baseUrl) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Remote base URL is required.", - }); - return false; - } - - try { - const client = createClient(baseUrl, workspace.directory?.trim() || undefined); - await waitForHealthy(client, { timeoutMs: 8_000 }); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - } - - async function refreshEngine() { - if (!isTauriRuntime()) return; - - try { - const info = await engineInfo(); - setEngine(info); - - const connectedWorkspace = connectedWorkspaceInfo(); - const syncLocalState = connectedWorkspace?.workspaceType !== "remote"; - - const username = info.opencodeUsername?.trim() ?? ""; - const password = info.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : null; - setEngineAuth(auth); - - if (info.projectDir && syncLocalState) { - setProjectDir(info.projectDir); - } - if (info.baseUrl && syncLocalState) { - options.setBaseUrl(info.baseUrl); - } - - if ( - syncLocalState && - info.running && - info.baseUrl && - !options.client() && - !reconnectingEngine - ) { - const now = Date.now(); - if (now - lastEngineReconnectAt > 10_000) { - const reconnectRoot = - (connectedWorkspace?.workspaceType === "local" - ? connectedWorkspace.path?.trim() - : connectedWorkspace?.directory?.trim()) || - info.projectDir?.trim() || - ""; - lastEngineReconnectAt = now; - reconnectingEngine = true; - connectToServer( - info.baseUrl, - reconnectRoot || undefined, - { workspaceType: "local", targetRoot: reconnectRoot, reason: "engine-refresh" }, - auth ?? undefined, - { quiet: true, navigate: false }, - ) - .catch(() => undefined) - .finally(() => { - reconnectingEngine = false; - }); - } - } - } catch { - // ignore - } - } - - async function refreshEngineDoctor() { - if (!isTauriRuntime()) return; - - try { - const source = options.engineSource(); - const result = await engineDoctor({ - preferSidecar: source === "sidecar", - opencodeBinPath: source === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - }); - setEngineDoctorResult(result); - setEngineDoctorCheckedAt(Date.now()); - } catch (e) { - setEngineDoctorResult(null); - setEngineDoctorCheckedAt(Date.now()); - setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e)); - } - } - - async function refreshSandboxDoctor() { - if (!isTauriRuntime()) { - setSandboxDoctorResult(null); - setSandboxDoctorCheckedAt(Date.now()); - return null; - } - if (sandboxDoctorBusy()) return sandboxDoctorResult(); - setSandboxDoctorBusy(true); - try { - const result = await sandboxDoctor(); - setSandboxDoctorResult(result); - return result; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - const fallback: SandboxDoctorResult = { - installed: false, - daemonRunning: false, - permissionOk: false, - ready: false, - error: message, - }; - setSandboxDoctorResult(fallback); - return fallback; - } finally { - setSandboxDoctorCheckedAt(Date.now()); - setSandboxDoctorBusy(false); - } - } - - async function activateWorkspace( - workspaceId: string, - hint?: { prevProjectDir?: string }, - ) { - const id = workspaceId.trim(); - if (!id) return false; - - const capturedPrevDir = hint?.prevProjectDir?.trim() || resolveCurrentRuntimeRoot(); - - const next = workspaces().find((w) => w.id === id) ?? null; - if (!next) return false; - if (connectedWorkspaceId() === id && options.client()) { - if (selectedWorkspaceId() !== id) { - await applyWorkspaceSelection(id); - } - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - wsDebug("activate:noop-already-connected", { id }); - return true; - } - if (selectedWorkspaceId() !== id) { - await applyWorkspaceSelection(id); - } - const isRemote = next.workspaceType === "remote"; - console.log("[workspace] activate", { id: next.id, type: next.workspaceType }); - const activateStart = Date.now(); - wsDebug("activate:start", { - id: next.id, - type: next.workspaceType, - remoteType: next.remoteType ?? null, - prevActiveId: selectedWorkspaceId(), - prevProjectDir: capturedPrevDir, - currentProjectDir: projectDir(), - startupPref: options.startupPreference(), - hasClient: Boolean(options.client()), - }); - - const remoteType = isRemote ? normalizeRemoteType(next.remoteType) : "opencode"; - const baseUrl = isRemote ? next.baseUrl?.trim() ?? "" : ""; - - setConnectingWorkspaceId(id); - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - - // Allow the UI to paint the "switching" state before we kick off work that can - // trigger expensive reactive updates (e.g. sidebar session refreshes). - if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { - await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); - } - - try { - if (isRemote) { - options.setStartupPreference("server"); - - if (remoteType === "openwork") { - const hostUrl = next.openworkHostUrl?.trim() ?? ""; - if (!hostUrl) { - options.setError("OpenWork server URL is required."); - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server URL is required.", - }); - return false; - } - - const workspaceToken = next.openworkToken?.trim() ?? ""; - const fallbackToken = options.openworkServer.openworkServerSettings().token ?? ""; - const token = workspaceToken || fallbackToken; - - const currentSettings = options.openworkServer.openworkServerSettings(); - if ( - currentSettings.urlOverride?.trim() !== hostUrl || - (token && currentSettings.token?.trim() !== token) - ) { - options.openworkServer.updateOpenworkServerSettings({ - ...currentSettings, - urlOverride: hostUrl, - token: token || currentSettings.token, - }); - } - - let resolvedBaseUrl = baseUrl; - let resolvedDirectory = next.directory?.trim() ?? ""; - let workspaceInfo: OpenworkWorkspaceInfo | null = null; - let resolvedAuth: OpencodeAuth | undefined = token ? { token, mode: "openwork" } : undefined; - - const finishRemoteWorkspaceActivation = async (shouldPersistResolved: boolean) => { - if (shouldPersistResolved) { - if (isTauriRuntime()) { - try { - const ws = await workspaceUpdateRemote({ - workspaceId: next.id, - remoteType: "openwork", - baseUrl: resolvedBaseUrl, - directory: resolvedDirectory || null, - openworkHostUrl: hostUrl, - openworkToken: token ? token : null, - openworkWorkspaceId: workspaceInfo?.id ?? next.openworkWorkspaceId ?? null, - openworkWorkspaceName: workspaceInfo?.name ?? next.openworkWorkspaceName ?? null, - }); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [id, selectedWorkspaceId()], ws)); - } catch { - // ignore - } - } else { - const resolvedToken = token.trim(); - setWorkspaces((prev) => - prev.map((ws) => { - if (ws.id !== next.id) return ws; - return { - ...ws, - remoteType: "openwork", - baseUrl: resolvedBaseUrl.replace(/\/+$/, ""), - directory: resolvedDirectory || null, - openworkHostUrl: hostUrl, - openworkToken: resolvedToken || null, - openworkWorkspaceId: workspaceInfo?.id ?? ws.openworkWorkspaceId ?? null, - openworkWorkspaceName: workspaceInfo?.name ?? ws.openworkWorkspaceName ?? null, - }; - }), - ); - } - } - - syncSelectedWorkspaceId(id); - setProjectDir(resolvedDirectory || ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - - if (isTauriRuntime()) { - try { - await workspaceSetRuntimeActive(id); - } catch { - // ignore - } - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - }; - - if (resolvedBaseUrl) { - const cachedOk = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: resolvedDirectory ?? "", - reason: "workspace-switch-openwork-cached", - }, - resolvedAuth, - { navigate: false, quiet: true }, - ); - - if (cachedOk) { - wsDebug("activate:remote:cached", { id, hostUrl, resolvedBaseUrl, resolvedDirectory }); - return await finishRemoteWorkspaceActivation(false); - } - } - - try { - const resolved = await resolveOpenworkHost({ - hostUrl, - token, - workspaceId: next.openworkWorkspaceId ?? null, - directoryHint: next.directory ?? null, - }); - if (resolved.kind !== "openwork") { - options.setError("OpenWork server unavailable. Check the URL and token."); - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server unavailable. Check the URL and token.", - }); - return false; - } - - resolvedBaseUrl = resolved.opencodeBaseUrl; - resolvedDirectory = resolved.directory; - workspaceInfo = resolved.workspace; - resolvedAuth = resolved.auth; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - - if (!resolvedBaseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - updateWorkspaceConnectionState(id, { - status: "error", - message: "Remote base URL is required.", - }); - return false; - } - - const ok = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: resolvedDirectory ?? "", - reason: "workspace-switch-openwork", - }, - resolvedAuth, - { navigate: false }, - ); - - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to connect to worker.", - }); - return false; - } - return await finishRemoteWorkspaceActivation(true); - } - - if (!baseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - updateWorkspaceConnectionState(id, { - status: "error", - message: "Remote base URL is required.", - }); - return false; - } - - const ok = await connectToServer( - baseUrl, - next.directory?.trim() || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: next.directory?.trim() ?? "", - reason: "workspace-switch-direct", - }, - undefined, - { navigate: false }, - ); - - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to connect to worker.", - }); - return false; - } - - syncSelectedWorkspaceId(id); - setProjectDir(next.directory?.trim() ?? ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - - if (isTauriRuntime()) { - try { - await workspaceSetRuntimeActive(id); - } catch { - // ignore - } - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - wsDebug("activate:remote:done", { id, ms: Date.now() - activateStart }); - return true; - } - - const wasLocalConnection = options.startupPreference() === "local" && options.client(); - options.setStartupPreference("local"); - const nextRoot = isRemote ? next.directory?.trim() ?? "" : next.path; - // Use the pre-switch snapshot instead of projectDir() which may already - // point at the new workspace due to applySelectedWorkspacePresentation. - const oldWorkspacePath = capturedPrevDir; - const workspaceChanged = oldWorkspacePath !== nextRoot; - - wsDebug("activate:local:prep", { - id, - nextRoot, - workspaceChanged, - wasLocalConnection: Boolean(wasLocalConnection), - prevProjectDir: oldWorkspacePath, - }); - - syncSelectedWorkspaceId(id); - setProjectDir(nextRoot); - - if (isTauriRuntime()) { - if (isRemote) { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - } else { - setWorkspaceConfigLoaded(false); - try { - const cfg = await loadWorkspaceConfigFromOpenworkServer(next.path) - ?? await workspaceOpenworkRead({ workspacePath: next.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - if (roots.length) { - setAuthorizedDirs(roots); - } else { - setAuthorizedDirs([next.path]); - } - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([next.path]); - } - } - - try { - if (!isRemote) { - await activateOpenworkHostWorkspace(next.path); - } - await workspaceSetRuntimeActive(id); - } catch { - // ignore - } - } else if (!isRemote) { - if (!authorizedDirs().includes(next.path)) { - const merged = authorizedDirs().length ? authorizedDirs().slice() : []; - if (!merged.includes(next.path)) merged.push(next.path); - setAuthorizedDirs(merged); - } - } else { - setAuthorizedDirs([]); - } - - // If we were previously connected to a remote engine, switching back to a local workspace - // requires starting (or reconnecting) the local host engine. - // - // Without this, we end up keeping the remote client while `startupPreference` flips to - // "local", and subsequent session/file actions behave inconsistently. - if (!isRemote && options.client() && !wasLocalConnection) { - wsDebug("activate:remote->local:reconnect", { - id, - nextPath: next.path, - engine: engine()?.baseUrl ?? null, - engineRunning: Boolean(engine()?.running), - }); - options.setSelectedSessionId(null); - options.setMessages([]); - options.setTodos([]); - options.setPendingPermissions([]); - options.setSessionStatusById({}); - - // If a local host engine is already running (common when bouncing between remote/local), - // reuse it instead of restarting to keep switching snappy. - let connectedToLocalHost = false; - const existingEngine = engine(); - const runtime = existingEngine?.runtime ?? resolveEngineRuntime(); - const canReuseHost = - isTauriRuntime() && - Boolean(existingEngine?.running && existingEngine.baseUrl); - - wsDebug("activate:remote->local:hostReuse", { - canReuseHost, - runtime, - existingEngineBaseUrl: existingEngine?.baseUrl ?? null, - existingEngineProjectDir: existingEngine?.projectDir ?? null, - }); - - if (canReuseHost && runtime === "openwork-orchestrator") { - try { - const reuseStart = Date.now(); - await orchestratorWorkspaceActivate({ - workspacePath: next.path, - name: next.displayName?.trim() || next.name?.trim() || null, - }); - await activateOpenworkHostWorkspace(next.path); - - const nextInfo = await engineInfo(); - setEngine(nextInfo); - - const username = nextInfo.opencodeUsername?.trim() ?? ""; - const password = nextInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (nextInfo.baseUrl) { - connectedToLocalHost = await connectToServer( - nextInfo.baseUrl, - next.path, - { workspaceType: "local", targetRoot: next.path, reason: "workspace-attach-local" }, - auth, - { navigate: false }, - ); - } - wsDebug("activate:remote->local:reuseHost:done", { - ok: connectedToLocalHost, - ms: Date.now() - reuseStart, - }); - } catch { - connectedToLocalHost = false; - wsDebug("activate:remote->local:reuseHost:error"); - } - } - - if (!connectedToLocalHost) { - const startHostAt = Date.now(); - const ok = await startHost({ workspacePath: next.path, navigate: false }); - wsDebug("activate:remote->local:startHost:done", { ok, ms: Date.now() - startHostAt }); - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to start local engine.", - }); - return false; - } - } - } - - // When running locally, restart the engine when workspace changes - if (!isRemote && wasLocalConnection && workspaceChanged) { - wsDebug("activate:local->local:restartEngine", { id, nextPath: next.path }); - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.restarting_engine"); - options.setBusyStartedAt(Date.now()); - - try { - const runtime = resolveEngineRuntime(); - if (runtime === "openwork-orchestrator") { - await orchestratorWorkspaceActivate({ - workspacePath: next.path, - name: next.displayName?.trim() || next.name?.trim() || null, - }); - await activateOpenworkHostWorkspace(next.path); - - const newInfo = await engineInfo(); - setEngine(newInfo); - - const username = newInfo.opencodeUsername?.trim() ?? ""; - const password = newInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (newInfo.baseUrl) { - const ok = await connectToServer( - newInfo.baseUrl, - next.path, - { workspaceType: "local", targetRoot: next.path, reason: "workspace-orchestrator-switch" }, - auth, - { navigate: false }, - ); - if (!ok) { - options.setError("Failed to reconnect after worker switch"); - } - } - } else { - // Stop the current engine - const info = await engineStop(); - setEngine(info); - - // Start engine with new workspace directory - const newInfo = await engineStart(next.path, { - preferSidecar: options.engineSource() === "sidecar", - opencodeBinPath: - options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - opencodeEnableExa: options.opencodeEnableExa?.() ?? false, - openworkRemoteAccess: options.openworkServer.openworkServerSettings().remoteAccessEnabled === true, - runtime, - workspacePaths: resolveWorkspacePaths(), - }); - setEngine(newInfo); - - const username = newInfo.opencodeUsername?.trim() ?? ""; - const password = newInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - // Reconnect to server - if (newInfo.baseUrl) { - const ok = await connectToServer( - newInfo.baseUrl, - next.path, - { workspaceType: "local", targetRoot: next.path, reason: "workspace-restart" }, - auth, - { navigate: false }, - ); - if (!ok) { - options.setError("Failed to reconnect after worker switch"); - } - } - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - wsDebug("activate:local:done", { id, ms: Date.now() - activateStart }); - return true; - } finally { - setConnectingWorkspaceId(null); - wsDebug("activate:finally", { id, ms: Date.now() - activateStart }); - } - } - - async function connectToServer( - nextBaseUrl: string, - directory?: string, - context?: { - workspaceId?: string; - workspaceType?: WorkspaceInfo["workspaceType"]; - targetRoot?: string; - reason?: string; - }, - auth?: OpencodeAuth, - connectOptions?: { quiet?: boolean; navigate?: boolean }, - ) { - const requestKey = connectRequestKey(nextBaseUrl, directory, context, auth, connectOptions); - const existing = connectInFlightByKey.get(requestKey); - if (existing) { - wsDebug("connect:dedupe", { - baseUrl: nextBaseUrl, - directory: directory ?? null, - reason: context?.reason ?? null, - workspaceType: context?.workspaceType ?? null, - }); - return existing; - } - - const run = (async () => { - console.log("[workspace] connect", { - baseUrl: nextBaseUrl, - directory: directory ?? null, - workspaceType: context?.workspaceType ?? null, - }); - const connectStart = Date.now(); - wsDebug("connect:start", { - baseUrl: nextBaseUrl, - directory: directory ?? null, - directoryScope: describeDirectoryScope(directory), - reason: context?.reason ?? null, - workspaceType: context?.workspaceType ?? null, - targetRoot: context?.targetRoot ?? null, - targetRootScope: describeDirectoryScope(context?.targetRoot), - workspaceId: context?.workspaceId ?? null, - selectedWorkspaceId: selectedWorkspaceId() || null, - selectedWorkspaceRoot: selectedWorkspaceRoot().trim() || null, - activeWorkspaceScope: describeDirectoryScope(selectedWorkspaceRoot().trim()), - projectDir: projectDir().trim() || null, - clientDirectory: options.clientDirectory().trim() || null, - healthTimeoutMs: resolveConnectHealthTimeoutMs(context?.reason), - quiet: connectOptions?.quiet ?? false, - navigate: connectOptions?.navigate ?? true, - authMode: auth && "mode" in auth ? (auth as any).mode : auth ? "basic" : "none", - }); - const quiet = connectOptions?.quiet ?? false; - const navigate = connectOptions?.navigate ?? true; - options.setError(null); - if (!quiet) { - options.setBusy(true); - options.setBusyLabel("status.connecting"); - options.setBusyStartedAt(Date.now()); - } - options.setSseConnected(false); - - const connectMeta: OpencodeConnectStatus = { - at: Date.now(), - baseUrl: nextBaseUrl, - directory: directory ?? null, - reason: context?.reason ?? null, - status: "connecting", - error: null, - }; - options.setOpencodeConnectStatus?.(connectMeta); - - const connectMetrics: NonNullable = {}; - - try { - let resolvedDirectory = resolveScopedClientDirectory({ - directory, - targetRoot: context?.targetRoot, - workspaceType: context?.workspaceType ?? "local", - }); - let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined, auth); - const healthTimeoutMs = resolveConnectHealthTimeoutMs(context?.reason); - const health = await waitForHealthy(nextClient, { timeoutMs: healthTimeoutMs }); - connectMetrics.healthyMs = Date.now() - connectStart; - wsDebug("connect:healthy", { - ms: Date.now() - connectStart, - version: health.version, - timeoutMs: healthTimeoutMs, - resolvedDirectory: resolvedDirectory || null, - resolvedDirectoryScope: describeDirectoryScope(resolvedDirectory), - }); - - if (context?.workspaceType === "remote" && !resolvedDirectory) { - try { - const pathInfo = unwrap(await nextClient.path.get()); - const discovered = toSessionTransportDirectory(pathInfo.directory); - if (discovered) { - resolvedDirectory = discovered; - console.log("[workspace] remote directory resolved", resolvedDirectory); - if (isTauriRuntime() && context.workspaceId) { - const updated = await workspaceUpdateRemote({ - workspaceId: context.workspaceId, - directory: resolvedDirectory, - }); - setWorkspaces(updated.workspaces); - syncSelectedWorkspaceId( - pickSelectedWorkspaceId(updated.workspaces, [context.workspaceId, selectedWorkspaceId()], updated), - ); - } - setProjectDir(resolvedDirectory); - nextClient = createClient(nextBaseUrl, resolvedDirectory, auth); - } - } catch (error) { - console.log("[workspace] remote directory lookup failed", error); - } - } - - options.setClient(nextClient); - options.setConnectedVersion(health.version); - options.setBaseUrl(nextBaseUrl); - options.setClientDirectory(resolvedDirectory); - setConnectedWorkspaceId( - resolveWorkspaceEntryId({ - workspaceId: context?.workspaceId ?? null, - workspaceType: context?.workspaceType, - targetRoot: context?.targetRoot ?? resolvedDirectory, - directory: resolvedDirectory, - }), - ); - - const providersPromise = (async () => { - const providersAt = Date.now(); - wsDebug("connect:providers:start", { baseUrl: nextBaseUrl }); - let disabledProviders: string[] = []; - try { - const config = unwrap(await nextClient.config.get()); - disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; - } catch { - // ignore config read failures and continue with provider discovery - } - try { - const providerList = unwrap(await nextClient.provider.list()); - wsDebug("connect:providers:done", { - ms: Date.now() - providersAt, - source: "provider.list", - available: providerList.all?.length ?? 0, - connected: providerList.connected?.length ?? 0, - }); - const next = filterProviderList(providerList, disabledProviders); - return { - providers: next.all, - defaults: next.default, - connectedIds: next.connected, - }; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - wsDebug("connect:providers:fallback", { ms: Date.now() - providersAt, message }); - try { - const cfg = unwrap(await nextClient.config.providers()); - const mapped = mapConfigProvidersToList(cfg.providers); - wsDebug("connect:providers:done", { - ms: Date.now() - providersAt, - source: "config.providers", - available: mapped.length, - connected: 0, - }); - const next = filterProviderList( - { all: mapped, connected: [], default: cfg.default }, - disabledProviders, - ); - return { - providers: next.all, - defaults: next.default, - connectedIds: next.connected, - }; - } catch (fallbackError) { - const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : safeStringify(fallbackError); - wsDebug("connect:providers:error", { ms: Date.now() - providersAt, message: fallbackMessage }); - return { - providers: [], - defaults: {}, - connectedIds: [], - }; - } - } finally { - connectMetrics.providersMs = Date.now() - providersAt; - } - })(); - - const targetRoot = context?.targetRoot ?? (resolvedDirectory || selectedWorkspaceRoot().trim()); - wsDebug("connect:loadSessions", { - targetRoot, - targetRootScope: describeDirectoryScope(targetRoot), - resolvedDirectory, - resolvedDirectoryScope: describeDirectoryScope(resolvedDirectory), - selectedWorkspaceId: selectedWorkspaceId() || null, - selectedWorkspaceRoot: selectedWorkspaceRoot().trim() || null, - }); - const sessionsAt = Date.now(); - await options.loadSessions(targetRoot); - connectMetrics.loadSessionsMs = Date.now() - sessionsAt; - wsDebug("connect:loadSessions:done", { ms: Date.now() - sessionsAt }); - options.onBootPhaseChange?.("sessionIndexReady", { - source: context?.reason ?? "connectToServer", - targetRoot: targetRoot || null, - }); - options.onStartupTrace?.("session-index-ready", { - source: context?.reason ?? "connectToServer", - targetRoot: targetRoot || null, - }); - const pendingPermissionsAt = Date.now(); - await options.refreshPendingPermissions(); - connectMetrics.pendingPermissionsMs = Date.now() - pendingPermissionsAt; - - const providerState = await providersPromise; - options.setProviders(providerState.providers); - options.setProviderDefaults(providerState.defaults); - options.setProviderConnectedIds(providerState.connectedIds); - - if (navigate && !options.selectedSessionId()) { - options.setSettingsTab("automations"); - options.setView("session"); - } - - options.onEngineStable?.(); - connectMetrics.totalMs = Date.now() - connectStart; - options.setOpencodeConnectStatus?.({ ...connectMeta, status: "connected", metrics: connectMetrics }); - wsDebug("connect:done", { ok: true, ms: Date.now() - connectStart }); - return true; - } catch (e) { - options.setClient(null); - options.setConnectedVersion(null); - setConnectedWorkspaceId(null); - const message = e instanceof Error ? e.message : safeStringify(e); - wsDebug("connect:error", { ms: Date.now() - connectStart, message }); - connectMetrics.totalMs = Date.now() - connectStart; - options.setOpencodeConnectStatus?.({ - ...connectMeta, - status: "error", - error: addOpencodeCacheHint(message), - metrics: connectMetrics, - }); - if (!quiet) { - options.setError(addOpencodeCacheHint(message)); - } - return false; - } finally { - if (!quiet) { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - })(); - - connectInFlightByKey.set(requestKey, run); - try { - return await run; - } finally { - if (connectInFlightByKey.get(requestKey) === run) { - connectInFlightByKey.delete(requestKey); - } - } - } - - const openEmptySession = async (scopeRoot?: string) => { - const root = (scopeRoot ?? selectedWorkspaceRoot().trim()).trim(); - wsDebug("open-empty-session:start", { - scopeRoot: scopeRoot ?? null, - resolvedRoot: root || null, - selectedWorkspaceId: selectedWorkspaceId(), - activeWorkspace: selectedWorkspaceInfo(), - hasClient: Boolean(options.client()), - }); - - if (options.client()) { - try { - await options.loadSessions(root || undefined); - } catch { - // If session loading fails, still fall back to the draft-ready session view. - } - } - - clearSelectedSessionSurface(); - options.setView("session"); - }; - - const activateFreshLocalWorkspace = async (workspaceId: string | null, workspacePath: string) => { - const hasClient = Boolean(options.client()); - const ok = hasClient - ? workspaceId - ? await activateWorkspace(workspaceId) - : true - : await startHost({ workspacePath, navigate: false }); - - if (!ok) { - return false; - } - return true; - }; - - async function createWorkspaceFlow(preset: WorkspacePreset, folder: string | null): Promise { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - - if (!folder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return false; - } - - options.setBusy(true); - options.setBusyLabel("status.creating_workspace"); - options.setBusyStartedAt(Date.now()); - options.setError(null); - clearSandboxCreateProgress(); - setSandboxPreflightBusy(false); - - try { - const resolvedFolder = await resolveWorkspacePath(folder); - if (!resolvedFolder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return false; - } - - const name = deriveWorkspaceName(resolvedFolder, preset); - const openworkServer = await resolveLocalOpenworkServer(); - const ws = openworkServer - ? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset }) - : await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - - const createdWorkspaceId = pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws); - - if (openworkServer && isTauriRuntime()) { - try { - await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - } catch { - // keep the server result as the source of truth for this run - } - } - - const nextSelectedId = createdWorkspaceId; - applyServerLocalWorkspaces(ws.workspaces, nextSelectedId); - if (nextSelectedId) { - const nextSelectedWorkspace = ws.workspaces.find((workspace) => workspace.id === nextSelectedId) ?? null; - if (nextSelectedWorkspace) { - await applySelectedWorkspacePresentation(nextSelectedWorkspace); - } else { - syncSelectedWorkspaceId(nextSelectedId); - } - updateWorkspaceConnectionState(nextSelectedId, { status: "connected", message: null }); - } - - queuePendingInitialSessionSelection(nextSelectedId || null, preset); - - setCreateWorkspaceOpen(false); - - const opened = await activateFreshLocalWorkspace(nextSelectedId || null, resolvedFolder); - if (!opened) { - options.setPendingInitialSessionSelection?.(null); - return false; - } - - if (preset === "starter") { - const materialized = await materializeStarterSessions(resolvedFolder, name, preset); - const sessionsReady = await waitForWorkspaceSessionsReady(resolvedFolder); - if (!sessionsReady) { - throw new Error("Starter sessions did not finish loading for the new workspace."); - } - if (nextSelectedId) { - await options.refreshWorkspaceSessions?.(nextSelectedId); - } - const openSessionId = materialized?.openSessionId?.trim() || ""; - if (openSessionId) { - options.setPendingInitialSessionSelection?.(null); - options.setSelectedSessionId(openSessionId); - options.setView("session", openSessionId); - await options.selectSession(openSessionId, { - skipHealthCheck: true, - source: "create-workspace-open-session", - }); - } - } - - if (!nextSelectedId) { - await openEmptySession(resolvedFolder); - } - - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function createSandboxFlow( - preset: WorkspacePreset, - folder: string | null, - input?: { onReady?: () => Promise | void }, - ): Promise { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - - if (!folder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return false; - } - - const runId = makeRunId(); - const startedAt = Date.now(); - const sandboxMode = resolveSandboxCreateMode(options.useMicrosandboxCreateSandbox?.() === true); - setSandboxCreatePhase("preflight"); - setSandboxPreflightBusy(true); - options.setError(null); - clearSandboxCreateProgress(); - - const doctor = await refreshSandboxDoctor(); - setSandboxPreflightBusy(false); - setSandboxCreatePhase("provisioning"); - setSandboxCreateProgress({ - runId, - startedAt, - stage: sandboxMode.runtimeCheckingStage, - error: null, - logs: [], - steps: [ - { key: "docker", label: sandboxMode.runtimeReadyLabel, status: "active", detail: null }, - { key: "workspace", label: "Prepare worker", status: "pending", detail: null }, - { key: "sandbox", label: "Start sandbox services", status: "pending", detail: null }, - { key: "health", label: "Wait for OpenWork", status: "pending", detail: null }, - { key: "connect", label: "Connect in OpenWork", status: "pending", detail: null }, - ], - }); - - if (doctor?.debug) { - const selectedBin = doctor.debug.selectedBin?.trim(); - if (selectedBin) { - pushSandboxCreateLog(`Docker binary: ${selectedBin}`); - } - const candidates = (doctor.debug.candidates ?? []).filter((item) => item?.trim()); - if (candidates.length) { - pushSandboxCreateLog(`Docker candidates: ${candidates.join(", ")}`); - } - const versionDebug = doctor.debug.versionCommand; - if (versionDebug) { - pushSandboxCreateLog(`docker --version exit=${versionDebug.status}`); - if (versionDebug.stderr?.trim()) pushSandboxCreateLog(`docker --version stderr: ${versionDebug.stderr.trim()}`); - } - const infoDebug = doctor.debug.infoCommand; - if (infoDebug) { - pushSandboxCreateLog(`docker info exit=${infoDebug.status}`); - if (infoDebug.stderr?.trim()) pushSandboxCreateLog(`docker info stderr: ${infoDebug.stderr.trim()}`); - } - } - if (!doctor?.ready) { - const detail = - doctor?.error?.trim() || - "Docker is required for sandboxes. Install Docker Desktop, start it, then retry."; - options.setError(detail); - setSandboxStep("docker", { status: "error", detail }); - setSandboxError(detail); - setSandboxStage("Docker not ready"); - setSandboxCreatePhase("idle"); - return false; - } - setSandboxStep("docker", { status: "done", detail: doctor.serverVersion ?? null }); - setSandboxStage("Preparing worker..."); - - try { - const resolvedFolder = await resolveWorkspacePath(folder); - if (!resolvedFolder) { - options.setError(t("app.error.choose_folder", currentLocale())); - setSandboxStep("workspace", { status: "error", detail: "No folder selected" }); - setSandboxError("No folder selected"); - return false; - } - - const name = deriveWorkspaceName(resolvedFolder, preset); - - setSandboxStep("workspace", { status: "active", detail: name }); - pushSandboxCreateLog(`Worker: ${resolvedFolder}`); - - // Ensure the workspace folder has baseline OpenWork/OpenCode files. - const openworkServer = await resolveLocalOpenworkServer(); - const created = openworkServer - ? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset }) - : await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - if (openworkServer && isTauriRuntime()) { - try { - await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - } catch { - // ignore desktop mirror failures here - } - } - const localId = pickSelectedWorkspaceId(created.workspaces, [resolveWorkspaceListSelectedId(created)], created); - applyServerLocalWorkspaces(created.workspaces, localId); - if (localId) { - syncSelectedWorkspaceId(localId); - } - setSandboxStep("workspace", { status: "done", detail: null }); - - // Remove the local workspace entry to avoid duplicate Local+Remote rows. - if (localId) { - pushSandboxCreateLog("Removing local worker row (will re-add as remote sandbox)..."); - const activeLocalWorkspace = openworkServer ? await findOpenworkWorkspaceByPath(resolvedFolder) : null; - const forgotten = await (activeLocalWorkspace - ? (() => activeLocalWorkspace.client.deleteWorkspace(activeLocalWorkspace.workspaceId).then((response) => ({ - activeId: response.activeId ?? "", - workspaces: response.workspaces ?? response.items, - })))() - : workspaceForget(localId)); - if (activeLocalWorkspace && isTauriRuntime()) { - try { - await workspaceForget(localId); - } catch { - // ignore desktop mirror failures here - } - } - applyServerLocalWorkspaces(forgotten.workspaces, forgotten.activeId); - } - - setSandboxStep("sandbox", { status: "active", detail: null }); - setSandboxStage("Starting sandbox services..."); - - let stopListen: (() => void) | null = null; - try { - stopListen = await listen( - "openwork://sandbox-create-progress", - (event: TauriEvent<{ runId?: string; stage?: string; message?: string; payload?: any }>) => { - const payload = event.payload ?? {}; - if ((payload.runId ?? "").trim() !== runId) return; - const stage = String(payload.stage ?? "").trim(); - const message = String(payload.message ?? "").trim(); - if (message) { - setSandboxStage(message); - pushSandboxCreateLog(message); - } - - if (stage === "docker.container") { - const state = String(payload.payload?.containerState ?? "").trim(); - if (state) { - setSandboxStep("sandbox", { status: "active", detail: `Container: ${state}` }); - } - } - - if (stage === "docker.config") { - const selected = String(payload.payload?.openworkDockerBin ?? "").trim(); - if (selected) { - pushSandboxCreateLog(`OPENWORK_DOCKER_BIN=${selected}`); - } - const resolved = String(payload.payload?.resolvedDockerBin ?? "").trim(); - if (resolved) { - pushSandboxCreateLog(`Resolved docker: ${resolved}`); - } - const candidates = Array.isArray(payload.payload?.candidates) - ? payload.payload.candidates.filter((item: unknown) => String(item ?? "").trim()) - : []; - if (candidates.length) { - pushSandboxCreateLog(`Docker probe paths: ${candidates.join(", ")}`); - } - } - - if (stage === "docker.inspect") { - const inspectError = String(payload.payload?.error ?? "").trim(); - if (inspectError) { - setSandboxStep("sandbox", { status: "active", detail: "Docker inspect warning" }); - pushSandboxCreateLog(`docker inspect warning: ${inspectError}`); - } - } - - if (stage === "openwork.waiting") { - const elapsedMs = Number(payload.payload?.elapsedMs ?? 0); - const seconds = elapsedMs > 0 ? Math.max(1, Math.floor(elapsedMs / 1000)) : 0; - setSandboxStep("health", { status: "active", detail: seconds ? `${seconds}s` : null }); - const probeError = String(payload.payload?.containerProbeError ?? "").trim(); - if (probeError) { - pushSandboxCreateLog(`Container probe: ${probeError}`); - } - } - - if (stage === "openwork.healthy") { - setSandboxStep("sandbox", { status: "done" }); - setSandboxStep("health", { status: "done", detail: null }); - } - - if (stage === "error") { - const err = String(payload.payload?.error ?? "").trim() || message || "Sandbox failed to start"; - setSandboxStep("sandbox", { status: "error", detail: err }); - setSandboxStep("health", { status: "error", detail: err }); - setSandboxError(err); - } - }, - ); - - const host = await orchestratorStartDetached({ - workspacePath: resolvedFolder, - sandboxBackend: sandboxMode.backend, - sandboxImageRef: sandboxMode.sandboxImageRef, - runId, - }); - setSandboxStep("sandbox", { status: "done", detail: host.sandboxContainerName ?? null }); - setSandboxStep("health", { status: "done" }); - setSandboxStage("Connecting to sandbox..."); - - setSandboxStep("connect", { status: "active", detail: null }); - - const ok = await createRemoteWorkspaceFlow({ - openworkHostUrl: host.openworkUrl, - openworkToken: host.ownerToken?.trim() || host.token, - openworkClientToken: host.token, - openworkHostToken: host.hostToken, - directory: resolvedFolder, - displayName: name, - sandboxBackend: host.sandboxBackend ?? sandboxMode.backend, - sandboxRunId: host.sandboxRunId ?? runId, - sandboxContainerName: host.sandboxContainerName ?? null, - manageBusy: false, - closeModal: false, - }); - if (!ok) { - const fallback = "Failed to connect to sandbox"; - pushSandboxCreateLog(fallback); - setSandboxStep("connect", { status: "error", detail: fallback }); - setSandboxError(fallback); - return false; - } - - if (input?.onReady) { - setSandboxCreatePhase("finalizing"); - setSandboxStage("Finalizing worker..."); - setSandboxStep("connect", { status: "active", detail: "Applying setup" }); - pushSandboxCreateLog("Applying final worker setup..."); - await input.onReady(); - } - - setSandboxStep("connect", { status: "done", detail: null }); - setSandboxStage("Sandbox ready."); - setCreateWorkspaceOpen(false); - clearSandboxCreateProgress(); - return true; - } finally { - stopListen?.(); - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - setSandboxError(message); - setSandboxStage("Sandbox failed"); - return false; - } finally { - setSandboxPreflightBusy(false); - setSandboxCreatePhase("idle"); - } - } - - async function createRemoteWorkspaceFlow(input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - openworkClientToken?: string | null; - openworkHostToken?: string | null; - directory?: string | null; - displayName?: string | null; - manageBusy?: boolean; - closeModal?: boolean; - - // Sandbox lifecycle metadata (desktop-managed) - sandboxBackend?: SandboxBackendType | null; - sandboxRunId?: string | null; - sandboxContainerName?: string | null; - }) { - if (createRemoteInFlight) { - wsDebug("create-remote:dedupe", { - hostUrl: input.openworkHostUrl ?? null, - directory: input.directory ?? null, - }); - return createRemoteInFlight; - } - - const run = (async () => { - const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? ""; - const token = input.openworkToken?.trim() ?? ""; - const directory = input.directory?.trim() ?? ""; - const displayName = input.displayName?.trim() || null; - - if (!hostUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - options.setError(null); - console.log("[workspace] create remote request", { - hostUrl: hostUrl || null, - directory: directory || null, - displayName, - }); - - options.setStartupPreference("server"); - - let remoteType: "openwork" = "openwork"; - let resolvedBaseUrl = ""; - let resolvedDirectory = directory; - let openworkWorkspace: OpenworkWorkspaceInfo | null = null; - let resolvedAuth: OpencodeAuth | undefined = undefined; - let resolvedHostUrl = hostUrl; - - options.openworkServer.updateOpenworkServerSettings({ - ...options.openworkServer.openworkServerSettings(), - urlOverride: hostUrl, - token: token || undefined, - }); - - try { - let resolved: Awaited> | null = null; - try { - resolved = await resolveOpenworkHost({ - hostUrl, - token, - directoryHint: directory || null, - }); - } catch (error) { - // Sandbox workers can report healthy before listWorkspaces is fully ready. - // Fall back to host-level OpenCode URL so the worker can still be registered. - if (input.sandboxBackend !== "docker") { - throw error; - } - wsDebug("sandbox:openwork-resolve-fallback:error", { - hostUrl, - message: error instanceof Error ? error.message : safeStringify(error), - }); - } - - if (resolved?.kind === "openwork") { - resolvedBaseUrl = resolved.opencodeBaseUrl; - resolvedDirectory = resolved.directory || directory; - openworkWorkspace = resolved.workspace; - resolvedHostUrl = resolved.hostUrl; - resolvedAuth = resolved.auth; - } else if (input.sandboxBackend === "docker" || input.sandboxBackend === "microsandbox") { - resolvedHostUrl = hostUrl; - resolvedBaseUrl = `${hostUrl.replace(/\/+$/, "")}/opencode`; - resolvedDirectory = directory || resolvedDirectory; - resolvedAuth = token ? { token, mode: "openwork" } : undefined; - wsDebug("sandbox:openwork-resolve-fallback:host", { - hostUrl: resolvedHostUrl, - baseUrl: resolvedBaseUrl, - directory: resolvedDirectory, - }); - } else { - options.setError("OpenWork server unavailable. Check the URL and token."); - return false; - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - return false; - } - - if (!resolvedBaseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - const ok = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceType: "remote", - targetRoot: resolvedDirectory ?? "", - reason: "workspace-create-remote", - }, - resolvedAuth, - ); - - if (!ok) { - return false; - } - - const finalDirectory = options.clientDirectory().trim() || resolvedDirectory || ""; - - const manageBusy = input.manageBusy ?? true; - if (manageBusy) { - options.setBusy(true); - options.setBusyLabel("status.creating_workspace"); - options.setBusyStartedAt(Date.now()); - } - - try { - let createdWorkspaceId: string | null = null; - if (isTauriRuntime()) { - const ws = await workspaceCreateRemote({ - baseUrl: resolvedBaseUrl.replace(/\/+$/, ""), - directory: finalDirectory ? finalDirectory : null, - displayName, - remoteType, - openworkHostUrl: remoteType === "openwork" ? resolvedHostUrl : null, - openworkToken: remoteType === "openwork" ? (token || null) : null, - openworkClientToken: - remoteType === "openwork" ? (input.openworkClientToken?.trim() || null) : null, - openworkHostToken: - remoteType === "openwork" ? (input.openworkHostToken?.trim() || null) : null, - openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null, - openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null, - sandboxBackend: input.sandboxBackend ?? null, - sandboxRunId: input.sandboxRunId ?? null, - sandboxContainerName: input.sandboxContainerName ?? null, - }); - setWorkspaces(ws.workspaces); - const nextSelectedId = pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws); - createdWorkspaceId = nextSelectedId; - syncSelectedWorkspaceId(nextSelectedId); - console.log("[workspace] create remote complete:", nextSelectedId || "none"); - } else { - const workspaceId = `remote:${resolvedBaseUrl}:${finalDirectory}`; - createdWorkspaceId = workspaceId; - const nextWorkspace: WorkspaceInfo = { - id: workspaceId, - name: displayName ?? openworkWorkspace?.name ?? resolvedHostUrl ?? resolvedBaseUrl, - path: "", - preset: "remote", - workspaceType: "remote", - remoteType, - baseUrl: resolvedBaseUrl, - directory: finalDirectory || null, - displayName, - openworkHostUrl: remoteType === "openwork" ? resolvedHostUrl : null, - openworkToken: remoteType === "openwork" ? (token || null) : null, - openworkClientToken: - remoteType === "openwork" ? (input.openworkClientToken?.trim() || null) : null, - openworkHostToken: - remoteType === "openwork" ? (input.openworkHostToken?.trim() || null) : null, - openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null, - openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null, - sandboxBackend: input.sandboxBackend ?? null, - sandboxRunId: input.sandboxRunId ?? null, - sandboxContainerName: input.sandboxContainerName ?? null, - }; - - setWorkspaces((prev) => { - const withoutMatch = prev.filter((workspace) => workspace.id !== workspaceId); - return [...withoutMatch, nextWorkspace]; - }); - syncSelectedWorkspaceId(workspaceId); - console.log("[workspace] create remote complete:", workspaceId); - } - - if (createdWorkspaceId) { - setConnectedWorkspaceId(createdWorkspaceId); - } - - setProjectDir(finalDirectory); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - - const closeModal = input.closeModal ?? true; - if (closeModal) { - setCreateWorkspaceOpen(false); - setCreateRemoteWorkspaceOpen(false); - } - if (createdWorkspaceId) { - updateWorkspaceConnectionState(createdWorkspaceId, { status: "connected", message: null }); - } - - await openEmptySession(selectedWorkspaceRoot().trim() || finalDirectory); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - console.log("[workspace] create remote failed:", message); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - if (manageBusy) { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - })(); - - createRemoteInFlight = run; - try { - return await run; - } finally { - if (createRemoteInFlight === run) { - createRemoteInFlight = null; - } - } - } - - async function updateRemoteWorkspaceFlow( - workspaceId: string, - input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - openworkClientToken?: string | null; - openworkHostToken?: string | null; - directory?: string | null; - displayName?: string | null; - }, - ) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace || workspace.workspaceType !== "remote") return false; - - const remoteType = normalizeRemoteType(workspace.remoteType); - if (remoteType !== "openwork") { - options.setError("Only OpenWork remote workers can be edited."); - return false; - } - - const hostUrl = - normalizeOpenworkServerUrl( - input.openworkHostUrl ?? workspace.openworkHostUrl ?? workspace.baseUrl ?? "", - ) ?? ""; - const token = - input.openworkToken?.trim() ?? - workspace.openworkToken?.trim() ?? - options.openworkServer.openworkServerSettings().token ?? - ""; - const directory = input.directory?.trim() ?? ""; - const displayName = input.displayName?.trim() || null; - - if (!hostUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - options.setError(null); - options.setStartupPreference("server"); - - let resolvedBaseUrl = ""; - let resolvedDirectory = directory; - let openworkWorkspace: OpenworkWorkspaceInfo | null = null; - let resolvedAuth: OpencodeAuth | undefined = undefined; - let resolvedHostUrl = hostUrl; - - options.openworkServer.updateOpenworkServerSettings({ - ...options.openworkServer.openworkServerSettings(), - urlOverride: hostUrl, - token: token || undefined, - }); - - try { - const resolved = await resolveOpenworkHost({ - hostUrl, - token, - workspaceId: workspace.openworkWorkspaceId ?? null, - directoryHint: directory || null, - }); - if (resolved.kind !== "openwork") { - options.setError("OpenWork server unavailable. Check the URL and token."); - return false; - } - resolvedBaseUrl = resolved.opencodeBaseUrl; - resolvedDirectory = resolved.directory || directory; - openworkWorkspace = resolved.workspace; - resolvedHostUrl = resolved.hostUrl; - resolvedAuth = resolved.auth; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - return false; - } - - if (!resolvedBaseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - const isActive = connectedWorkspaceId() === id; - const finalDirectory = resolvedDirectory || ""; - - if (isActive) { - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - const ok = await connectToServer( - resolvedBaseUrl, - finalDirectory || undefined, - { - workspaceId: id, - workspaceType: "remote", - targetRoot: finalDirectory ?? "", - reason: "workspace-edit-remote", - }, - resolvedAuth, - ); - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to connect to worker.", - }); - return false; - } - } - - if (isTauriRuntime()) { - try { - const ws = await workspaceUpdateRemote({ - workspaceId: id, - remoteType: "openwork", - baseUrl: resolvedBaseUrl, - directory: finalDirectory ? finalDirectory : null, - displayName, - openworkHostUrl: resolvedHostUrl, - openworkToken: token ? token : null, - openworkClientToken: - input.openworkClientToken?.trim() || workspace.openworkClientToken?.trim() || null, - openworkHostToken: - input.openworkHostToken?.trim() || workspace.openworkHostToken?.trim() || null, - openworkWorkspaceId: openworkWorkspace?.id ?? workspace.openworkWorkspaceId ?? null, - openworkWorkspaceName: openworkWorkspace?.name ?? workspace.openworkWorkspaceName ?? null, - }); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [id, selectedWorkspaceId()], ws)); - } catch { - // ignore - } - } else { - setWorkspaces((prev) => - prev.map((item) => - item.id === id - ? { - ...item, - remoteType: "openwork", - baseUrl: resolvedBaseUrl, - directory: finalDirectory ? finalDirectory : null, - displayName, - openworkHostUrl: resolvedHostUrl, - openworkToken: token ? token : null, - openworkClientToken: - input.openworkClientToken?.trim() || item.openworkClientToken?.trim() || null, - openworkHostToken: - input.openworkHostToken?.trim() || item.openworkHostToken?.trim() || null, - openworkWorkspaceId: openworkWorkspace?.id ?? item.openworkWorkspaceId ?? null, - openworkWorkspaceName: openworkWorkspace?.name ?? item.openworkWorkspaceName ?? null, - } - : item, - ), - ); - } - - if (isActive) { - setProjectDir(finalDirectory); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - } - - return true; - } - - async function forgetWorkspace(workspaceId: string) { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return; - } - - const id = workspaceId.trim(); - if (!id) return; - const workspace = workspaces().find((entry) => entry.id === id) ?? null; - - console.log("[workspace] forget", { id }); - - try { - const previousActive = selectedWorkspaceId(); - const openworkWorkspace = workspace?.workspaceType === "local" ? await findOpenworkWorkspaceByPath(workspace.path) : null; - const ws = openworkWorkspace - ? await openworkWorkspace.client.deleteWorkspace(openworkWorkspace.workspaceId).then((response) => ({ - activeId: response.activeId ?? "", - workspaces: response.workspaces ?? response.items, - })) - : await workspaceForget(id); - - if (openworkWorkspace && isTauriRuntime()) { - try { - await workspaceForget(id); - } catch { - // ignore desktop mirror failures here - } - } - - if (openworkWorkspace) { - applyServerLocalWorkspaces(ws.workspaces, ws.activeId); - } else { - setWorkspaces(ws.workspaces); - } - clearWorkspaceConnectionState(id); - - if (!openworkWorkspace) { - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [selectedWorkspaceId()], ws)); - } - - const nextSelectedId = pickSelectedWorkspaceId(ws.workspaces, [selectedWorkspaceId()], ws); - const selected = ws.workspaces.find((w) => w.id === nextSelectedId) ?? null; - // Snapshot the runtime root before the optimistic selected-workspace update - // so activateWorkspace can still detect a real workspace transition. - const prevProjectDir = resolveCurrentRuntimeRoot(); - if (selected) { - setProjectDir(selected.workspaceType === "remote" ? selected.directory?.trim() ?? "" : selected.path); - } - - if (nextSelectedId && nextSelectedId !== previousActive) { - await activateWorkspace(nextSelectedId, { prevProjectDir }); - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - async function recoverWorkspace(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - if (connectingWorkspaceId() === id) return false; - - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace) return false; - - const reconnect = async () => { - if (connectedWorkspaceId() === id) { - return await activateWorkspace(id); - } - return await testWorkspaceConnection(id); - }; - - setConnectingWorkspaceId(id); - options.setError(null); - - try { - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - - if (workspace.workspaceType !== "remote") { - return Boolean(await reconnect()); - } - - const isSandboxWorkspace = - workspace.sandboxBackend === "docker" || - workspace.sandboxBackend === "microsandbox" || - Boolean(workspace.sandboxContainerName?.trim()); - - if (!isSandboxWorkspace) { - return Boolean(await reconnect()); - } - - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - updateWorkspaceConnectionState(id, { - status: "error", - message: t("app.error.tauri_required", currentLocale()), - }); - return false; - } - - const workspacePath = workspace.directory?.trim() || workspace.path?.trim() || ""; - if (!workspacePath) { - const message = "Worker folder is missing. Open Edit connection and try again."; - options.setError(message); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - - const doctor = await refreshSandboxDoctor(); - if (!doctor?.ready) { - const detail = - doctor?.error?.trim() || - "Docker needs to be running before we can get this worker back online."; - throw new Error(detail); - } - - const host = await orchestratorStartDetached({ - workspacePath, - sandboxBackend: "docker", - runId: workspace.sandboxRunId?.trim() || null, - openworkToken: - workspace.openworkClientToken?.trim() || - workspace.openworkToken?.trim() || - options.openworkServer.openworkServerSettings().token?.trim() || - null, - openworkHostToken: workspace.openworkHostToken?.trim() || null, - }); - - const resolved = await resolveOpenworkHost({ - hostUrl: host.openworkUrl, - token: host.ownerToken?.trim() || host.token, - directoryHint: workspacePath, - }); - - if (resolved.kind !== "openwork") { - throw new Error("Worker is still warming up. Try again in a few seconds."); - } - - const updated = await workspaceUpdateRemote({ - workspaceId: id, - remoteType: "openwork", - baseUrl: resolved.opencodeBaseUrl, - directory: resolved.directory || workspacePath, - openworkHostUrl: resolved.hostUrl, - openworkToken: host.ownerToken?.trim() || host.token, - openworkClientToken: host.token, - openworkHostToken: host.hostToken, - openworkWorkspaceId: resolved.workspace.id, - openworkWorkspaceName: resolved.workspace.name ?? workspace.openworkWorkspaceName ?? null, - sandboxBackend: host.sandboxBackend ?? "docker", - sandboxRunId: host.sandboxRunId ?? workspace.sandboxRunId ?? null, - sandboxContainerName: host.sandboxContainerName ?? workspace.sandboxContainerName ?? null, - }); - - setWorkspaces(updated.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(updated.workspaces, [id, selectedWorkspaceId()], updated)); - - const ok = await reconnect(); - if (!ok) { - const message = "Worker restarted, but reconnect failed. Try again in a few seconds."; - updateWorkspaceConnectionState(id, { status: "error", message }); - options.setError(message); - return false; - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - const hint = addOpencodeCacheHint(message); - options.setError(hint); - updateWorkspaceConnectionState(id, { status: "error", message: hint }); - return false; - } finally { - setConnectingWorkspaceId((current) => (current === id ? null : current)); - } - } - - async function pickWorkspaceFolder() { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return null; - } - - try { - const selection = await pickDirectory({ title: t("onboarding.choose_workspace_folder", currentLocale()) }); - const folder = - typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - - return folder ?? null; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return null; - } - } - - function joinNativePath(base: string, leaf: string) { - const trimmedBase = base.replace(/[\\/]+$/, ""); - if (!trimmedBase) return leaf; - const separator = trimmedBase.includes("\\") ? "\\" : "/"; - return `${trimmedBase}${separator}${leaf}`; - } - - function deriveWorkspaceName(folderPath: string, preset: WorkspacePreset) { - return folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker"; - } - - async function resolveFirstRunWelcomeFolder() { - const base = (await homeDir()).replace(/[\\/]+$/, ""); - return joinNativePath(joinNativePath(base, DEFAULT_WORKSPACE_HOME_FOLDER_NAME), FIRST_RUN_WELCOME_WORKSPACE_NAME); - } - - async function exportWorkspaceConfig(workspaceId?: string) { - if (exportingWorkspaceConfig()) return; - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return; - } - - const targetId = workspaceId?.trim() || selectedWorkspaceInfo()?.id || ""; - if (!targetId) { - options.setError("Select a worker to export"); - return; - } - const target = workspaces().find((ws) => ws.id === targetId) ?? null; - if (!target) { - options.setError("Unknown worker"); - return; - } - if (target.workspaceType === "remote") { - options.setError("Export is only supported for local workers"); - return; - } - - setExportingWorkspaceConfig(true); - options.setError(null); - - try { - const nameBase = (target.displayName || target.name || "worker") - .toLowerCase() - .replace(/[^a-z0-9-_]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 60); - const dateStamp = new Date().toISOString().slice(0, 10); - const fileName = `openwork-${nameBase || "worker"}-${dateStamp}.openwork-workspace`; - const downloads = await downloadDir().catch(() => null); - const defaultPath = downloads ? `${downloads}/${fileName}` : fileName; - - const outputPath = await saveFile({ - title: "Export worker config", - defaultPath, - filters: [{ name: "OpenWork Worker", extensions: ["openwork-workspace", "zip"] }], - }); - - if (!outputPath) { - return; - } - - await workspaceExportConfig({ - workspaceId: target.id, - outputPath, - }); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - setExportingWorkspaceConfig(false); - } - } - - async function importWorkspaceConfig() { - if (importingWorkspaceConfig()) return; - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return; - } - - setImportingWorkspaceConfig(true); - options.setError(null); - - try { - const selection = await pickFile({ - title: "Import worker config", - filters: [{ name: "OpenWork Worker", extensions: ["openwork-workspace", "zip"] }], - }); - const filePath = - typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - if (!filePath) return; - - const target = await pickDirectory({ - title: "Choose a worker folder", - }); - const folder = - typeof target === "string" ? target : Array.isArray(target) ? target[0] : null; - if (!folder) return; - - const resolvedFolder = await resolveWorkspacePath(folder); - if (!resolvedFolder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return; - } - - const ws = await workspaceImportConfig({ - archivePath: filePath, - targetDir: resolvedFolder, - }); - - setWorkspaces(ws.workspaces); - const nextSelectedId = pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws); - syncSelectedWorkspaceId(nextSelectedId); - setCreateWorkspaceOpen(false); - setCreateRemoteWorkspaceOpen(false); - - const opened = await activateFreshLocalWorkspace(nextSelectedId || null, resolvedFolder); - if (!opened) { - return; - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - setImportingWorkspaceConfig(false); - } - } - - async function startHost(optionsOverride?: { workspacePath?: string; navigate?: boolean }) { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - - const overrideWorkspacePath = optionsOverride?.workspacePath?.trim() ?? ""; - if (selectedWorkspaceInfo()?.workspaceType === "remote" && !overrideWorkspacePath) { - options.setError(t("app.error.host_requires_local", currentLocale())); - return false; - } - - const dir = (overrideWorkspacePath || selectedWorkspacePath() || projectDir()).trim(); - if (!dir) { - options.setError(t("app.error.pick_workspace_folder", currentLocale())); - return false; - } - - try { - const source = options.engineSource(); - const result = await engineDoctor({ - preferSidecar: source === "sidecar", - opencodeBinPath: source === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - }); - setEngineDoctorResult(result); - setEngineDoctorCheckedAt(Date.now()); - - if (!result.found) { - options.setError( - options.isWindowsPlatform() - ? "OpenCode CLI not found. Install the OpenWork-pinned OpenCode version for Windows or bundle opencode.exe with OpenWork, then restart. If it is installed, ensure `opencode.exe` is on PATH (try `opencode --version` in PowerShell)." - : "OpenCode CLI not found. Install the OpenWork-pinned OpenCode version, then retry.", - ); - return false; - } - - if (!result.supportsServe) { - const serveDetails = [result.serveHelpStdout, result.serveHelpStderr] - .filter((value) => value && value.trim()) - .join("\n\n"); - const suffix = serveDetails ? `\n\nServe output:\n${serveDetails}` : ""; - options.setError( - `OpenCode CLI is installed, but \`opencode serve\` is unavailable. Update to the OpenWork-pinned OpenCode version and retry.${suffix}` - ); - return false; - } - } catch (e) { - setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e)); - } - - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.starting_engine"); - options.setBusyStartedAt(Date.now()); - - try { - setProjectDir(dir); - if (!authorizedDirs().length) { - setAuthorizedDirs([dir]); - } - - const info = await engineStart(dir, { - preferSidecar: options.engineSource() === "sidecar", - opencodeBinPath: - options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - opencodeEnableExa: options.opencodeEnableExa?.() ?? false, - openworkRemoteAccess: options.openworkServer.openworkServerSettings().remoteAccessEnabled === true, - runtime: resolveEngineRuntime(), - workspacePaths: resolveWorkspacePaths(), - }); - setEngine(info); - - const username = info.opencodeUsername?.trim() ?? ""; - const password = info.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (info.baseUrl) { - const ok = await connectToServer( - info.baseUrl, - dir, - { workspaceType: "local", targetRoot: dir, reason: "host-start" }, - auth, - { navigate: optionsOverride?.navigate ?? true }, - ); - if (!ok) return false; - } - - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function updateWorkspaceDisplayName(workspaceId: string, displayName: string | null) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace) return false; - - const nextDisplayName = displayName?.trim() || null; - options.setError(null); - - const openworkWorkspace = workspace.workspaceType === "local" - ? await findOpenworkWorkspaceByPath(workspace.path) - : null; - - if (openworkWorkspace) { - try { - const ws = await openworkWorkspace.client.updateWorkspaceDisplayName(openworkWorkspace.workspaceId, nextDisplayName); - if (isTauriRuntime()) { - try { - await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName }); - } catch { - // ignore desktop mirror failures here - } - } - applyServerLocalWorkspaces(ws.workspaces, ws.activeId); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } - } - - if (isTauriRuntime()) { - try { - const ws = await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName }); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [id, selectedWorkspaceId()], ws)); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } - } - - setWorkspaces((prev) => - prev.map((entry) => - entry.id === id - ? { - ...entry, - displayName: nextDisplayName, - name: nextDisplayName ?? entry.name, - } - : entry - ) - ); - return true; - } - - const openRenameWorkspace = (workspaceId: string) => { - const workspace = workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace) return; - setRenameWorkspaceId(workspaceId); - setRenameWorkspaceName( - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - "", - ); - setRenameWorkspaceOpen(true); - }; - - const closeRenameWorkspace = () => { - if (renameWorkspaceBusy()) return; - setRenameWorkspaceOpen(false); - setRenameWorkspaceId(null); - setRenameWorkspaceName(""); - }; - - const saveRenameWorkspace = async () => { - const workspaceId = renameWorkspaceId(); - if (!workspaceId) return; - const nextName = renameWorkspaceName().trim(); - if (!nextName) return; - if (renameWorkspaceBusy()) return; - - setRenameWorkspaceBusy(true); - options.setError(null); - try { - const ok = await updateWorkspaceDisplayName(workspaceId, nextName); - if (!ok) return; - setRenameWorkspaceOpen(false); - setRenameWorkspaceId(null); - setRenameWorkspaceName(""); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - setRenameWorkspaceBusy(false); - } - }; - - const closeWorkspaceConnectionSettings = () => { - setEditRemoteWorkspaceOpen(false); - setEditRemoteWorkspaceId(null); - setEditRemoteWorkspaceError(null); - }; - - const saveWorkspaceConnectionSettings = async (input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; - }) => { - const workspaceId = editRemoteWorkspaceId(); - if (!workspaceId) return; - setEditRemoteWorkspaceError(null); - try { - const ok = await updateRemoteWorkspaceFlow(workspaceId, input); - if (ok) { - closeWorkspaceConnectionSettings(); - return; - } - setEditRemoteWorkspaceError(t("app.error_connection_failed_url", currentLocale())); - options.setError(null); - } catch (e) { - const message = e instanceof Error ? e.message : t("app.error_connection_failed", currentLocale()); - setEditRemoteWorkspaceError(message); - options.setError(null); - } - }; - - const openWorkspaceConnectionSettings = (workspaceId: string) => { - const workspace = workspaces().find((item) => item.id === workspaceId) ?? null; - if (workspace?.workspaceType === "remote") { - setEditRemoteWorkspaceId(workspace.id); - setEditRemoteWorkspaceError(null); - setEditRemoteWorkspaceOpen(true); - return; - } - setEditRemoteWorkspaceId(null); - setEditRemoteWorkspaceError(null); - options.setSettingsTab("advanced"); - options.setView("settings"); - }; - - async function stopHost() { - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.disconnecting"); - options.setBusyStartedAt(Date.now()); - - try { - if (isTauriRuntime()) { - const info = await engineStop(); - setEngine(info); - } - - setEngineAuth(null); - - options.setClient(null); - options.setConnectedVersion(null); - setConnectedWorkspaceId(null); - if (isTauriRuntime()) { - try { - await workspaceSetRuntimeActive(null); - } catch { - // ignore - } - } - options.setSelectedSessionId(null); - options.setMessages([]); - options.setTodos([]); - options.setPendingPermissions([]); - options.setSessionStatusById({}); - options.setSseConnected(false); - - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - - options.setView("session"); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function reloadWorkspaceEngine() { - if (!isTauriRuntime()) { - options.setError("Reloading the engine requires the desktop app."); - return false; - } - - if (selectedWorkspaceDisplay().workspaceType !== "local") { - options.setError("Reload is only available for local workers."); - return false; - } - - const root = selectedWorkspacePath().trim(); - if (!root) { - options.setError("Pick a worker folder first."); - return false; - } - - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.reloading_engine"); - options.setBusyStartedAt(Date.now()); - - try { - const runtime = engine()?.runtime ?? resolveEngineRuntime(); - if (runtime === "openwork-orchestrator") { - await orchestratorInstanceDispose(root); - await orchestratorWorkspaceActivate({ - workspacePath: root, - name: selectedWorkspaceInfo()?.displayName?.trim() || selectedWorkspaceInfo()?.name?.trim() || null, - }); - - const nextInfo = await engineInfo(); - setEngine(nextInfo); - - const username = nextInfo.opencodeUsername?.trim() ?? ""; - const password = nextInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (nextInfo.baseUrl) { - const ok = await connectToServer( - nextInfo.baseUrl, - root, - { workspaceType: "local", targetRoot: root, reason: "engine-reload-orchestrator" }, - auth, - ); - if (!ok) { - options.setError("Failed to reconnect after reload"); - return false; - } - } - - return true; - } - - const info = await engineStop(); - setEngine(info); - - const nextInfo = await engineStart(root, { - preferSidecar: options.engineSource() === "sidecar", - opencodeBinPath: - options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - opencodeEnableExa: options.opencodeEnableExa?.() ?? false, - openworkRemoteAccess: options.openworkServer.openworkServerSettings().remoteAccessEnabled === true, - runtime, - workspacePaths: resolveWorkspacePaths(), - }); - setEngine(nextInfo); - - const username = nextInfo.opencodeUsername?.trim() ?? ""; - const password = nextInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (nextInfo.baseUrl) { - const ok = await connectToServer( - nextInfo.baseUrl, - root, - { workspaceType: "local", targetRoot: root, reason: "engine-reload" }, - auth, - ); - if (!ok) { - options.setError("Failed to reconnect after reload"); - return false; - } - } - - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function onInstallEngine() { - options.setError(null); - setEngineInstallLogs(null); - options.setBusy(true); - options.setBusyLabel("status.installing_opencode"); - options.setBusyStartedAt(Date.now()); - - try { - const result = await engineInstall(); - const combined = `${result.stdout}${result.stderr ? `\n${result.stderr}` : ""}`.trim(); - setEngineInstallLogs(combined || null); - - if (!result.ok) { - options.setError(result.stderr.trim() || t("app.error.install_failed", currentLocale())); - } - - await refreshEngineDoctor(); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - function normalizeRoots(list: string[]) { - const out: string[] = []; - for (const entry of list) { - const trimmed = entry.trim().replace(/\/+$/, ""); - if (!trimmed) continue; - if (!out.includes(trimmed)) out.push(trimmed); - } - return out; - } - - async function resolveWorkspacePath(input: string) { - const trimmed = input.trim(); - if (!trimmed) return ""; - if (!isTauriRuntime()) return trimmed; - - if (trimmed === "~") { - try { - return (await homeDir()).replace(/[\\/]+$/, ""); - } catch { - return trimmed; - } - } - - if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { - try { - const home = (await homeDir()).replace(/[\\/]+$/, ""); - return `${home}${trimmed.slice(1)}`; - } catch { - return trimmed; - } - } - - return trimmed; - } - - async function persistAuthorizedRoots(nextRoots: string[]) { - if (!isTauriRuntime()) return; - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - const root = selectedWorkspacePath().trim(); - if (!root) return; - - const existing = workspaceConfig(); - const cfg: WorkspaceOpenworkConfig = { - version: existing?.version ?? 1, - workspace: existing?.workspace ?? null, - authorizedRoots: nextRoots, - blueprint: existing?.blueprint ?? null, - reload: existing?.reload ?? null, - }; - - const persistedViaServer = await persistWorkspaceConfigToOpenworkServer(cfg).catch(() => false); - if (!persistedViaServer) { - await workspaceOpenworkWrite({ workspacePath: root, config: cfg }); - } - setWorkspaceConfig(cfg); - } - - async function addAuthorizedDir() { - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - const next = newAuthorizedDir().trim(); - if (!next) return; - - const roots = normalizeRoots([...authorizedDirs(), next]); - setAuthorizedDirs(roots); - setNewAuthorizedDir(""); - - try { - await persistAuthorizedRoots(roots); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - async function addAuthorizedDirFromPicker(optionsOverride?: { persistToWorkspace?: boolean }) { - if (!isTauriRuntime()) return; - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - - try { - const selection = await pickDirectory({ title: t("onboarding.authorize_folder", currentLocale()) }); - const folder = - typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - if (!folder) return; - - const roots = normalizeRoots([...authorizedDirs(), folder]); - setAuthorizedDirs(roots); - - if (optionsOverride?.persistToWorkspace) { - await persistAuthorizedRoots(roots); - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - async function removeAuthorizedDir(dir: string) { - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - const roots = normalizeRoots(authorizedDirs().filter((root) => root !== dir)); - setAuthorizedDirs(roots); - - try { - await persistAuthorizedRoots(roots); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - function removeAuthorizedDirAtIndex(index: number) { - const roots = authorizedDirs(); - const target = roots[index]; - if (target) { - void removeAuthorizedDir(target); - } - } - - function restoreLastSession() { - const map = options.readLastSessionByWorkspace?.() ?? {}; - const workspaceId = selectedWorkspaceId().trim(); - if (!workspaceId) return; - const lastSessionId = map[workspaceId]?.trim(); - if (!lastSessionId) return; - if (options.selectedSessionId() === lastSessionId) return; - options.setSelectedSessionId(lastSessionId); - options.setView("session", lastSessionId); - void options.selectSession(lastSessionId, { skipHealthCheck: true, source: "restore-last-session" }); - } - - async function bootstrapOnboarding() { - const enterPhase = (phase: BootPhase, detail?: Record) => { - options.onBootPhaseChange?.(phase, detail); - options.onStartupTrace?.(`phase:${phase}`, detail); - }; - const markBranch = (branch: StartupBranch, detail?: Record) => { - options.onStartupBranch?.(branch, detail); - options.onStartupTrace?.(`branch:${branch}`, detail); - }; - - const startupPref = readStartupPreference(); - let info: EngineInfo | null = null; - - if (isTauriRuntime()) { - enterPhase("workspaceBootstrap", { source: "workspace_bootstrap" }); - try { - const ws = await workspaceBootstrap(); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws)); - } catch (error) { - options.onStartupTrace?.("workspace_bootstrap:error", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - } - } - - enterPhase("engineProbe", { source: "ts-probe" }); - void refreshEngine().catch(() => undefined); - info = engine(); - void refreshEngineDoctor().catch(() => undefined); - - if (isTauriRuntime() && workspaces().length === 0) { - markBranch("firstRunNoWorkspace", { startupPref }); - options.setStartupPreference("local"); - const welcomeFolder = await resolveFirstRunWelcomeFolder(); - const ok = await createWorkspaceFlow("starter", welcomeFolder); - if (!ok) { - options.setOnboardingStep("local"); - } - enterPhase("ready", { reason: "first-run-no-workspace" }); - return; - } - - if (isTauriRuntime()) { - const active = workspaces().find((w) => w.id === selectedWorkspaceId()) ?? null; - if (active) { - if (active.workspaceType === "remote") { - setProjectDir(active.directory?.trim() ?? ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - if (active.baseUrl) { - options.setBaseUrl(active.baseUrl); - } - } else { - setProjectDir(active.path); - try { - const cfg = await workspaceOpenworkRead({ workspacePath: active.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - setAuthorizedDirs(roots.length ? roots : [active.path]); - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([active.path]); - } - - } - } - } - - const localEngine = info ?? engine(); - if (localEngine?.baseUrl) { - options.setBaseUrl(localEngine.baseUrl); - } - - const activeWorkspace = selectedWorkspaceInfo(); - if (isTauriRuntime() && !localEngine?.baseUrl) { - const firstLocalWorkspace = workspaces().find((workspace) => workspace.workspaceType === "local"); - if (firstLocalWorkspace?.path?.trim()) { - enterPhase("engineStartOrConnect", { source: "bootstrap-first-local-host-start" }); - await startHost({ workspacePath: firstLocalWorkspace.path.trim(), navigate: false }).catch(() => false); - info = engine(); - } - } - - if (activeWorkspace?.workspaceType === "remote") { - markBranch("remoteWorkspace", { workspaceId: activeWorkspace.id }); - options.setStartupPreference("server"); - options.setOnboardingStep("connecting"); - enterPhase("engineStartOrConnect", { source: "remote-activate" }); - const ok = await activateWorkspace(activeWorkspace.id); - if (!ok) { - options.setOnboardingStep("server"); - } else { - enterPhase("sessionIndexReady", { source: "remote-activate" }); - restoreLastSession(); - enterPhase("firstSessionReady", { source: "restore-last-session" }); - } - enterPhase("ready", { reason: "remote-workspace-branch" }); - return; - } - - if (startupPref) { - options.setStartupPreference(startupPref); - } - - if (startupPref === "server") { - markBranch("serverPreference", { startupPref }); - options.setOnboardingStep("server"); - enterPhase("ready", { reason: "server-preference" }); - return; - } - - if (selectedWorkspacePath().trim()) { - options.setStartupPreference("local"); - - if (localEngine?.running && localEngine.baseUrl) { - markBranch("localAttachExisting", { - baseUrl: localEngine.baseUrl, - }); - const bootstrapRoot = selectedWorkspacePath().trim() || localEngine.projectDir?.trim() || ""; - options.setOnboardingStep("connecting"); - enterPhase("engineStartOrConnect", { source: "bootstrap-local-attach" }); - const ok = await connectToServer( - localEngine.baseUrl, - bootstrapRoot || undefined, - { workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" }, - engineAuth() ?? undefined, - ); - if (!ok) { - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - enterPhase("error", { reason: "bootstrap-local-connect-failed" }); - return; - } - enterPhase("sessionIndexReady", { source: "bootstrap-local-attach" }); - restoreLastSession(); - enterPhase("firstSessionReady", { source: "restore-last-session" }); - enterPhase("ready", { reason: "bootstrap-local-attach" }); - return; - } - - markBranch("localHostStart", { workspacePath: selectedWorkspacePath().trim() }); - options.setOnboardingStep("connecting"); - enterPhase("engineStartOrConnect", { source: "bootstrap-local-host-start" }); - const ok = await startHost({ workspacePath: selectedWorkspacePath().trim() }); - if (!ok) { - options.setOnboardingStep("local"); - enterPhase("error", { reason: "bootstrap-local-host-start-failed" }); - return; - } - enterPhase("sessionIndexReady", { source: "bootstrap-local-host-start" }); - restoreLastSession(); - enterPhase("firstSessionReady", { source: "restore-last-session" }); - enterPhase("ready", { reason: "bootstrap-local-host-start" }); - return; - } - - if (startupPref === "local") { - markBranch("localPreference", { startupPref }); - options.setOnboardingStep("local"); - enterPhase("ready", { reason: "local-preference" }); - return; - } - - markBranch("welcome", { startupPref: startupPref ?? null }); - options.setOnboardingStep("welcome"); - enterPhase("ready", { reason: "default-welcome" }); - } - - function onSelectStartup(nextPref: StartupPreference) { - if (options.rememberStartupChoice()) { - writeStartupPreference(nextPref); - } - options.setStartupPreference(nextPref); - options.setOnboardingStep(nextPref === "local" ? "local" : "server"); - } - - function onBackToWelcome() { - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - } - - async function onStartHost() { - options.setStartupPreference("local"); - options.setOnboardingStep("connecting"); - const ok = await startHost({ workspacePath: selectedWorkspacePath().trim() }); - if (!ok) { - options.setOnboardingStep("local"); - } - } - - async function onAttachHost() { - options.setStartupPreference("local"); - options.setOnboardingStep("connecting"); - const attachRoot = selectedWorkspacePath().trim() || engine()?.projectDir?.trim() || ""; - const ok = await connectToServer( - engine()?.baseUrl ?? "", - attachRoot || undefined, - { workspaceType: "local", targetRoot: attachRoot, reason: "attach-local" }, - engineAuth() ?? undefined, - ); - if (!ok) { - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - } - } - - async function onConnectClient() { - options.setStartupPreference("server"); - options.setOnboardingStep("connecting"); - const settings = options.openworkServer.openworkServerSettings(); - const ok = await createRemoteWorkspaceFlow({ - openworkHostUrl: settings.urlOverride ?? null, - openworkToken: settings.token ?? null, - directory: options.clientDirectory().trim() ? options.clientDirectory().trim() : null, - displayName: null, - }); - if (!ok) { - options.setOnboardingStep("server"); - } - } - - function onRememberStartupToggle() { - if (typeof window === "undefined") return; - const next = !options.rememberStartupChoice(); - options.setRememberStartupChoice(next); - try { - if (next) { - const current = options.startupPreference(); - if (current === "local" || current === "server") { - writeStartupPreference(current); - } - } else { - clearStartupPreference(); - } - } catch { - // ignore - } - } - - return { - engine, - engineDoctorResult, - engineDoctorCheckedAt, - engineInstallLogs, - sandboxDoctorResult, - sandboxDoctorCheckedAt, - sandboxDoctorBusy, - sandboxPreflightBusy, - sandboxCreatePhase, - projectDir, - workspaces, - selectedWorkspaceId, - authorizedDirs, - newAuthorizedDir, - workspaceConfig, - workspaceConfigLoaded, - createWorkspaceOpen, - createRemoteWorkspaceOpen, - editRemoteWorkspaceOpen, - editRemoteWorkspaceId, - editRemoteWorkspaceError, - editRemoteWorkspaceDefaults, - renameWorkspaceOpen, - renameWorkspaceId, - renameWorkspaceName, - renameWorkspaceBusy, - setRenameWorkspaceName, - connectingWorkspaceId, - connectedWorkspaceId, - runtimeWorkspaceId, - runtimeWorkspaceConfig, - workspaceConnectionStateById, - exportingWorkspaceConfig, - importingWorkspaceConfig, - selectedWorkspaceInfo, - selectedWorkspaceDisplay, - selectedWorkspacePath, - selectedWorkspaceRoot, - runtimeWorkspaceRoot, - setCreateWorkspaceOpen, - setCreateRemoteWorkspaceOpen, - setProjectDir, - setAuthorizedDirs, - setNewAuthorizedDir, - setWorkspaceConfig, - setWorkspaceConfigLoaded, - setWorkspaces, - clearSelectedSessionSurface, - syncSelectedWorkspaceId: syncSelectedWorkspaceId, - workspaceRootForId, - selectWorkspace, - switchWorkspace, - refreshEngine, - refreshEngineDoctor, - activateWorkspace, - ensureRuntimeWorkspaceId, - testWorkspaceConnection, - connectToServer, - createWorkspaceFlow, - createSandboxFlow, - createRemoteWorkspaceFlow, - updateRemoteWorkspaceFlow, - updateWorkspaceDisplayName, - openRenameWorkspace, - closeRenameWorkspace, - saveRenameWorkspace, - openWorkspaceConnectionSettings, - closeWorkspaceConnectionSettings, - saveWorkspaceConnectionSettings, - forgetWorkspace, - recoverWorkspace, - pickWorkspaceFolder, - exportWorkspaceConfig, - importWorkspaceConfig, - startHost, - stopHost, - reloadWorkspaceEngine, - refreshRuntimeWorkspaceConfig, - bootstrapOnboarding, - onSelectStartup, - onBackToWelcome, - onStartHost, - onAttachHost, - onConnectClient, - onRememberStartupToggle, - onInstallEngine, - addAuthorizedDir, - addAuthorizedDirFromPicker, - removeAuthorizedDir, - removeAuthorizedDirAtIndex, - setEngineInstallLogs, - refreshSandboxDoctor, - sandboxCreateProgress, - lastSandboxCreateProgress, - clearSandboxCreateProgress, - workspaceDebugEvents, - clearWorkspaceDebugEvents, - }; -} diff --git a/apps/app/src/app/entry.tsx b/apps/app/src/app/entry.tsx deleted file mode 100644 index 9b51cef5..00000000 --- a/apps/app/src/app/entry.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/app/extensions/provider.tsx b/apps/app/src/app/extensions/provider.tsx deleted file mode 100644 index b53d0077..00000000 --- a/apps/app/src/app/extensions/provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { ExtensionsStore } from "../context/extensions"; - -const ExtensionsContext = createContext(); - -export function ExtensionsProvider(props: ParentProps<{ store: ExtensionsStore }>) { - return ( - - {props.children} - - ); -} - -export function useExtensions() { - const context = useContext(ExtensionsContext); - if (!context) { - throw new Error("useExtensions must be used within an ExtensionsProvider"); - } - return context; -} diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index e31eeda5..62773a79 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -44,6 +44,10 @@ body { height: 100%; } +#root { + height: 100%; +} + html { font-size: var(--openwork-font-size, 16px); } diff --git a/apps/app/src/app/lib/den-template-cache.ts b/apps/app/src/app/lib/den-template-cache.ts index 13b56645..b130b9c7 100644 --- a/apps/app/src/app/lib/den-template-cache.ts +++ b/apps/app/src/app/lib/den-template-cache.ts @@ -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(); -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); } diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 6cebc3bd..c1448ec0 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -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, diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts index ee24bb8f..c95c6ace 100644 --- a/apps/app/src/app/lib/opencode.ts +++ b/apps/app/src/app/lib/opencode.ts @@ -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, ); }; }; diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index b4fd4b51..e5c6ecb6 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -751,8 +751,22 @@ function buildAuthHeaders(token?: string, hostToken?: string, extra?: Record (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( options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {}, ): Promise { const url = `${baseUrl}${path}`; - const fetchImpl = resolveFetch(); + const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( fetchImpl, url, @@ -833,7 +847,7 @@ async function requestJsonRaw( options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {}, ): Promise> { 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, diff --git a/apps/app/src/app/lib/release-channels.ts b/apps/app/src/app/lib/release-channels.ts new file mode 100644 index 00000000..e423baac --- /dev/null +++ b/apps/app/src/app/lib/release-channels.ts @@ -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"; +} diff --git a/apps/app/src/app/lib/version-gate.ts b/apps/app/src/app/lib/version-gate.ts new file mode 100644 index 00000000..bf6cc53c --- /dev/null +++ b/apps/app/src/app/lib/version-gate.ts @@ -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 { + 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 { + if (!isUpdateAllowedByDesktopConfig(updateVersion, desktopConfig)) { + return false; + } + return isUpdateSupportedByDen(updateVersion); +} diff --git a/apps/app/src/app/lib/workspace-shell-layout.ts b/apps/app/src/app/lib/workspace-shell-layout.ts deleted file mode 100644 index f87589a1..00000000 --- a/apps/app/src/app/lib/workspace-shell-layout.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/app/src/app/pages/automations.tsx b/apps/app/src/app/pages/automations.tsx deleted file mode 100644 index 1c46435a..00000000 --- a/apps/app/src/app/pages/automations.tsx +++ /dev/null @@ -1,1043 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import type { ScheduledJob } from "../types"; -import { useAutomations } from "../automations/provider"; -import { usePlatform } from "../context/platform"; -import { formatRelativeTime, isTauriRuntime } from "../utils"; -import { t } from "../../i18n"; - -import { - BookOpen, - Brain, - Calendar, - Clock, - MessageSquare, - Play, - PlugZap, - Plus, - RefreshCw, - Search, - Sparkles, - Trash2, - TrendingUp, - Trophy, - X, -} from "lucide-solid"; -import { useStatusToasts, type AppStatusToastTone } from "../shell/status-toasts"; - -type AutomationsFilter = "all" | "scheduled" | "templates"; -type ScheduleMode = "daily" | "interval"; - -type AutomationTemplate = { - icon: any; - name: string; - description: string; - prompt: string; - scheduleMode: ScheduleMode; - scheduleTime?: string; - scheduleDays?: string[]; - intervalHours?: number; - badge: string; -}; - -const pageTitleClass = "text-[28px] font-semibold tracking-[-0.5px] text-dls-text"; -const sectionTitleClass = "text-[15px] font-medium tracking-[-0.2px] text-dls-text"; -const panelCardClass = - "rounded-[20px] border border-dls-border bg-dls-surface p-5 transition-all hover:border-dls-border hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)]"; -const pillButtonClass = - "inline-flex items-center justify-center gap-1.5 rounded-full px-4 py-2 text-[13px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.18)] disabled:cursor-not-allowed disabled:opacity-60"; -const pillPrimaryClass = `${pillButtonClass} bg-dls-accent text-white hover:bg-[var(--dls-accent-hover)]`; -const pillSecondaryClass = `${pillButtonClass} border border-dls-border bg-dls-surface text-dls-text hover:bg-dls-hover`; -const pillGhostClass = `${pillButtonClass} border border-dls-border bg-dls-surface text-dls-secondary hover:bg-dls-hover hover:text-dls-text`; -const tagClass = - "inline-flex items-center rounded-md border border-dls-border bg-dls-hover px-2 py-1 text-[11px] text-dls-secondary"; - -const DEFAULT_AUTOMATION_NAME = () => t("scheduled.default_automation_name"); -const DEFAULT_AUTOMATION_PROMPT = - "Scan recent commits and flag riskier diffs with the most important follow-ups."; -const DEFAULT_SCHEDULE_TIME = "09:00"; -const DEFAULT_SCHEDULE_DAYS = ["mo", "tu", "we", "th", "fr"]; -const DEFAULT_INTERVAL_HOURS = 6; - -const automationTemplates: AutomationTemplate[] = [ - { - icon: Calendar, - name: t("scheduled.tpl_daily_planning_name"), - description: t("scheduled.tpl_daily_planning_desc"), - prompt: - "Review my pending tasks and calendar, then draft a practical plan for today with top priorities and one follow-up reminder.", - scheduleMode: "daily", - scheduleTime: "08:30", - scheduleDays: ["mo", "tu", "we", "th", "fr"], - badge: t("scheduled.badge_weekday_morning"), - }, - { - icon: BookOpen, - name: t("scheduled.tpl_inbox_zero_name"), - description: t("scheduled.tpl_inbox_zero_desc"), - prompt: - "Summarize unread inbox messages, suggest priority order, and draft concise reply options for the top conversations.", - scheduleMode: "daily", - scheduleTime: "17:30", - scheduleDays: ["mo", "tu", "we", "th", "fr"], - badge: t("scheduled.badge_end_of_day"), - }, - { - icon: MessageSquare, - name: t("scheduled.tpl_meeting_prep_name"), - description: t("scheduled.tpl_meeting_prep_desc"), - prompt: - "Prepare meeting briefs for tomorrow with context, talking points, and questions to unblock decisions.", - scheduleMode: "daily", - scheduleTime: "18:00", - scheduleDays: ["mo", "tu", "we", "th", "fr"], - badge: t("scheduled.badge_weekday_evening"), - }, - { - icon: TrendingUp, - name: t("scheduled.tpl_weekly_wins_name"), - description: t("scheduled.tpl_weekly_wins_desc"), - prompt: - "Summarize the week into wins, blockers, and clear next steps I can share with the team.", - scheduleMode: "daily", - scheduleTime: "16:00", - scheduleDays: ["fr"], - badge: t("scheduled.badge_friday_wrapup"), - }, - { - icon: Trophy, - name: t("scheduled.tpl_learning_digest_name"), - description: t("scheduled.tpl_learning_digest_desc"), - prompt: - "Collect my saved links and notes, then draft a weekly learning digest with key ideas and follow-up actions.", - scheduleMode: "daily", - scheduleTime: "10:00", - scheduleDays: ["su"], - badge: t("scheduled.badge_weekend_review"), - }, - { - icon: Brain, - name: t("scheduled.tpl_habit_checkin_name"), - description: t("scheduled.tpl_habit_checkin_desc"), - prompt: - "Ask me for a quick progress check-in, capture blockers, and suggest one concrete next action.", - scheduleMode: "interval", - intervalHours: 6, - badge: t("scheduled.badge_every_few_hours"), - }, -]; - -const dayOptions = [ - { id: "mo", label: () => t("scheduled.day_mon"), cron: "1" }, - { id: "tu", label: () => t("scheduled.day_tue"), cron: "2" }, - { id: "we", label: () => t("scheduled.day_wed"), cron: "3" }, - { id: "th", label: () => t("scheduled.day_thu"), cron: "4" }, - { id: "fr", label: () => t("scheduled.day_fri"), cron: "5" }, - { id: "sa", label: () => t("scheduled.day_sat"), cron: "6" }, - { id: "su", label: () => t("scheduled.day_sun"), cron: "0" }, -]; - -export type AutomationsViewProps = { - busy: boolean; - selectedWorkspaceRoot: string; - createSessionAndOpen: (initialPrompt?: string) => Promise | string | void; - newTaskDisabled: boolean; - schedulerInstalled: boolean; - canEditPlugins: boolean; - addPlugin: (pluginNameOverride?: string) => void; - reloadWorkspaceEngine: () => Promise; - reloadBusy: boolean; - canReloadWorkspace: boolean; - showHeader?: boolean; -}; - -const pad2 = (value: number) => String(value).padStart(2, "0"); - -const parseCronNumbers = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return [] as number[]; - const parts = trimmed.split(","); - const values = new Set(); - for (const part of parts) { - const segment = part.trim(); - if (!segment) continue; - if (segment.includes("-")) { - const [startRaw, endRaw] = segment.split("-"); - const start = Number.parseInt(startRaw ?? "", 10); - const end = Number.parseInt(endRaw ?? "", 10); - if (!Number.isFinite(start) || !Number.isFinite(end)) continue; - const lo = Math.min(start, end); - const hi = Math.max(start, end); - for (let i = lo; i <= hi; i += 1) values.add(i); - continue; - } - const num = Number.parseInt(segment, 10); - if (!Number.isFinite(num)) continue; - values.add(num); - } - return Array.from(values).sort((a, b) => a - b); -}; - -const humanizeCron = (cron: string) => { - const parts = cron.trim().split(/\s+/); - if (parts.length < 5) return t("scheduled.custom_schedule"); - const [minuteRaw, hourRaw, dom, mon, dowRaw] = parts; - if (!minuteRaw || !hourRaw || !dom || !mon || !dowRaw) return t("scheduled.custom_schedule"); - - if ( - minuteRaw === "0" && - hourRaw.startsWith("*/") && - dom === "*" && - mon === "*" && - dowRaw === "*" - ) { - const interval = Number.parseInt(hourRaw.slice(2), 10); - if (Number.isFinite(interval) && interval > 0) { - return interval === 1 ? t("scheduled.every_hour") : t("scheduled.every_n_hours", undefined, { interval }); - } - } - - const hour = Number.parseInt(hourRaw, 10); - const minute = Number.parseInt(minuteRaw, 10); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) return t("scheduled.custom_schedule"); - if (dom !== "*" || mon !== "*") return t("scheduled.custom_schedule"); - - const timeLabel = `${pad2(hour)}:${pad2(minute)}`; - - if (dowRaw === "*") { - return t("scheduled.every_day_at", undefined, { time: timeLabel }); - } - - const days = parseCronNumbers(dowRaw); - const normalized = new Set(days.map((d) => (d === 7 ? 0 : d))); - const allDays = [0, 1, 2, 3, 4, 5, 6]; - const weekdayDays = [1, 2, 3, 4, 5]; - const weekendDays = [0, 6]; - - if (allDays.every((d) => normalized.has(d))) return t("scheduled.every_day_at", undefined, { time: timeLabel }); - if ( - weekdayDays.every((d) => normalized.has(d)) && - !weekendDays.some((d) => normalized.has(d)) - ) { - return t("scheduled.weekdays_at", undefined, { time: timeLabel }); - } - if ( - weekendDays.every((d) => normalized.has(d)) && - !weekdayDays.some((d) => normalized.has(d)) - ) { - return t("scheduled.weekends_at", undefined, { time: timeLabel }); - } - - const labels: Record = { - 0: t("scheduled.day_sun"), - 1: t("scheduled.day_mon"), - 2: t("scheduled.day_tue"), - 3: t("scheduled.day_wed"), - 4: t("scheduled.day_thu"), - 5: t("scheduled.day_fri"), - 6: t("scheduled.day_sat"), - }; - - const list = Array.from(normalized) - .filter((d) => d >= 0 && d <= 6) - .sort((a, b) => a - b) - .map((d) => labels[d] ?? String(d)) - .join(", "); - - return list ? t("scheduled.days_at", undefined, { days: list, time: timeLabel }) : t("scheduled.at_time", undefined, { time: timeLabel }); -}; - -const buildCronFromDaily = (timeValue: string, days: string[]) => { - const [hour, minute] = timeValue.split(":"); - if (!hour || !minute) return ""; - const hourValue = Number.parseInt(hour, 10); - const minuteValue = Number.parseInt(minute, 10); - if (!Number.isFinite(hourValue) || !Number.isFinite(minuteValue)) return ""; - if (!days.length) return ""; - if (days.length === dayOptions.length) { - return `${minuteValue} ${hourValue} * * *`; - } - const daySpec = dayOptions - .filter((day) => days.includes(day.id)) - .map((day) => day.cron) - .join(","); - return daySpec ? `${minuteValue} ${hourValue} * * ${daySpec}` : ""; -}; - -const buildCronFromInterval = (hours: number) => { - if (!Number.isFinite(hours) || hours <= 0) return ""; - const interval = Math.max(1, Math.round(hours)); - return `0 */${interval} * * *`; -}; - -const taskSummary = (job: ScheduledJob) => { - const run = job.run; - if (run?.command) { - const args = run.arguments ? ` ${run.arguments}` : ""; - return `${run.command}${args}`; - } - const prompt = run?.prompt ?? job.prompt; - return prompt?.trim() || t("scheduled.task_summary_no_prompt"); -}; - -const toRelative = (value?: string | null) => { - if (!value) return t("scheduled.never"); - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) return t("scheduled.never"); - return formatRelativeTime(parsed); -}; - -const templateScheduleLabel = (template: AutomationTemplate) => { - if (template.scheduleMode === "interval") { - const interval = template.intervalHours ?? DEFAULT_INTERVAL_HOURS; - return interval === 1 ? t("scheduled.every_hour") : t("scheduled.every_n_hours", undefined, { interval }); - } - return humanizeCron( - buildCronFromDaily( - template.scheduleTime ?? DEFAULT_SCHEDULE_TIME, - template.scheduleDays ?? DEFAULT_SCHEDULE_DAYS, - ), - ); -}; - -const statusLabel = (status?: string | null) => { - if (!status) return t("scheduled.not_run_yet"); - if (status === "running") return t("scheduled.running_status"); - if (status === "success") return t("scheduled.success_status"); - if (status === "failed") return t("scheduled.failed_status"); - return status; -}; - -const statusTagClass = (status?: string | null) => { - if (status === "success") { - return "inline-flex items-center rounded-md border border-emerald-7/30 bg-emerald-3/40 px-2 py-1 text-[11px] text-emerald-11"; - } - if (status === "failed") { - return "inline-flex items-center rounded-md border border-red-7/30 bg-red-3/40 px-2 py-1 text-[11px] text-red-11"; - } - if (status === "running") { - return "inline-flex items-center rounded-md border border-amber-7/30 bg-amber-3/40 px-2 py-1 text-[11px] text-amber-11"; - } - return tagClass; -}; - -const TemplateCard = (props: { - template: AutomationTemplate; - disabled: boolean; - onUse: () => void; -}) => { - const Icon = props.template.icon; - return ( -
-
-
- -
-
-

{props.template.name}

-

- {props.template.description} -

-
- {props.template.badge} - {templateScheduleLabel(props.template)} -
-
-
- -
- {t("scheduled.template_badge")} - -
-
- ); -}; - -const JobCard = (props: { - job: ScheduledJob; - busy: boolean; - sourceLabel: string; - onRun: () => void; - onDelete: () => void; -}) => { - const summary = createMemo(() => taskSummary(props.job)); - const scheduleLabel = createMemo(() => humanizeCron(props.job.schedule)); - const status = createMemo(() => props.job.lastRunStatus ?? null); - - return ( -
-
-
- -
-
-
-

{props.job.name}

- {statusLabel(status())} -
-

- {summary()} -

-
- {scheduleLabel()} - {props.sourceLabel} - - {props.job.source} - -
-
-
{t("scheduled.last_run_prefix")} {toRelative(props.job.lastRunAt)}
-
{t("scheduled.created_prefix")} {toRelative(props.job.createdAt)}
-
-
-
- -
- {t("scheduled.filter_scheduled")} -
- - -
-
-
- ); -}; - -export default function AutomationsView(props: AutomationsViewProps) { - const automations = useAutomations(); - const platform = usePlatform(); - const statusToasts = useStatusToasts(); - - const [searchQuery, setSearchQuery] = createSignal(""); - const [activeFilter, setActiveFilter] = createSignal("all"); - const [installingScheduler, setInstallingScheduler] = createSignal(false); - const [schedulerInstallRequested, setSchedulerInstallRequested] = createSignal(false); - const [deleteTarget, setDeleteTarget] = createSignal(null); - const [deleteBusy, setDeleteBusy] = createSignal(false); - const [deleteError, setDeleteError] = createSignal(null); - const [createModalOpen, setCreateModalOpen] = createSignal(false); - const [createBusy, setCreateBusy] = createSignal(false); - const [createError, setCreateError] = createSignal(null); - const [automationName, setAutomationName] = createSignal(DEFAULT_AUTOMATION_NAME()); - const [automationPrompt, setAutomationPrompt] = createSignal(DEFAULT_AUTOMATION_PROMPT); - const [scheduleMode, setScheduleMode] = createSignal("daily"); - const [scheduleTime, setScheduleTime] = createSignal(DEFAULT_SCHEDULE_TIME); - const [scheduleDays, setScheduleDays] = createSignal([...DEFAULT_SCHEDULE_DAYS]); - const [intervalHours, setIntervalHours] = createSignal(DEFAULT_INTERVAL_HOURS); - const [lastUpdatedNow, setLastUpdatedNow] = createSignal(Date.now()); - - createEffect(() => { - if (typeof window === "undefined") return; - const interval = window.setInterval(() => setLastUpdatedNow(Date.now()), 1_000); - onCleanup(() => window.clearInterval(interval)); - }); - - createEffect(() => { - if (props.schedulerInstalled) { - setSchedulerInstallRequested(false); - } - }); - - const showToast = (title: string, tone: AppStatusToastTone = "info") => { - statusToasts.showToast({ title, tone }); - }; - - const resetDraft = (template?: AutomationTemplate) => { - setAutomationName(template?.name ?? DEFAULT_AUTOMATION_NAME()); - setAutomationPrompt(template?.prompt ?? DEFAULT_AUTOMATION_PROMPT); - setScheduleMode(template?.scheduleMode ?? "daily"); - setScheduleTime(template?.scheduleTime ?? DEFAULT_SCHEDULE_TIME); - setScheduleDays([...(template?.scheduleDays ?? DEFAULT_SCHEDULE_DAYS)]); - setIntervalHours(template?.intervalHours ?? DEFAULT_INTERVAL_HOURS); - setCreateError(null); - }; - - const supported = createMemo(() => { - if (automations.jobsSource() === "remote") return true; - return isTauriRuntime() && props.schedulerInstalled && !schedulerInstallRequested(); - }); - - const schedulerGateActive = createMemo(() => { - if (automations.jobsSource() !== "local") return false; - if (!isTauriRuntime()) return false; - return !props.schedulerInstalled || schedulerInstallRequested(); - }); - - const automationDisabled = createMemo( - () => props.newTaskDisabled || schedulerGateActive() || createBusy(), - ); - - const sourceLabel = createMemo(() => - automations.jobsSource() === "remote" ? t("scheduled.source_remote") : t("scheduled.source_local"), - ); - - const sourceDescription = createMemo(() => - automations.jobsSource() === "remote" - ? t("scheduled.subtitle_remote") - : t("scheduled.subtitle_local"), - ); - - const supportNote = createMemo(() => { - if (automations.jobsSource() === "remote") return null; - if (!isTauriRuntime()) return t("scheduled.desktop_required"); - if (!props.schedulerInstalled || schedulerInstallRequested()) return null; - return null; - }); - - const lastUpdatedLabel = createMemo(() => { - lastUpdatedNow(); - if (!automations.jobsUpdatedAt()) return t("scheduled.not_synced_yet"); - return formatRelativeTime(automations.jobsUpdatedAt() as number); - }); - - const filteredJobs = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - const items = automations.jobs(); - if (!query) return items; - return items.filter((job) => { - const summary = taskSummary(job).toLowerCase(); - const schedule = humanizeCron(job.schedule).toLowerCase(); - return ( - job.name.toLowerCase().includes(query) || - summary.includes(query) || - schedule.includes(query) - ); - }); - }); - - const filteredTemplates = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - if (!query) return automationTemplates; - return automationTemplates.filter((template) => { - return ( - template.name.toLowerCase().includes(query) || - template.description.toLowerCase().includes(query) || - template.badge.toLowerCase().includes(query) - ); - }); - }); - - const showJobsSection = createMemo(() => activeFilter() !== "templates"); - const showTemplatesSection = createMemo(() => activeFilter() !== "scheduled"); - - const cronExpression = createMemo(() => { - if (scheduleMode() === "interval") { - return buildCronFromInterval(intervalHours()); - } - return buildCronFromDaily(scheduleTime(), scheduleDays()); - }); - - const cronPreviewLabel = createMemo(() => { - const cron = cronExpression(); - return cron ? humanizeCron(cron) : null; - }); - - const openSchedulerDocs = () => { - platform.openLink("https://github.com/different-ai/opencode-scheduler"); - }; - - const refreshJobs = () => { - if (props.busy) return; - void automations.refresh({ force: true }); - }; - - const handleInstallScheduler = async () => { - if (installingScheduler() || !props.canEditPlugins) return; - setInstallingScheduler(true); - setSchedulerInstallRequested(true); - try { - await Promise.resolve(props.addPlugin("opencode-scheduler")); - showToast(t("scheduled.scheduler_install_requested"), "success"); - } catch (error) { - setSchedulerInstallRequested(false); - showToast( - error instanceof Error ? error.message : t("scheduled.prepare_error_fallback"), - "error", - ); - } finally { - setInstallingScheduler(false); - } - }; - - const openCreateModal = () => { - if (automationDisabled()) return; - resetDraft(); - setCreateModalOpen(true); - }; - - const openCreateModalFromTemplate = (template: AutomationTemplate) => { - if (automationDisabled()) return; - resetDraft(template); - setCreateModalOpen(true); - }; - - const closeCreateModal = () => { - setCreateModalOpen(false); - setCreateError(null); - setCreateBusy(false); - }; - - const handleCreateAutomation = async () => { - if (automationDisabled()) return; - const plan = automations.prepareCreateAutomation({ - name: automationName(), - prompt: automationPrompt(), - schedule: cronExpression(), - workdir: props.selectedWorkspaceRoot, - }); - if (!plan.ok) { - setCreateError(plan.error); - return; - } - - setCreateBusy(true); - setCreateError(null); - try { - await Promise.resolve(props.createSessionAndOpen(plan.prompt)); - setCreateModalOpen(false); - showToast(t("scheduled.prepared_automation_in_chat"), "success"); - } catch (error) { - setCreateError( - error instanceof Error ? error.message : t("scheduled.prepare_error_fallback"), - ); - } finally { - setCreateBusy(false); - } - }; - - const handleRunAutomation = async (job: ScheduledJob) => { - if (!supported() || props.busy) return; - const plan = automations.prepareRunAutomation(job, props.selectedWorkspaceRoot); - if (!plan.ok) { - showToast(plan.error, "warning"); - return; - } - await Promise.resolve(props.createSessionAndOpen(plan.prompt)); - showToast(t("scheduled.prepared_job_in_chat", undefined, { name: job.name }), "success"); - }; - - const confirmDelete = async () => { - const target = deleteTarget(); - if (!target) return; - setDeleteBusy(true); - setDeleteError(null); - try { - await automations.remove(target.slug); - setDeleteTarget(null); - showToast(t("scheduled.removed_job", undefined, { name: target.name }), "success"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setDeleteError(message || t("scheduled.delete_error_fallback")); - } finally { - setDeleteBusy(false); - } - }; - - const toggleDay = (id: string) => { - setScheduleDays((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return Array.from(next); - }); - }; - - const updateIntervalHours = (value: string) => { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) return; - const bounded = Math.min(24, Math.max(1, parsed)); - setIntervalHours(bounded); - }; - - const jobsEmptyMessage = createMemo(() => { - const query = searchQuery().trim(); - if (query) return t("scheduled.no_automations_match", undefined, { query }); - if (schedulerGateActive()) return t("scheduled.install_scheduler_hint"); - return t("scheduled.empty_hint"); - }); - - return ( -
-
-
-
- -

{t("scheduled.title")}

-
-

- {t("scheduled.page_description")} -

-
- -
- - - -
-
- -
-
- - setSearchQuery(event.currentTarget.value)} - placeholder={t("scheduled.search_placeholder")} - class="w-full rounded-xl border border-dls-border bg-dls-surface py-3 pl-11 pr-4 text-[14px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" - /> -
- -
- - {(filter) => ( - - )} - -
-
-
- - -
-
-
-
- -
-
-
- {props.schedulerInstalled - ? t("scheduled.reload_activate_title") - : t("scheduled.install_scheduler_title")} -
-

- {props.schedulerInstalled - ? t("scheduled.reload_activate_hint") - : t("scheduled.install_scheduler_hint")} -

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

{t("scheduled.your_automations")}

-

{sourceDescription()}

-
-
- {sourceLabel()} · {t("scheduled.last_updated_prefix")} {lastUpdatedLabel()} -
-
- - - {jobsEmptyMessage()} -
- } - > -
-
- - {(job) => ( - void handleRunAutomation(job)} - onDelete={() => setDeleteTarget(job)} - /> - )} - -
-
-
- - - - -
-
-
-

{t("scheduled.quick_start_templates")}

-

- {t("scheduled.quick_start_templates_desc")} -

-
-
{t("scheduled.template_count", undefined, { count: filteredTemplates().length })}
-
- - - {t("scheduled.no_templates_match")} -
- } - > -
-
- - {(template) => ( - openCreateModalFromTemplate(template)} - /> - )} - -
-
-
- - - - -
-
-
-
-

{t("scheduled.delete_confirm_title")}

-

- {t("scheduled.delete_confirm_desc", undefined, { source: sourceLabel().toLowerCase() })} -

-
- -
- {deleteTarget()?.name} -
- -
- - -
-
-
-
-
- - -
-
-
-
-
{t("scheduled.create_title")}
-

- {t("scheduled.create_desc")} -

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