From f927f4771a38c079c5eeb48c0d883d5a4f59c9bd Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 29 Jan 2026 17:08:59 -0800 Subject: [PATCH 1/7] fix(notion): reduce reload friction (#322) --- packages/app/pr/notion-connection-fix.md | 145 ++++++++++++++++++ packages/app/src/app/app.tsx | 7 + .../app/src/app/components/mcp-auth-modal.tsx | 4 +- packages/app/src/app/system-state.ts | 15 +- 4 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 packages/app/pr/notion-connection-fix.md diff --git a/packages/app/pr/notion-connection-fix.md b/packages/app/pr/notion-connection-fix.md new file mode 100644 index 000000000..09d6e84ca --- /dev/null +++ b/packages/app/pr/notion-connection-fix.md @@ -0,0 +1,145 @@ +# Notion Connection: Investigate Double-Restart Issue + +**Branch:** `feat/notion-connection-fix` +**Priority:** P2 + +--- + +## Problem + +From demo: "First time we connect Notion just didn't work. It says no write access... Second time we got CSRF error and third time it worked" + +The connection flow requires multiple restart attempts: +1. Connect → "Write request denied" +2. Restart → CSRF error +3. Restart again → Finally works + +--- + +## Hypothesis + +The config needs to restart twice: +- Once to detect the MCP is configured +- Second time to actually activate the connection with valid tokens + +### Code findings +- MCP auth modal: `packages/app/src/app/components/mcp-auth-modal.tsx:12` +- Auth flow calls OpenCode MCP endpoints: `mcp-auth-modal.tsx:155,180,311` +- Auth modal blocks when reload required: `mcp-auth-modal.tsx:139` +- Notion quick-connect flow: `packages/app/src/app/app.tsx:2177` + - Writes Notion MCP config: `app.tsx:2222` + - Marks reload required: `app.tsx:2246` + - Sets localStorage `openwork.notionStatus`: `app.tsx:2246` +- On startup, `openwork.notionStatus === "connecting"` triggers reload-required: `app.tsx:2817` +- MCP status refresh uses OpenCode `client.mcp.status`: `app.tsx:2294` +- File watcher emits reload-required for config changes: `packages/desktop/src-tauri/src/workspace/watch.rs:99-124` +- UI listener converts event to `markReloadRequired`: `packages/app/src/app/app.tsx:1868` + +--- + +## Investigation Steps + +### 1. Trace the OAuth flow + +**Files to check:** +- `packages/app/src/app/components/mcp-auth-modal.tsx` +- Server-side MCP handling + +Questions: +- When OAuth completes, where is the token saved? +- Does the app know the token is saved? +- Is there a race condition between token save and config reload? + +Add checks around `openwork.notionStatus` transitions to confirm if status ever clears after the first reload. + +### 2. Check what happens on first restart + +Add logging: +```tsx +// In MCP status handling +console.log('[MCP] Notion config:', notionConfig); +console.log('[MCP] Notion status:', notionStatus); +console.log('[MCP] Has token:', !!notionToken); +``` + +### 3. Check CSRF error source + +- Is this from Notion's OAuth? +- Is this from OpenWork server? +- Is there stale state from previous connection attempt? + +Check if reload-required is firing twice (explicit `markReloadRequired("mcp")` + file watcher event) right after OAuth. + +### 4. Check server restart logic + +**Files:** +- `packages/desktop/src-tauri/src/openwork_server/spawn.rs` +- OpenCode server handling + +Questions: +- Does restart fully clear MCP state? +- Is there caching that persists across restarts? + +--- + +## Potential Fixes + +### Option A: Auto-restart after OAuth + +After OAuth token is saved, automatically trigger a server reload: +```tsx +const handleOAuthComplete = async () => { + await saveToken(); + // Automatically reload to pick up new token + await reloadWorkspace(); +}; +``` + +### Option B: Hot-reload MCP connections + +Instead of full restart, implement hot-reload for MCP: +```tsx +const refreshMcpConnection = async (name: string) => { + // Disconnect existing + await mcpDisconnect(name); + // Reconnect with new config + await mcpConnect(name); +}; +``` + +### Option C: Fix the state mismatch + +If the issue is stale state, ensure clean state on connection attempt: +```tsx +const connectNotion = async () => { + // Clear any cached state + clearMcpCache("notion"); + // Proceed with fresh connection + await initiateOAuth("notion"); +}; +``` + +**Also consider:** clearing `openwork.notionStatus` after successful engine reload to avoid repeated reload prompts on startup. + +--- + +## Reproduction Steps + +1. Fresh install or clear Notion connection +2. Go to MCP settings +3. Click "Connect Notion" +4. Complete OAuth flow +5. Observe error +6. Click Reload +7. Check if working +8. If not, reload again + +Track what state changes at each step. + +--- + +## Success Criteria + +- Notion connection works on first attempt after OAuth +- No need to restart multiple times +- Clear error messages if something goes wrong diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index cc21988a9..93b0b8b0d 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -1809,6 +1809,13 @@ export default function App() { const markReloadRequired = (reason: ReloadReason, options?: { force?: boolean }) => { if (booting() || reloadBusy()) return; + if (!options?.force) { + const existingReasons = reloadReasons(); + if (reloadRequired() && existingReasons.includes(reason)) return; + if (reloadRequired() && reason === "config" && existingReasons.includes("mcp")) { + return; + } + } if (!options?.force) { const label = busyLabel(); if ( diff --git a/packages/app/src/app/components/mcp-auth-modal.tsx b/packages/app/src/app/components/mcp-auth-modal.tsx index 1669d891c..3833180b6 100644 --- a/packages/app/src/app/components/mcp-auth-modal.tsx +++ b/packages/app/src/app/components/mcp-auth-modal.tsx @@ -136,7 +136,8 @@ export default function McpAuthModal(props: McpAuthModalProps) { setAuthInProgress(true); try { - if (props.reloadRequired) { + const statusEntry = await fetchMcpStatus(slug); + if (props.reloadRequired && !statusEntry) { setNeedsReload(true); setReloadNotice( props.reloadBlocked @@ -146,7 +147,6 @@ export default function McpAuthModal(props: McpAuthModalProps) { return; } - const statusEntry = await fetchMcpStatus(slug); if (statusEntry?.status === "connected") { setAlreadyConnected(true); return; diff --git a/packages/app/src/app/system-state.ts b/packages/app/src/app/system-state.ts index 7d7565de7..0572e32cc 100644 --- a/packages/app/src/app/system-state.ts +++ b/packages/app/src/app/system-state.ts @@ -262,16 +262,25 @@ export function createSystemState(options: { if (nextStatus === "connecting") { nextStatus = "connected"; options.notion.setStatus(nextStatus); + options.notion.setStatusDetail("Workspace connected"); } if (nextStatus === "connected") { - options.notion.setStatusDetail(options.notion.statusDetail() ?? "Workspace connected"); + const detail = options.notion.statusDetail(); + if (!detail || detail.toLowerCase().includes("reload")) { + options.notion.setStatusDetail("Workspace connected"); + } } try { window.localStorage.setItem("openwork.notionStatus", nextStatus); - if (nextStatus === "connected" && options.notion.statusDetail()) { - window.localStorage.setItem("openwork.notionStatusDetail", options.notion.statusDetail() || ""); + if (nextStatus === "connected") { + const detail = options.notion.statusDetail(); + if (detail) { + window.localStorage.setItem("openwork.notionStatusDetail", detail); + } else { + window.localStorage.removeItem("openwork.notionStatusDetail"); + } } } catch { // ignore From ed2ce0d423b797520d0a8ef32688980398b5fdd1 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 29 Jan 2026 17:09:07 -0800 Subject: [PATCH 2/7] docs(skill): add trigger phrase guidance (#321) --- packages/app/pr/skill-creator-triggers.md | 111 +++++++++++++++++++++ packages/app/src/app/data/skill-creator.md | 32 ++++++ 2 files changed, 143 insertions(+) create mode 100644 packages/app/pr/skill-creator-triggers.md diff --git a/packages/app/pr/skill-creator-triggers.md b/packages/app/pr/skill-creator-triggers.md new file mode 100644 index 000000000..bc4f576c6 --- /dev/null +++ b/packages/app/pr/skill-creator-triggers.md @@ -0,0 +1,111 @@ +# Skill Creator: Better Trigger Selection + +**Branch:** `feat/skill-creator-triggers` +**Priority:** P2 + +--- + +## Problem + +Skills don't auto-trigger reliably because the "when to use" descriptions in frontmatter are weak and vague. + +From demo: "I should have triggered it earlier. So that's something I need to I guess." + +--- + +## Root Cause + +The skill creator doesn't emphasize writing strong, specific trigger phrases. Users write vague descriptions like "when adding content" instead of specific triggers like "when user mentions 'content pipeline' or 'add to my content database'". + +--- + +## Location + +**Primary file in OpenWork:** `packages/app/src/app/data/skill-creator.md` + +**Also update:** `vendor/openwork-enterprise` skill-creator template (same guidance, different repo) + +The skill creator needs to: +1. Explicitly ask for trigger phrases +2. Show examples of good vs bad triggers +3. Validate trigger specificity + +### Code findings +- Template file: `packages/app/src/app/data/skill-creator.md:1` +- Used in skill creation flow: `packages/app/src/app/context/extensions.ts:600,660` +- Frontmatter parsing: `packages/server/src/frontmatter.ts:1` +- Frontmatter validation: `packages/server/src/skills.ts:83` and `packages/server/src/validators.ts:7` +- Example of strong trigger phrasing: `.opencode/skills/get-started/SKILL.md:6` ("Always load this skill when the user says …") + +--- + +## Changes to Skill Creator + +### Add trigger phrase guidance + +In the skill creator flow, add explicit step: + +```markdown +## Step: Define Trigger Phrases + +The description field is HOW Claude decides when to use your skill. +It must include specific trigger phrases. + +**Bad example:** +> "Use when working with content" + +**Good examples:** +> "Use when user mentions 'content pipeline', 'add to content database', or 'schedule a post'" +> "Triggers on: 'rotate PDF', 'flip PDF pages', 'change PDF orientation'" + +Write 2-3 specific phrases that should trigger this skill: +``` + +### Update frontmatter template + +```yaml +--- +name: my-skill +description: | + [What it does in one sentence] + + Triggers when user mentions: + - "[specific phrase 1]" + - "[specific phrase 2]" + - "[specific phrase 3]" +--- +``` + +### Add validation + +When skill is created, warn if description lacks specific trigger phrases: +- Must contain at least one quoted phrase or specific keyword +- Should be >50 chars +- Should include "when" or "triggers" language + +--- + +## UI Changes (Context Panel) + +Also update context-panel.tsx to display trigger phrases prominently. + +The skill subtitle should show the trigger phrase, not the generic description: +```tsx +// Extract trigger from description +const extractTrigger = (description: string): string | null => { + // Look for "Triggers when" or "Use when" patterns + const match = description.match(/(?:triggers?|use) when[:\s]+(.+?)(?:\.|$)/i); + return match?.[1]?.trim() ?? null; +}; + +const subtitle = extractTrigger(skill.description) ?? skill.description?.slice(0, 60); +``` + +--- + +## Testing + +1. Create a skill with vague description → verify warning shown +2. Create a skill with specific triggers → verify no warning +3. Check context panel → verify trigger phrase shown as subtitle +4. Use the skill → verify it triggers on the specified phrases diff --git a/packages/app/src/app/data/skill-creator.md b/packages/app/src/app/data/skill-creator.md index 745fe8e38..27cded3bf 100644 --- a/packages/app/src/app/data/skill-creator.md +++ b/packages/app/src/app/data/skill-creator.md @@ -30,6 +30,38 @@ A skill is a folder under `.opencode/skills//` or `.claude/skills/ Date: Thu, 29 Jan 2026 17:09:30 -0800 Subject: [PATCH 3/7] feat(context): improve context panel clarity (#319) --- packages/app/pr/context-panel-ux.md | 199 ++++++++++++++++++ packages/app/src/app/app.tsx | 9 +- .../app/components/session/context-panel.tsx | 82 +++++++- packages/app/src/app/context/extensions.ts | 2 + packages/app/src/app/lib/openwork-server.ts | 1 + packages/app/src/app/lib/tauri.ts | 1 + packages/app/src/app/pages/session.tsx | 37 ++++ packages/app/src/app/types.ts | 1 + packages/app/src/app/utils/index.ts | 9 +- .../desktop/src-tauri/src/commands/skills.rs | 89 +++++++- packages/server/src/skills.ts | 36 +++- packages/server/src/types.ts | 1 + 12 files changed, 446 insertions(+), 21 deletions(-) create mode 100644 packages/app/pr/context-panel-ux.md diff --git a/packages/app/pr/context-panel-ux.md b/packages/app/pr/context-panel-ux.md new file mode 100644 index 000000000..6c8782c1f --- /dev/null +++ b/packages/app/pr/context-panel-ux.md @@ -0,0 +1,199 @@ +# Context Panel: Collapse + Better Subtitles + +**Branch:** `feat/context-panel-ux` +**Priority:** P0 (collapse) / P1 (subtitles) + +--- + +## Problems + +1. Too many sections expanded at once - overwhelming +2. File names show just basename, not enough to differentiate duplicates +3. Skill subtitles show vague descriptions, not trigger phrases +4. Files not clickable + +### Code findings +- Context panel render: `packages/app/src/app/components/session/context-panel.tsx:116-285` +- Working files list is derived in utils: `packages/app/src/app/utils/index.ts:591` (currently uses `item.name` only) +- Artifacts include full paths: `packages/app/src/app/utils/index.ts:518` +- `SkillCard` type (name/path/description): `packages/app/src/app/types.ts:150` +- Frontmatter parsing on server: `packages/server/src/frontmatter.ts:1` and `packages/server/src/skills.ts:33` +- Existing open/reveal pattern: `packages/app/src/app/pages/mcp.tsx:154` + +--- + +## Changes + +### 1. Default collapsed sections + +**File:** `packages/app/src/app/app.tsx` L1912-1920 + +```tsx +// BEFORE +const [expandedSidebarSections, setExpandedSidebarSections] = createSignal({ + progress: true, + artifacts: true, + context: true, + plugins: true, + mcp: true, + skills: true, + authorizedFolders: true, +}); + +// AFTER +const [expandedSidebarSections, setExpandedSidebarSections] = createSignal({ + progress: true, // Keep: shows active task progress + artifacts: true, // Keep: shows outputs + context: false, // Collapse: working files can be overwhelming + plugins: false, // Collapse: not actionable for new users + mcp: false, // Collapse: technical detail + skills: true, // Keep: this is the key value prop + authorizedFolders: false, // Collapse: technical detail +}); +``` + +--- + +### 2. Smarter file path display + +**File:** `packages/app/src/app/utils/index.ts` + `packages/app/src/app/components/session/context-panel.tsx` + +**Problem:** Two files with same basename show identically: +``` +/foo/bar/skill.md → skill.md +/baz/qux/skill.md → skill.md +``` + +**Solution:** Prefer workspace-relative path (or minimal unique path) using `item.path` from `deriveArtifacts` rather than `item.name`. + +Update `deriveWorkingFiles`: +```tsx +// packages/app/src/app/utils/index.ts:591 +const deriveWorkingFiles = (artifacts: ArtifactItem[]) => + artifacts + .filter((item) => item.category === "file" && item.path) + .map((item) => item.path ?? item.name); +``` + +Then apply minimal-unique display in the panel: +```tsx +const getSmartFileName = (files: string[], file: string): string => { + const basename = file.split(/[/\\]/).pop() ?? file; + + // Check if basename is unique + const duplicates = files.filter(f => + (f.split(/[/\\]/).pop() ?? f) === basename + ); + + if (duplicates.length === 1) { + return basename; + } + + // Find minimum path segments needed to differentiate + const segments = file.split(/[/\\]/); + for (let i = 2; i <= segments.length; i++) { + const shortPath = segments.slice(-i).join('/'); + const isUnique = duplicates.every(d => { + const dSegments = d.split(/[/\\]/); + return dSegments.slice(-i).join('/') !== shortPath || d === file; + }); + if (isUnique) return shortPath; + } + + return file; // Fallback to full path +}; +``` + +Use in the file list: +```tsx + + {(file) => ( +
+ + + {getSmartFileName(props.workingFiles, file)} + +
+ )} +
+``` + +--- + +### 3. Make files clickable + +**File:** `context-panel.tsx` + +Wrap file items in buttons that open/focus the file: +```tsx + + {(file) => ( + + )} + +``` + +Add prop to `ContextPanelProps`: +```tsx +onFileClick?: (path: string) => void; +``` + +Hook implementation suggestion: +- Use `@tauri-apps/plugin-opener` (`openPath` / `revealItemInDir`) similar to `mcp.tsx:154` +- If remote workspace, disable clicks or show toast + +--- + +### 4. Better skill subtitles + +**File:** `context-panel.tsx` L262-280 + +**Current:** Shows `skill.description` (often vague) + +**Should show:** The "when to use" trigger phrase from frontmatter + +**Note:** Frontmatter parsing happens server-side (`packages/server/src/skills.ts:33`). To show triggers, either: +1) Add a `trigger` field to `SkillCard` by parsing description or a new frontmatter field, or +2) Parse trigger phrases client-side from `description`. + +```tsx + + {(skill) => { + const label = humanizeSkill(skill.name) || skill.name; + // Prefer trigger phrase if available, else truncated description + const subtitle = skill.trigger ?? skill.description?.slice(0, 60); + return ( +
+ +
+
{label}
+ +
+ {subtitle} +
+
+
+
+ ); + }} +
+``` + +--- + +## Testing + +1. Open session → verify only progress, artifacts, skills expanded +2. Add two files with same basename → verify paths differentiate them +3. Click on a file → verify it opens/focuses +4. Check skill subtitles → verify they show useful trigger info diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 93b0b8b0d..361a25585 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -1950,11 +1950,11 @@ export default function App() { const [expandedSidebarSections, setExpandedSidebarSections] = createSignal({ progress: true, artifacts: true, - context: true, - plugins: true, - mcp: true, + context: false, + plugins: false, + mcp: false, skills: true, - authorizedFolders: true, + authorizedFolders: false, }); const [appVersion, setAppVersion] = createSignal(null); @@ -3592,6 +3592,7 @@ export default function App() { setTab, setSettingsTab, activeWorkspaceDisplay: activeWorkspaceDisplay(), + activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), setWorkspaceSearch: workspaceStore.setWorkspaceSearch, setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen, mode: mode(), diff --git a/packages/app/src/app/components/session/context-panel.tsx b/packages/app/src/app/components/session/context-panel.tsx index d2aa927f0..e89ffe40a 100644 --- a/packages/app/src/app/components/session/context-panel.tsx +++ b/packages/app/src/app/components/session/context-panel.tsx @@ -15,6 +15,7 @@ export type ContextPanelProps = { skillsStatus: string | null; authorizedDirs: string[]; workingFiles: string[]; + workspaceRoot?: string; expandedSections: { context: boolean; plugins: boolean; @@ -23,6 +24,7 @@ export type ContextPanelProps = { authorizedFolders: boolean; }; onToggleSection: (section: "context" | "plugins" | "mcp" | "skills" | "authorizedFolders") => void; + onFileClick?: (path: string) => void; }; const humanizePlugin = (name: string) => { @@ -63,6 +65,48 @@ const humanizeSkill = (name: string) => { .trim(); }; +const splitPathSegments = (value: string) => value.split(/[/\\]/).filter(Boolean); + +const toWorkspaceRelative = (file: string, root?: string) => { + const normalizedRoot = (root ?? "").trim().replace(/[\\/]+/g, "/").replace(/\/+$/, ""); + if (!normalizedRoot) return file; + const normalizedFile = file.replace(/[\\/]+/g, "/"); + const rootKey = normalizedRoot.toLowerCase(); + const fileKey = normalizedFile.toLowerCase(); + if (fileKey === rootKey) return normalizedFile.split("/").pop() ?? normalizedFile; + if (fileKey.startsWith(`${rootKey}/`)) { + return normalizedFile.slice(normalizedRoot.length + 1); + } + return normalizedFile; +}; + +const getSmartFileName = (files: string[], file: string): string => { + if (!file) return ""; + const segments = splitPathSegments(file); + const basename = segments[segments.length - 1] ?? file; + + const duplicates = files.filter((candidate) => { + const candidateSegments = splitPathSegments(candidate); + return (candidateSegments[candidateSegments.length - 1] ?? candidate) === basename; + }); + + if (duplicates.length <= 1) { + return basename; + } + + for (let i = 2; i <= segments.length; i += 1) { + const shortPath = segments.slice(-i).join("/"); + const isUnique = duplicates.every((candidate) => { + if (candidate === file) return true; + const candidateSegments = splitPathSegments(candidate); + return candidateSegments.slice(-i).join("/") !== shortPath; + }); + if (isUnique) return shortPath; + } + + return file; +}; + const mcpStatusLabel = (status?: McpStatus, disabled?: boolean) => { if (disabled) return "Disabled"; if (!status) return "Disconnected"; @@ -97,6 +141,9 @@ const mcpStatusDot = (status?: McpStatus, disabled?: boolean) => { }; export default function ContextPanel(props: ContextPanelProps) { + const displayFiles = () => + props.workingFiles.map((entry) => toWorkspaceRelative(entry, props.workspaceRoot)); + return (
@@ -123,12 +170,27 @@ export default function ContextPanel(props: ContextPanelProps) { fallback={
None yet.
} > - {(file) => ( -
- - {file} -
- )} + {(file) => { + const displayPath = () => toWorkspaceRelative(file, props.workspaceRoot); + const label = () => getSmartFileName(displayFiles(), displayPath()); + const canOpen = () => typeof props.onFileClick === "function"; + return ( + + ); + }}
@@ -263,14 +325,16 @@ export default function ContextPanel(props: ContextPanelProps) { {(skill) => { const label = humanizeSkill(skill.name) || skill.name; const description = skill.description?.trim(); + const trigger = skill.trigger?.trim(); + const subtitle = trigger || description; return (
{label}
- -
- {description} + +
+ {subtitle}
diff --git a/packages/app/src/app/context/extensions.ts b/packages/app/src/app/context/extensions.ts index e2f1e41a8..c6482bed6 100644 --- a/packages/app/src/app/context/extensions.ts +++ b/packages/app/src/app/context/extensions.ts @@ -129,6 +129,7 @@ export function createExtensionsStore(options: { name: entry.name, description: entry.description, path: entry.path, + trigger: entry.trigger, })) : []; setSkills(next); @@ -176,6 +177,7 @@ export function createExtensionsStore(options: { name: entry.name, description: entry.description, path: entry.path, + trigger: entry.trigger, })) : []; diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts index 9c3257c5d..01a8f0de9 100644 --- a/packages/app/src/app/lib/openwork-server.ts +++ b/packages/app/src/app/lib/openwork-server.ts @@ -39,6 +39,7 @@ export type OpenworkSkillItem = { path: string; description: string; scope: "project" | "global"; + trigger?: string; }; export type OpenworkCommandItem = { diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index 16b6b4820..ed706a9f8 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -352,6 +352,7 @@ export type LocalSkillCard = { name: string; path: string; description?: string; + trigger?: string; }; export async function listLocalSkills(projectDir: string): Promise { diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index afe22c9eb..716961e6c 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -35,12 +35,14 @@ import WorkspaceChip from "../components/workspace-chip"; import ProviderAuthModal from "../components/provider-auth-modal"; import StatusBar from "../components/status-bar"; import type { OpenworkServerStatus } from "../lib/openwork-server"; +import { join } from "@tauri-apps/api/path"; import MessageList from "../components/session/message-list"; import Composer from "../components/session/composer"; import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar"; import ContextPanel from "../components/session/context-panel"; import FlyoutItem from "../components/flyout-item"; +import { isTauriRuntime } from "../utils"; export type SessionViewProps = { selectedSessionId: string | null; @@ -48,6 +50,7 @@ export type SessionViewProps = { setTab: (tab: DashboardTab) => void; setSettingsTab: (tab: SettingsTab) => void; activeWorkspaceDisplay: WorkspaceDisplay; + activeWorkspaceRoot: string; setWorkspaceSearch: (value: string) => void; setWorkspacePickerOpen: (open: boolean) => void; mode: "host" | "client" | null; @@ -147,6 +150,38 @@ export default function SessionView(props: SessionViewProps) { const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent"); + const isAbsolutePath = (value: string) => + /^(?:[a-zA-Z]:[\\/]|\\\\|\/|~\/)/.test(value.trim()); + + const handleWorkingFileClick = async (file: string) => { + const trimmed = file.trim(); + if (!trimmed) return; + + if (props.activeWorkspaceDisplay.workspaceType === "remote") { + setCommandToast("File open is unavailable for remote workspaces."); + return; + } + + if (!isTauriRuntime()) { + setCommandToast("File open is available in the desktop app."); + return; + } + + try { + const { openPath } = await import("@tauri-apps/plugin-opener"); + const root = props.activeWorkspaceRoot.trim(); + if (!isAbsolutePath(trimmed) && !root) { + setCommandToast("Pick a workspace to open files."); + return; + } + const target = !isAbsolutePath(trimmed) && root ? await join(root, trimmed) : trimmed; + await openPath(target); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to open file"; + setCommandToast(message); + } + }; + const loadAgentOptions = async (force = false) => { if (agentPickerBusy()) return agentOptions(); if (agentPickerReady() && !force) return agentOptions(); @@ -1127,6 +1162,7 @@ export default function SessionView(props: SessionViewProps) { skillsStatus={props.skillsStatus} authorizedDirs={props.authorizedDirs} workingFiles={props.workingFiles} + workspaceRoot={props.activeWorkspaceRoot} expandedSections={props.expandedSidebarSections} onToggleSection={(section) => props.setExpandedSidebarSections((curr) => ({ @@ -1134,6 +1170,7 @@ export default function SessionView(props: SessionViewProps) { [section]: !curr[section], })) } + onFileClick={handleWorkingFileClick} />
diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index 0680cd7a0..9f9dc1da9 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -151,6 +151,7 @@ export type SkillCard = { name: string; path: string; description?: string; + trigger?: string; }; export type PluginInstallStep = { diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index efbdfbaec..3f304b954 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -594,10 +594,11 @@ export function deriveWorkingFiles(items: ArtifactItem[]): string[] { for (const item of items) { const rawKey = item.path ?? item.name; - const normalized = rawKey.trim().replace(/[\\/]+/g, "/").toLowerCase(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - results.push(item.name); + const normalizedPath = rawKey.trim().replace(/[\\/]+/g, "/"); + const normalizedKey = normalizedPath.toLowerCase(); + if (!normalizedPath || seen.has(normalizedKey)) continue; + seen.add(normalizedKey); + results.push(normalizedPath); if (results.length >= 5) break; } diff --git a/packages/desktop/src-tauri/src/commands/skills.rs b/packages/desktop/src-tauri/src/commands/skills.rs index 8281e85e0..2bca6e5ed 100644 --- a/packages/desktop/src-tauri/src/commands/skills.rs +++ b/packages/desktop/src-tauri/src/commands/skills.rs @@ -164,6 +164,88 @@ pub struct LocalSkillCard { pub name: String, pub path: String, pub description: Option, + pub trigger: Option, +} + +fn extract_frontmatter_value(raw: &str, keys: &[&str]) -> Option { + let mut lines = raw.lines(); + let first = lines.next()?.trim(); + if first != "---" { + return None; + } + + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + break; + } + if trimmed.is_empty() { + continue; + } + let Some((key, value)) = trimmed.split_once(':') else { + continue; + }; + if !keys.iter().any(|candidate| candidate.eq_ignore_ascii_case(key.trim())) { + continue; + } + let mut cleaned = value.trim().to_string(); + if (cleaned.starts_with('"') && cleaned.ends_with('"')) + || (cleaned.starts_with('\'') && cleaned.ends_with('\'')) + { + if cleaned.len() >= 2 { + cleaned = cleaned[1..cleaned.len() - 1].to_string(); + } + } + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + continue; + } + return Some(cleaned.to_string()); + } + + None +} + +fn extract_trigger(raw: &str) -> Option { + if let Some(frontmatter) = extract_frontmatter_value(raw, &["trigger", "when"]) { + return Some(frontmatter); + } + + let mut in_frontmatter = false; + let mut in_when_section = false; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed == "---" { + in_frontmatter = !in_frontmatter; + continue; + } + if in_frontmatter { + continue; + } + if trimmed.starts_with('#') { + let heading = trimmed.trim_start_matches('#').trim(); + in_when_section = heading.eq_ignore_ascii_case("When to use"); + continue; + } + if !in_when_section { + continue; + } + + let cleaned = trimmed + .trim_start_matches(|c: char| c == '-' || c == '*' || c == '+') + .trim_start_matches(|c: char| c.is_whitespace()) + .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')') + .trim(); + if !cleaned.is_empty() { + return Some(cleaned.to_string()); + } + } + + None } fn extract_description(raw: &str) -> Option { @@ -221,15 +303,16 @@ pub fn list_local_skills(project_dir: String) -> Result, Str continue; }; - let description = match fs::read_to_string(path.join("SKILL.md")) { - Ok(raw) => extract_description(&raw), - Err(_) => None, + let (description, trigger) = match fs::read_to_string(path.join("SKILL.md")) { + Ok(raw) => (extract_description(&raw), extract_trigger(&raw)), + Err(_) => (None, None), }; out.push(LocalSkillCard { name: name.to_string(), path: path.to_string_lossy().to_string(), description, + trigger, }); } diff --git a/packages/server/src/skills.ts b/packages/server/src/skills.ts index 77651a9da..00d60063e 100644 --- a/packages/server/src/skills.ts +++ b/packages/server/src/skills.ts @@ -22,6 +22,33 @@ async function findWorkspaceRoots(workspaceRoot: string): Promise { return roots; } +const extractTriggerFromBody = (body: string) => { + const lines = body.split(/\r?\n/); + let inWhenSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (/^#{1,6}\s+/.test(trimmed)) { + const heading = trimmed.replace(/^#{1,6}\s+/, "").trim(); + inWhenSection = /^when to use$/i.test(heading); + continue; + } + + if (!inWhenSection) continue; + + const cleaned = trimmed + .replace(/^[-*+]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .trim(); + + if (cleaned) return cleaned; + } + + return ""; +}; + async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise { if (!(await exists(dir))) return []; const entries = await readdir(dir, { withFileTypes: true }); @@ -31,9 +58,15 @@ async function listSkillsInDir(dir: string, scope: "project" | "global"): Promis const skillPath = join(dir, entry.name, "SKILL.md"); if (!(await exists(skillPath))) continue; const content = await readFile(skillPath, "utf8"); - const { data } = parseFrontmatter(content); + const { data, body } = parseFrontmatter(content); const name = typeof data.name === "string" ? data.name : entry.name; const description = typeof data.description === "string" ? data.description : ""; + const trigger = + typeof data.trigger === "string" + ? data.trigger + : typeof data.when === "string" + ? data.when + : extractTriggerFromBody(body); try { validateSkillName(name); validateDescription(description); @@ -46,6 +79,7 @@ async function listSkillsInDir(dir: string, scope: "project" | "global"): Promis description, path: skillPath, scope, + trigger: trigger.trim() || undefined, }); } return items; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 2409452a2..ef43cb65d 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -76,6 +76,7 @@ export interface SkillItem { path: string; description: string; scope: "project" | "global"; + trigger?: string; } export interface CommandItem { From 45fefd94664b20890dc9fd8ca944714a49fa6528 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 29 Jan 2026 17:09:40 -0800 Subject: [PATCH 4/7] feat(reload): persist reload toast details (#318) --- packages/app/pr/reload-toast-persist.md | 157 ++++++++++++++++++ packages/app/src/app/app.tsx | 144 +++++++++++----- .../app/components/reload-workspace-toast.tsx | 38 ++++- packages/app/src/app/context/extensions.ts | 20 ++- packages/app/src/app/context/session.ts | 63 ++++++- packages/app/src/app/system-state.ts | 22 ++- packages/app/src/app/types.ts | 7 + 7 files changed, 394 insertions(+), 57 deletions(-) create mode 100644 packages/app/pr/reload-toast-persist.md diff --git a/packages/app/pr/reload-toast-persist.md b/packages/app/pr/reload-toast-persist.md new file mode 100644 index 000000000..330c78c46 --- /dev/null +++ b/packages/app/pr/reload-toast-persist.md @@ -0,0 +1,157 @@ +# Reload Toast: Persist + Show What Changed + +**Branch:** `feat/reload-toast-persist` +**Priority:** P0 + +--- + +## Problems + +1. Toast disappears before user can click +2. Toast is vague - just says "Reload required" without specifics + +--- + +## Current Code + +**File:** `packages/app/src/app/app.tsx` L1799-1834 + +```tsx +const [reloadToastDismissedAt, setReloadToastDismissedAt] = createSignal(null); + +const reloadToastVisible = createMemo(() => { + if (!reloadRequired()) return false; // <-- Hides when reload not required + const lastTriggeredAt = reloadLastTriggeredAt(); + const dismissedAt = reloadToastDismissedAt(); + if (!lastTriggeredAt) return true; + if (!dismissedAt) return true; + return dismissedAt < lastTriggeredAt; +}); +``` + +### Code findings +- Reload state + reasons live in `packages/app/src/app/system-state.ts:40-45` (`reloadRequired`, `reloadReasons`, `reloadLastTriggeredAt`) +- `markReloadRequired(reason)` only stores the reason string: `packages/app/src/app/system-state.ts:135-139` +- `ReloadReason` is a fixed union: `packages/app/src/app/types.ts:202` (`"plugins" | "skills" | "mcp" | "config"`) +- Tauri watcher emits `openwork://reload-required` with `{ reason, path }`: `packages/desktop/src-tauri/src/workspace/watch.rs:99-124` +- UI listener drops `path` and only maps `reason`: `packages/app/src/app/app.tsx:1870-1902` +- Explicit triggers: + - Skills/plugins: `packages/app/src/app/context/extensions.ts:526,549,588,669,750` + - MCP: `packages/app/src/app/app.tsx:2557` + - Config/model change: `packages/app/src/app/app.tsx:3020,3034` + - Tool-driven file writes: `packages/app/src/app/context/session.ts:141-193` + +--- + +## Changes Required + +### 1. Investigate why toast disappears + +Add logging to track state changes: +```tsx +createEffect(() => { + console.log('[ReloadToast] reloadRequired:', reloadRequired()); + console.log('[ReloadToast] lastTriggeredAt:', reloadLastTriggeredAt()); + console.log('[ReloadToast] dismissedAt:', reloadToastDismissedAt()); +}); +``` + +Possible causes: +- `reloadRequired()` becomes false prematurely (auto-reload?) +- Page navigation clears state +- Effect at L1830-1834 clears state + +**Other auto-clear sites:** +- `clearReloadRequired()` in `packages/app/src/app/system-state.ts:141-145` +- Successful reload clears state in `packages/app/src/app/system-state.ts:281-282` +- Workspace change clears reload state in `packages/app/src/app/app.tsx:1928-1933` + +### 2. Track what triggered the reload + +**Update `markReloadRequired` to accept details:** + +```tsx +type ReloadTrigger = { + type: "skill" | "plugin" | "config" | "mcp"; + name?: string; +}; + +const [reloadTrigger, setReloadTrigger] = createSignal(null); + +const markReloadRequired = (reason: ReloadReason, trigger?: ReloadTrigger) => { + markReloadRequiredRaw(reason); + if (trigger) { + setReloadTrigger(trigger); + } +}; +``` + +**Note:** There is already a `path` in the Tauri event payload (`workspace/watch.rs:119-121`). We can parse it to a name (skill/plugin) and pass it as `trigger.name`. + +### 3. Update toast to show specific change + +**File:** `packages/app/src/app/components/reload-workspace-toast.tsx` + +Add `trigger` prop: +```tsx +export type ReloadWorkspaceToastProps = { + // ... existing props + trigger?: { type: string; name?: string } | null; +}; +``` + +Update description display: +```tsx +const getDescription = () => { + if (!props.trigger) return props.description; + const { type, name } = props.trigger; + switch (type) { + case "skill": + return `Skill '${name}' was added. Reload to use it.`; + case "plugin": + return `Plugin '${name}' was added. Reload to activate.`; + case "mcp": + return `MCP '${name}' was added. Reload to connect.`; + default: + return "Config changed. Reload to apply."; + } +}; +``` + +### 4. Update callers to pass trigger info + +Find all places that call `markReloadRequired` and add trigger details: +- When skill is created: `markReloadRequired("skills", { type: "skill", name: skillName })` +- When plugin is added: `markReloadRequired("plugins", { type: "plugin", name: pluginName })` +- When MCP is added: `markReloadRequired("mcp", { type: "mcp", name: mcpName })` + +**Concrete hook points:** +- Skills: `packages/app/src/app/context/extensions.ts:588,669,750` +- Plugins: `packages/app/src/app/context/extensions.ts:526,549` +- MCP: `packages/app/src/app/app.tsx:2557` +- Config/model changes: `packages/app/src/app/app.tsx:3020,3034` +- File watcher: `packages/app/src/app/app.tsx:1870-1902` (parse `event.payload.path`) + +--- + +## Toast behavior rules + +Toast should ONLY hide when: +1. User clicks "Dismiss" +2. User clicks "Reload" AND reload completes successfully + +Toast should NOT auto-hide on: +- Timeout +- Navigation +- Any automatic state change + +--- + +## Testing + +1. Create a skill → verify toast shows "Skill 'X' was added" +2. Add a plugin → verify toast shows "Plugin 'X' was added" +3. Change config → verify toast shows "Config changed" +4. Wait 30 seconds → verify toast still visible +5. Click Dismiss → verify toast hides +6. Trigger again → verify toast reappears diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 361a25585..90aba0484 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -61,6 +61,7 @@ import type { OnboardingStep, PluginScope, ReloadReason, + ReloadTrigger, ResetOpenworkMode, SettingsTab, SkillCard, @@ -462,7 +463,7 @@ export default function App() { const mountTime = Date.now(); const [lastKnownConfigSnapshot, setLastKnownConfigSnapshot] = createSignal(""); const [developerMode, setDeveloperMode] = createSignal(false); - let markReloadRequiredRef: (reason: ReloadReason) => void = () => {}; + let markReloadRequiredRef: (reason: ReloadReason, trigger?: ReloadTrigger) => void = () => {}; let setReloadLastFinishedAtRef: (value: number) => void = () => {}; const [selectedSessionId, setSelectedSessionId] = createSignal( @@ -505,7 +506,7 @@ export default function App() { developerMode, setError, setSseConnected, - markReloadRequired: (reason) => markReloadRequiredRef(reason), + markReloadRequired: (reason, trigger) => markReloadRequiredRef(reason, trigger), }); const { @@ -912,7 +913,7 @@ export default function App() { setBusyLabel, setBusyStartedAt, setError, - markReloadRequired: (reason) => markReloadRequiredRef(reason), + markReloadRequired: (reason, trigger) => markReloadRequiredRef(reason, trigger), onNotionSkillInstalled: () => { setNotionSkillInstalled(true); try { @@ -1775,6 +1776,7 @@ export default function App() { reloadLastTriggeredAt, reloadLastFinishedAt, setReloadLastFinishedAt, + reloadTrigger, reloadBusy, reloadError, canReloadEngine, @@ -1807,7 +1809,10 @@ export default function App() { anyActiveRuns, } = systemState; - const markReloadRequired = (reason: ReloadReason, options?: { force?: boolean }) => { + const markReloadRequired = ( + reason: ReloadReason, + options?: { force?: boolean; trigger?: ReloadTrigger }, + ) => { if (booting() || reloadBusy()) return; if (!options?.force) { const existingReasons = reloadReasons(); @@ -1831,7 +1836,46 @@ export default function App() { const lastFinishedAt = reloadLastFinishedAt(); if (lastFinishedAt && now - lastFinishedAt < 2000) return; } - markReloadRequiredRaw(reason); + markReloadRequiredRaw(reason, options?.trigger); + }; + + const extractReloadTriggerFromPath = (reason: ReloadReason, rawPath?: string): ReloadTrigger | null => { + if (!rawPath) return null; + const normalized = rawPath.replace(/\\/g, "/"); + const fileName = normalized.split("/").filter(Boolean).pop(); + + if (reason === "skills") { + const match = normalized.match(/\/\.opencode\/(?:skill|skills)\/([^/]+)/i); + return { + type: "skill", + name: match?.[1], + action: "updated", + path: rawPath, + }; + } + + if (reason === "plugins") { + return { + type: "plugin", + action: "updated", + path: rawPath, + }; + } + + if (reason === "mcp") { + return { + type: "mcp", + action: "updated", + path: rawPath, + }; + } + + return { + type: "config", + name: fileName, + action: "updated", + path: rawPath, + }; }; const [reloadToastDismissedAt, setReloadToastDismissedAt] = createSignal(null); @@ -1845,6 +1889,14 @@ export default function App() { return dismissedAt < lastTriggeredAt; }); + createEffect(() => { + if (!developerMode()) return; + console.log("[ReloadToast] reloadRequired:", reloadRequired()); + console.log("[ReloadToast] lastTriggeredAt:", reloadLastTriggeredAt()); + console.log("[ReloadToast] dismissedAt:", reloadToastDismissedAt()); + console.log("[ReloadToast] trigger:", reloadTrigger()); + }); + const reloadWarning = createMemo(() => anyActiveRuns() ? t("reload.toast_warning_active", currentLocale()) @@ -1874,39 +1926,49 @@ export default function App() { onMount(() => { if (!isTauriRuntime()) return; let unlisten: (() => void) | null = null; - void listen("openwork://reload-required", async (event: TauriEvent<{ reason?: string }>) => { - const rawReason = event.payload?.reason; - const reason: ReloadReason = - rawReason === "plugins" || - rawReason === "skills" || - rawReason === "config" || - rawReason === "mcp" - ? rawReason - : "config"; + void listen( + "openwork://reload-required", + async (event: TauriEvent<{ reason?: string; path?: string }>) => { + const rawReason = event.payload?.reason; + const reason: ReloadReason = + rawReason === "plugins" || + rawReason === "skills" || + rawReason === "config" || + rawReason === "mcp" + ? rawReason + : "config"; - if (reason === "config") { - const root = workspaceStore.activeWorkspacePath().trim(); - if (root) { - try { - const configFile = await readOpencodeConfig("project", root); - const nextSnapshot = getConfigSnapshot(configFile.content); - if (nextSnapshot === untrack(lastKnownConfigSnapshot)) { - // Only model (or nothing) changed. Update UI but skip reload toast. - const nextModel = parseDefaultModelFromConfig(configFile.content); - if (nextModel && !modelEquals(untrack(defaultModel), nextModel)) { - setDefaultModel(nextModel); + if (reason === "config") { + const root = workspaceStore.activeWorkspacePath().trim(); + if (root) { + try { + const configFile = await readOpencodeConfig("project", root); + const nextSnapshot = getConfigSnapshot(configFile.content); + if (nextSnapshot === untrack(lastKnownConfigSnapshot)) { + // Only model (or nothing) changed. Update UI but skip reload toast. + const nextModel = parseDefaultModelFromConfig(configFile.content); + if (nextModel && !modelEquals(untrack(defaultModel), nextModel)) { + setDefaultModel(nextModel); + } + return; } - return; + setLastKnownConfigSnapshot(nextSnapshot); + } catch { + // If we can't read/parse, fall back to showing the toast } - setLastKnownConfigSnapshot(nextSnapshot); - } catch { - // If we can't read/parse, fall back to showing the toast } } - } - markReloadRequired(reason, { force: false }); - }) + const trigger = + extractReloadTriggerFromPath(reason, event.payload?.path) ?? + { + type: reason === "plugins" ? "plugin" : reason === "skills" ? "skill" : reason, + action: "updated", + }; + + markReloadRequired(reason, { force: false, trigger }); + }, + ) .then((stop) => { unlisten = stop; }) @@ -1917,7 +1979,7 @@ export default function App() { }); }); - markReloadRequiredRef = (reason) => markReloadRequired(reason, { force: true }); + markReloadRequiredRef = (reason, trigger) => markReloadRequired(reason, { force: true, trigger }); setReloadLastFinishedAtRef = (value) => setReloadLastFinishedAt(value); const { @@ -1936,7 +1998,6 @@ export default function App() { if (!isTauriRuntime()) return; workspaceStore.activeWorkspaceId(); workspaceProjectDir(); - clearReloadRequired(); void refreshMcpServers(); }); @@ -2250,7 +2311,7 @@ export default function App() { } } - markReloadRequired("mcp"); + markReloadRequired("mcp", { trigger: { type: "mcp", name: "notion", action: "added" } }); setNotionStatusDetail(t("settings.reload_required", currentLocale())); try { window.localStorage.setItem("openwork.notionStatus", "connecting"); @@ -2561,7 +2622,7 @@ export default function App() { setMcpStatus(t("mcp.reload_required_after_add", currentLocale())); } - markReloadRequired("mcp"); + markReloadRequired("mcp", { trigger: { type: "mcp", name: slug, action: "added" } }); console.log("[connectMcp] ✓ marked reload required, refreshing servers"); await refreshMcpServers(); @@ -2839,7 +2900,7 @@ export default function App() { } if (storedNotionStatus === "connecting") { - markReloadRequired("mcp"); + markReloadRequired("mcp", { trigger: { type: "mcp", name: "notion", action: "added" } }); } await refreshMcpServers(); @@ -3024,7 +3085,9 @@ export default function App() { await openworkClient.patchConfig(openworkWorkspaceId, { opencode: { model: formatModelRef(nextModel) }, }); - markReloadRequired("config"); + markReloadRequired("config", { + trigger: { type: "config", name: "opencode.json", action: "updated" }, + }); return; } @@ -3038,7 +3101,9 @@ export default function App() { throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); } setLastKnownConfigSnapshot(getConfigSnapshot(content)); - markReloadRequired("config"); + markReloadRequired("config", { + trigger: { type: "config", name: "opencode.json", action: "updated" }, + }); } catch (error) { if (cancelled) return; const message = error instanceof Error ? error.message : safeStringify(error); @@ -3882,6 +3947,7 @@ export default function App() { open={reloadToastVisible()} title={t("reload.toast_title", currentLocale())} description={t("reload.toast_description", currentLocale())} + trigger={reloadTrigger()} warning={reloadWarning()} blockedReason={reloadBlockedReason()} error={reloadError()} diff --git a/packages/app/src/app/components/reload-workspace-toast.tsx b/packages/app/src/app/components/reload-workspace-toast.tsx index 04006872f..f611eb561 100644 --- a/packages/app/src/app/components/reload-workspace-toast.tsx +++ b/packages/app/src/app/components/reload-workspace-toast.tsx @@ -2,11 +2,13 @@ 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; @@ -20,6 +22,40 @@ export type ReloadWorkspaceToastProps = { }; 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."; + } + + return "Config changed. Reload to apply."; + }; + return (
@@ -57,7 +93,7 @@ export default function ReloadWorkspaceToast(props: ReloadWorkspaceToastProps) { ? Reloading will stop active tasks. : props.error ? {props.error} - : props.description + : getDescription() }
diff --git a/packages/app/src/app/context/extensions.ts b/packages/app/src/app/context/extensions.ts index c6482bed6..4032c75e7 100644 --- a/packages/app/src/app/context/extensions.ts +++ b/packages/app/src/app/context/extensions.ts @@ -4,7 +4,7 @@ import { applyEdits, modify } from "jsonc-parser"; import { join } from "@tauri-apps/api/path"; import { currentLocale, t } from "../../i18n"; -import type { Client, Mode, PluginScope, ReloadReason, SkillCard } from "../types"; +import type { Client, Mode, PluginScope, ReloadReason, ReloadTrigger, SkillCard } from "../types"; import { addOpencodeCacheHint, isTauriRuntime } from "../utils"; import skillCreatorTemplate from "../data/skill-creator.md?raw"; import { @@ -45,7 +45,7 @@ export function createExtensionsStore(options: { setBusyLabel: (value: string | null) => void; setBusyStartedAt: (value: number | null) => void; setError: (value: string | null) => void; - markReloadRequired: (reason: ReloadReason) => void; + markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void; onNotionSkillInstalled?: () => void; }) { // Translation helper that uses current language from i18n @@ -444,6 +444,7 @@ export function createExtensionsStore(options: { async function addPlugin(pluginNameOverride?: string) { const pluginName = (pluginNameOverride ?? pluginInput()).trim(); const isManualInput = pluginNameOverride == null; + const triggerName = stripPluginVersion(pluginName); const isRemoteWorkspace = options.workspaceType() === "remote"; const isHostMode = options.mode() === "host"; @@ -525,7 +526,7 @@ export function createExtensionsStore(options: { plugin: [pluginName], }; await writeOpencodeConfig(scope, targetDir, `${JSON.stringify(payload, null, 2)}\n`); - options.markReloadRequired("plugins"); + options.markReloadRequired("plugins", { type: "plugin", name: triggerName, action: "added" }); if (isManualInput) { setPluginInput(""); } @@ -548,7 +549,7 @@ export function createExtensionsStore(options: { const updated = applyEdits(raw, edits); await writeOpencodeConfig(scope, targetDir, updated); - options.markReloadRequired("plugins"); + options.markReloadRequired("plugins", { type: "plugin", name: triggerName, action: "added" }); if (isManualInput) { setPluginInput(""); } @@ -582,12 +583,17 @@ export function createExtensionsStore(options: { return; } + const inferredName = sourceDir.split(/[\\/]/).filter(Boolean).pop(); const result = await importSkill(targetDir, sourceDir, { overwrite: false }); if (!result.ok) { setSkillsStatus(result.stderr || result.stdout || translate("skills.import_failed").replace("{status}", String(result.status))); } else { setSkillsStatus(result.stdout || translate("skills.imported")); - options.markReloadRequired("skills"); + options.markReloadRequired("skills", { + type: "skill", + name: inferredName, + action: "added", + }); } await refreshSkills({ force: true }); @@ -668,7 +674,7 @@ export function createExtensionsStore(options: { setSkillsStatus(result.stderr || result.stdout || translate("skills.install_failed")); } else { setSkillsStatus(result.stdout || translate("skills.skill_creator_installed")); - options.markReloadRequired("skills"); + options.markReloadRequired("skills", { type: "skill", name: "skill-creator", action: "added" }); } await refreshSkills({ force: true }); @@ -749,7 +755,7 @@ export function createExtensionsStore(options: { setSkillsStatus(result.stderr || result.stdout || translate("skills.uninstall_failed")); } else { setSkillsStatus(result.stdout || translate("skills.uninstalled")); - options.markReloadRequired("skills"); + options.markReloadRequired("skills", { type: "skill", name: trimmed, action: "removed" }); } await refreshSkills({ force: true }); diff --git a/packages/app/src/app/context/session.ts b/packages/app/src/app/context/session.ts index 4cdfb7669..e21ceede1 100644 --- a/packages/app/src/app/context/session.ts +++ b/packages/app/src/app/context/session.ts @@ -12,6 +12,7 @@ import type { PendingPermission, PlaceholderAssistantMessage, ReloadReason, + ReloadTrigger, TodoItem, } from "../types"; import { @@ -112,7 +113,7 @@ export function createSessionStore(options: { developerMode: () => boolean; setError: (message: string | null) => void; setSseConnected: (connected: boolean) => void; - markReloadRequired?: (reason: ReloadReason) => void; + markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void; }) { const [store, setStore] = createStore({ sessions: [], @@ -127,6 +128,7 @@ export function createSessionStore(options: { const reloadDetectionSet = new Set(); const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i; + const skillNamePattern = /[\\/]\.opencode[\\/](?:skill|skills)[\\/]+([^\\/]+)/i; const opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i; const opencodePathPattern = /(?:^|[\\/])\.opencode[\\/]/i; const mutatingTools = new Set(["write", "edit", "apply_patch"]); @@ -147,6 +149,25 @@ export function createSessionStore(options: { return null; }; + const detectReloadTriggerFromText = (text: string): ReloadTrigger | null => { + if (skillPathPattern.test(text)) { + const match = text.match(skillNamePattern); + return { + type: "skill", + 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") { @@ -168,17 +189,43 @@ export function createSessionStore(options: { return null; }; - const detectReloadFromPart = (part: Part): ReloadReason | 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; - return ( + const reason = detectReloadReasonDeep(state.input) || detectReloadReasonDeep(state.patch) || - detectReloadReasonDeep(state.diff) - ); + 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) => { @@ -186,10 +233,10 @@ export function createSessionStore(options: { if (!part?.id || !part.messageID) return; const key = `${part.messageID}:${part.id}`; if (reloadDetectionSet.has(key)) return; - const reason = detectReloadFromPart(part); - if (!reason) return; + const detection = detectReloadFromPart(part); + if (!detection) return; reloadDetectionSet.add(key); - options.markReloadRequired(reason); + options.markReloadRequired(detection.reason, detection.trigger); }; const addError = (error: unknown, fallback = "Unknown error") => { diff --git a/packages/app/src/app/system-state.ts b/packages/app/src/app/system-state.ts index 0572e32cc..2ccb26cc5 100644 --- a/packages/app/src/app/system-state.ts +++ b/packages/app/src/app/system-state.ts @@ -6,7 +6,15 @@ import type { ProviderListItem } from "./types"; import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; -import type { Client, Mode, PluginScope, ReloadReason, ResetOpenworkMode, UpdateHandle } from "./types"; +import type { + Client, + Mode, + PluginScope, + ReloadReason, + ReloadTrigger, + ResetOpenworkMode, + UpdateHandle, +} from "./types"; import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils"; import { mapConfigProvidersToList } from "./utils/providers"; import { createUpdaterState } from "./context/updater"; @@ -41,6 +49,7 @@ export function createSystemState(options: { const [reloadReasons, setReloadReasons] = createSignal([]); const [reloadLastTriggeredAt, setReloadLastTriggeredAt] = createSignal(null); const [reloadLastFinishedAt, setReloadLastFinishedAt] = createSignal(null); + const [reloadTrigger, setReloadTrigger] = createSignal(null); const [reloadBusy, setReloadBusy] = createSignal(false); const [reloadError, setReloadError] = createSignal(null); @@ -132,16 +141,24 @@ export function createSystemState(options: { } } - function markReloadRequired(reason: ReloadReason) { + function markReloadRequired(reason: ReloadReason, trigger?: ReloadTrigger) { setReloadRequired(true); setReloadLastTriggeredAt(Date.now()); setReloadReasons((current) => (current.includes(reason) ? current : [...current, reason])); + if (trigger) { + setReloadTrigger(trigger); + } else { + setReloadTrigger({ + type: reason === "plugins" ? "plugin" : reason === "skills" ? "skill" : reason, + }); + } } function clearReloadRequired() { setReloadRequired(false); setReloadReasons([]); setReloadError(null); + setReloadTrigger(null); } const reloadCopy = createMemo(() => { @@ -466,6 +483,7 @@ export function createSystemState(options: { reloadLastTriggeredAt, reloadLastFinishedAt, setReloadLastFinishedAt, + reloadTrigger, reloadBusy, reloadError, reloadCopy, diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index 9f9dc1da9..bde1322aa 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -202,6 +202,13 @@ export type McpStatusMap = Record; export type ReloadReason = "plugins" | "skills" | "mcp" | "config"; +export type ReloadTrigger = { + type: "skill" | "plugin" | "config" | "mcp"; + name?: string; + action?: "added" | "removed" | "updated"; + path?: string; +}; + export type PendingPermission = ApiPermissionRequest & { receivedAt: number; }; From 9957f80fde382a54a07b3c9b01ce31aa2faa08aa Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 29 Jan 2026 17:33:52 -0800 Subject: [PATCH 5/7] chore: bump version to 0.7.3 --- packages/app/package.json | 2 +- packages/desktop/package.json | 2 +- packages/desktop/src-tauri/Cargo.lock | 2 +- packages/desktop/src-tauri/Cargo.toml | 2 +- packages/desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 4e15c4e36..f895a58da 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@different-ai/openwork-ui", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a5a7774fe..2682dc782 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@different-ai/openwork", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "scripts": { "dev": "tauri dev --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 88893fbeb..4ee965601 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2636,7 +2636,7 @@ dependencies = [ [[package]] name = "openwork" -version = "0.7.2" +version = "0.7.3" dependencies = [ "base64 0.22.1", "gethostname", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index f20098953..26539969b 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openwork" -version = "0.7.2" +version = "0.7.3" description = "OpenWork" authors = ["Different AI"] edition = "2021" diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 2ad9eb9a4..30aa3b7fd 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenWork", - "version": "0.7.2", + "version": "0.7.3", "identifier": "com.differentai.openwork", "build": { "beforeDevCommand": "pnpm -C ../.. --filter @different-ai/openwork run prepare:sidecar && pnpm -w dev:ui", From 4f146cad9c86ad24f42337e31d5e642cf78a7cf4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:05:33 -0800 Subject: [PATCH 6/7] chore(aur): update PKGBUILD for 0.7.3 (#323) Co-authored-by: OpenWork Release Bot --- packaging/aur/.SRCINFO | 6 +++--- packaging/aur/PKGBUILD | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index cca00d896..c0c62d864 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = openwork pkgdesc = An Open source alternative to Claude Cowork - pkgver = 0.7.2 + pkgver = 0.7.3 pkgrel = 2 url = https://github.com/different-ai/openwork arch = x86_64 @@ -14,7 +14,7 @@ pkgbase = openwork depends = dbus depends = librsvg noextract = openwork-0.3.6.deb - source = openwork-desktop-linux-amd64.deb::https://github.com/different-ai/openwork/releases/download/v0.7.2/openwork-desktop-linux-amd64.deb - sha256sums = 3415a83776019c5876b47ea9e0151c4838f0f22d4e0c2e4d6c02b917f8716ee0 + source = openwork-desktop-linux-amd64.deb::https://github.com/different-ai/openwork/releases/download/v0.7.3/openwork-desktop-linux-amd64.deb + sha256sums = 0de8efb8cd4cf8deb461ffd59765445e00d48b34a0952d56abb8408016cd14cd pkgname = openwork diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 7f795e949..2762ec25b 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,5 +1,5 @@ pkgname=openwork -pkgver=0.7.2 +pkgver=0.7.3 pkgrel=2 # pkgrel should change when PKGBUILD does. Standard is to change back to 1 next time. Any interger is valid. pkgdesc="An Open source alternative to Claude Cowork" arch=('x86_64') @@ -9,7 +9,7 @@ depends=('gtk3' 'glib2' 'libayatana-appindicator' 'libsoup3' 'webkit2gtk-4.1' 'o # Renaming to ${pkgname}-${pkgver}.deb source=("${pkgname}-${pkgver}.deb::${url}/releases/download/v${pkgver}/openwork-desktop-linux-amd64.deb") -sha256sums=('3415a83776019c5876b47ea9e0151c4838f0f22d4e0c2e4d6c02b917f8716ee0') +sha256sums=('0de8efb8cd4cf8deb461ffd59765445e00d48b34a0952d56abb8408016cd14cd') # Makes sure makepkg doesn't extract the .deb since it will break noextract=("${pkgname}-${pkgver}.deb") From 8168709742d82066bc24ba9f236a6e52e9d120d9 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 29 Jan 2026 18:10:57 -0800 Subject: [PATCH 7/7] feat(providers): improve provider onboarding visibility and stability (#324) --- packages/app/src/app/app.tsx | 4 + .../app/components/provider-auth-modal.tsx | 122 ++++++++++++------ packages/app/src/app/pages/dashboard.tsx | 9 ++ packages/app/src/app/pages/settings.tsx | 77 ++++++++++- packages/app/src/app/utils/index.ts | 1 + 5 files changed, 174 insertions(+), 39 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 90aba0484..817b73db5 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -3459,6 +3459,10 @@ export default function App() { setTab, settingsTab: settingsTab(), setSettingsTab, + providers: providers(), + providerConnectedIds: providerConnectedIds(), + providerAuthBusy: providerAuthBusy(), + openProviderAuthModal, view: currentView(), setView, mode: mode(), diff --git a/packages/app/src/app/components/provider-auth-modal.tsx b/packages/app/src/app/components/provider-auth-modal.tsx index b4c01f9b0..1360a4784 100644 --- a/packages/app/src/app/components/provider-auth-modal.tsx +++ b/packages/app/src/app/components/provider-auth-modal.tsx @@ -12,6 +12,14 @@ type ProviderAuthEntry = { connected: boolean; }; +const PROVIDER_LABELS: Record = { + opencode: "OpenCode", + openai: "OpenAI", + anthropic: "Anthropic", + google: "Google", + openrouter: "OpenRouter", +}; + export type ProviderAuthModalProps = { open: boolean; loading: boolean; @@ -25,6 +33,30 @@ export type ProviderAuthModalProps = { }; export default function ProviderAuthModal(props: ProviderAuthModalProps) { + 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 entries = createMemo(() => { const methods = props.authMethods ?? {}; const connected = new Set(props.connectedProviderIds ?? []); @@ -35,7 +67,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { const provider = providers.find((item) => item.id === id); return { id, - name: provider?.name ?? id, + name: formatProviderName(id, provider?.name), methods: methods[id] ?? [], connected: connected.has(id), }; @@ -57,31 +89,41 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
-
-
-
-

Connect provider

-

Choose a provider to authenticate.

-
- +
+
+

Connect providers

+

Sign in to services you want OpenWork to use.

+
+ +
+ +
+
+ +
+ Loading providers... +
+
+ } + > +
+ {props.error} +
+
- -
- {props.error} -
-
- - -
- Loading providers... -
-
- -
+
No providers available.
} @@ -99,12 +141,17 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
{entry.name}
{entry.id}
- -
- - Connected -
-
+
+ Connect} + > +
+ + Connected +
+
+
@@ -127,16 +174,17 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
+
- -
Opening authentication...
-
- -
- OAuth providers open in your browser. API key providers require editing your `opencode.json`. +
+
+ Opening authentication...
- -
diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index d052d4de0..e84b9ff9e 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -4,6 +4,7 @@ import type { McpServerEntry, McpStatusMap, PluginScope, + ProviderListItem, SettingsTab, SkillCard, WorkspaceCommand, @@ -39,6 +40,10 @@ export type DashboardViewProps = { setTab: (tab: DashboardTab) => void; settingsTab: SettingsTab; setSettingsTab: (tab: SettingsTab) => void; + providers: ProviderListItem[]; + providerConnectedIds: string[]; + providerAuthBusy: boolean; + openProviderAuthModal: () => Promise; view: View; setView: (view: View, sessionId?: string) => void; mode: "host" | "client" | null; @@ -858,6 +863,10 @@ export default function DashboardView(props: DashboardViewProps) { busy={props.busy} settingsTab={props.settingsTab} setSettingsTab={props.setSettingsTab} + providers={props.providers} + providerConnectedIds={props.providerConnectedIds} + providerAuthBusy={props.providerAuthBusy} + openProviderAuthModal={props.openProviderAuthModal} openworkServerStatus={props.openworkServerStatus} openworkServerUrl={props.openworkServerUrl} openworkServerSettings={props.openworkServerSettings} diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index 75739c9a8..97af0411c 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -5,8 +5,8 @@ import { formatBytes, formatRelativeTime, isTauriRuntime } from "../utils"; import Button from "../components/button"; import TextInput from "../components/text-input"; import SettingsKeybinds, { type KeybindSetting } from "../components/settings-keybinds"; -import { ChevronDown, HardDrive, MessageCircle, RefreshCcw, Shield, Smartphone, X } from "lucide-solid"; -import type { SettingsTab } from "../types"; +import { ChevronDown, HardDrive, MessageCircle, PlugZap, RefreshCcw, Shield, Smartphone, X } from "lucide-solid"; +import type { ProviderListItem, SettingsTab } from "../types"; import type { OpenworkAuditEntry, OpenworkServerCapabilities, OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server"; import type { EngineInfo, @@ -33,6 +33,10 @@ export type SettingsViewProps = { busy: boolean; settingsTab: SettingsTab; setSettingsTab: (tab: SettingsTab) => void; + providers: ProviderListItem[]; + providerConnectedIds: string[]; + providerAuthBusy: boolean; + openProviderAuthModal: () => Promise; openworkServerStatus: OpenworkServerStatus; openworkServerUrl: string; openworkServerSettings: OpenworkServerSettings; @@ -519,6 +523,38 @@ export default function SettingsView(props: SettingsViewProps) { return "bg-gray-4/60 text-gray-11 border-gray-7/50"; }; + const [providerConnectError, setProviderConnectError] = createSignal(null); + const providerConnectedCount = createMemo(() => (props.providerConnectedIds ?? []).length); + const providerAvailableCount = createMemo(() => (props.providers ?? []).length); + const providerStatusLabel = createMemo(() => { + if (!providerAvailableCount()) return "Unavailable"; + if (!providerConnectedCount()) return "Not connected"; + return `${providerConnectedCount()} connected`; + }); + const providerStatusStyle = createMemo(() => { + if (!providerAvailableCount()) return "bg-gray-4/60 text-gray-11 border-gray-7/50"; + if (!providerConnectedCount()) return "bg-amber-7/10 text-amber-11 border-amber-7/20"; + return "bg-green-7/10 text-green-11 border-green-7/20"; + }); + const providerSummary = createMemo(() => { + if (!providerAvailableCount()) return "Connect to OpenCode to load providers."; + const connected = providerConnectedCount(); + const available = providerAvailableCount(); + if (!connected) return `${available} available`; + return `${connected} connected · ${available} available`; + }); + + const handleOpenProviderAuth = async () => { + if (props.busy || props.providerAuthBusy) return; + setProviderConnectError(null); + try { + await props.openProviderAuthModal(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to open providers"; + setProviderConnectError(message); + } + }; + const [openworkUrl, setOpenworkUrl] = createSignal(""); const [openworkToken, setOpenworkToken] = createSignal(""); const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false); @@ -748,6 +784,43 @@ export default function SettingsView(props: SettingsViewProps) {
+
+
+
+
+ +
Providers
+
+
Connect services for models and tools.
+
+
+ {providerStatusLabel()} +
+
+ +
+ +
{providerSummary()}
+
+ + +
+ {providerConnectError()} +
+
+ +
+ API keys live in opencode.json. Use /models + to pick a default. +
+
+
Connection
{props.headerStatus}
diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index 3f304b954..7efefc00d 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -32,6 +32,7 @@ const FRIENDLY_PROVIDER_LABELS: Record = { openai: "OpenAI", anthropic: "Anthropic", google: "Google", + openrouter: "OpenRouter", }; const humanizeModelLabel = (value: string) => {