@steerable/agent-shell-web 0.6.55 → 0.6.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +3 -4
  2. package/package.json +4 -4
  3. package/src/AppShell.tsx +3 -3
  4. package/src/components/AgentSidebar.tsx +4 -4
  5. package/src/components/CreateProjectModal.tsx +2 -2
  6. package/src/components/SidebarRelease.tsx +4 -4
  7. package/src/components/TerminalPanel.tsx +3 -3
  8. package/src/components/TerminalView.tsx +4 -4
  9. package/src/components/chat/ApprovalModal.tsx +1 -1
  10. package/src/components/chat/ApprovalPromptProvider.tsx +4 -4
  11. package/src/components/chat/AskUserPromptProvider.tsx +3 -3
  12. package/src/components/chat/ChatProjectBadge.tsx +3 -3
  13. package/src/components/chat/TaskPanelModal.tsx +2 -2
  14. package/src/components/chat/TaskProcessPanel.tsx +2 -2
  15. package/src/components/chat/useChatTasks.ts +3 -3
  16. package/src/components/settings/AgentCapabilityFields.tsx +2 -2
  17. package/src/components/settings/AgentsSettingsPanel.tsx +3 -3
  18. package/src/components/settings/CliInstallSettingsPanel.tsx +67 -0
  19. package/src/components/settings/InsightsSettingsPanel.tsx +15 -15
  20. package/src/components/settings/LlmSettingsPanel.tsx +3 -3
  21. package/src/components/settings/McpSettingsPanel.tsx +12 -12
  22. package/src/components/settings/PythonRunnerSettingsPanel.tsx +4 -4
  23. package/src/components/settings/SecuritySettingsPanel.tsx +3 -3
  24. package/src/components/settings/SkillsSettingsPanel.tsx +7 -7
  25. package/src/components/settings/TelemetrySettingsPanel.tsx +5 -5
  26. package/src/components/settings/UsagePanel.tsx +3 -3
  27. package/src/components/settings/WebSearchSettingsPanel.tsx +5 -5
  28. package/src/hooks/useChatsAndAgents.ts +9 -9
  29. package/src/hooks/useProjects.ts +2 -2
  30. package/src/i18n/index.tsx +1 -1
  31. package/src/layouts/AgentLayout.tsx +14 -6
  32. package/src/lib/app-release.ts +1 -1
  33. package/src/lib/attachments.ts +2 -2
  34. package/src/lib/{browser-dev-electron-mock.ts → browser-dev-host-mock.ts} +10 -10
  35. package/src/lib/chat-transport.ts +10 -10
  36. package/src/lib/host-bridge.ts +376 -4
  37. package/src/lib/host-paste.ts +1 -1
  38. package/src/lib/http-bridge.ts +10 -8
  39. package/src/lib/insights.ts +2 -2
  40. package/src/lib/local-api.ts +13 -5
  41. package/src/lib/portable.ts +3 -3
  42. package/src/lib/slash-sources.ts +3 -3
  43. package/src/lib/tauri-bridge.ts +1 -1
  44. package/src/main.tsx +3 -3
  45. package/src/pages/AgentPage.tsx +23 -23
  46. package/src/pages/SettingsPage.tsx +4 -4
  47. package/src/lib/electron-bridge.ts +0 -389
package/README.md CHANGED
@@ -17,10 +17,9 @@ bundle; there is no prebuilt `dist/` here. See
17
17
  [`docs/spec/architecture.md`](https://github.com/pathlyapp/steerable/blob/develop/docs/spec/architecture.md)
18
18
  for the tier model.
19
19
 
20
- Renderer code reads native and backend capabilities through `HostBridge`.
21
- Browser mode uses the HTTP/SSE adapter; Tauri mode layers native commands and
22
- menu events over that same adapter. `ElectronBridge` and
23
- `getElectronBridge()` remain deprecated compatibility aliases.
20
+ Renderer code reads native and backend capabilities through `HostBridge`
21
+ (`getHostBridge()`). Browser mode uses the HTTP/SSE adapter; Tauri mode
22
+ layers native commands and menu events over that same adapter.
24
23
 
25
24
  Products that require authentication can register an `AppShellGate` from
26
25
  `@steerable/agent-shell-web/auth/gate` before calling `bootstrap()`. An
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.55",
3
+ "version": "0.6.57",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -45,8 +45,8 @@
45
45
  "react-router-dom": "^6.30.0",
46
46
  "rehype-raw": "^7.0.0",
47
47
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-protocol": "0.6.55",
49
- "@steerable/agent-ui": "0.6.55"
48
+ "@steerable/agent-ui": "0.6.57",
49
+ "@steerable/agent-protocol": "0.6.57"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.8.3",
60
60
  "vite": "^5.4.21",
61
61
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.6.55"
62
+ "@steerable/pack-sdk": "0.6.57"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
package/src/AppShell.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Outlet } from 'react-router-dom';
2
- import { isElectron } from './lib/electron-bridge';
2
+ import { hasHostBridge } from './lib/host-bridge';
3
3
  import { isDemoMode } from './lib/demo-flag';
4
4
 
5
5
  export function AppShell() {
@@ -13,9 +13,9 @@ export function AppShell() {
13
13
  Demo · simulated data, no live model
14
14
  </div>
15
15
  ) : (
16
- !isElectron() && (
16
+ !hasHostBridge() && (
17
17
  <div className="border-t border-agent-border bg-agent-muted px-3 py-1 text-xs text-agent-muted-foreground">
18
- Browser preview mode &middot; running outside Electron, IPC bridge unavailable
18
+ Browser preview mode &middot; no host bridge
19
19
  </div>
20
20
  )
21
21
  )}
@@ -92,7 +92,7 @@ import {
92
92
  import { RiPushpin2Fill, RiPushpin2Line } from "react-icons/ri";
93
93
  import { parseChatTitle } from "@/lib/chat-title";
94
94
  import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
95
- import { getElectronBridge, isElectron } from "@/lib/electron-bridge";
95
+ import { getHostBridge, hasHostBridge } from "@/lib/host-bridge";
96
96
  import { hasGeneralSettingsChrome, hostToolChrome, settingsChrome } from "@/lib/host-tools";
97
97
  import {
98
98
  createProject,
@@ -290,7 +290,7 @@ export function AgentSidebar({
290
290
  const navigate = useNavigate();
291
291
  const location = useLocation();
292
292
  const { chatId: currentChatId } = useParams<{ chatId?: string }>();
293
- const bridge = getElectronBridge();
293
+ const bridge = getHostBridge();
294
294
  const release = useAppRelease();
295
295
  const onSettingsPage = location.pathname === "/settings";
296
296
  // 插件页(及旧的 agents/skills/mcp 深链)高亮「插件」;其余 /settings 高亮底部综合设置。
@@ -385,7 +385,7 @@ export function AgentSidebar({
385
385
  const backgroundIds = [...streamingChatIds].filter(
386
386
  (id) => id !== currentChatId,
387
387
  );
388
- if (backgroundIds.length === 0 || !isElectron()) return;
388
+ if (backgroundIds.length === 0 || !hasHostBridge()) return;
389
389
 
390
390
  let cancelled = false;
391
391
  let ticking = false;
@@ -908,7 +908,7 @@ export function AgentSidebar({
908
908
  normalizedChats.length,
909
909
  ]);
910
910
 
911
- const hasElectron = isElectron();
911
+ const hasElectron = hasHostBridge();
912
912
 
913
913
  return (
914
914
  <div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
@@ -7,7 +7,7 @@ import { createPortal } from 'react-dom';
7
7
  import { LuFolder, LuFolderPlus, LuX } from 'react-icons/lu';
8
8
  import { BRAND_NAME } from '@/brand';
9
9
  import { ConfirmDialog } from '@/components/ConfirmDialog';
10
- import { getElectronBridge } from '@/lib/electron-bridge';
10
+ import { getHostBridge } from '@/lib/host-bridge';
11
11
  import { t } from '@/i18n';
12
12
 
13
13
  export interface ProjectFormValues {
@@ -83,7 +83,7 @@ export function CreateProjectModal({
83
83
  addSourceFolder(typed);
84
84
  return;
85
85
  }
86
- const picker = getElectronBridge()?.local?.selectDirectory;
86
+ const picker = getHostBridge()?.local?.selectDirectory;
87
87
  if (!picker) {
88
88
  setError(t('Enter a folder path'));
89
89
  return;
@@ -4,9 +4,9 @@ import {
4
4
  type AppReleaseView,
5
5
  } from '@/lib/app-release';
6
6
  import {
7
- getElectronBridge,
7
+ getHostBridge,
8
8
  type AppReleaseSnapshot,
9
- } from '@/lib/electron-bridge';
9
+ } from '@/lib/host-bridge';
10
10
  import { t } from '@/i18n';
11
11
 
12
12
  export function useAppRelease(): AppReleaseView & { run: () => void } {
@@ -14,7 +14,7 @@ export function useAppRelease(): AppReleaseView & { run: () => void } {
14
14
  const [confirmedCurrent, setConfirmedCurrent] = useState(false);
15
15
 
16
16
  useEffect(() => {
17
- const app = getElectronBridge()?.app;
17
+ const app = getHostBridge()?.app;
18
18
  if (!app) return;
19
19
  let alive = true;
20
20
  void app.snapshot().then((next) => {
@@ -32,7 +32,7 @@ export function useAppRelease(): AppReleaseView & { run: () => void } {
32
32
  }, []);
33
33
 
34
34
  const run = useCallback(() => {
35
- const app = getElectronBridge()?.app;
35
+ const app = getHostBridge()?.app;
36
36
  if (!app || !snap || !snap.enabled) return;
37
37
  if (snap.phase !== 'idle' && snap.phase !== 'error' && snap.phase !== 'ready') return;
38
38
  const task = snap.phase === 'ready' ? app.install() : app.check();
@@ -1,5 +1,5 @@
1
1
  import { Suspense, lazy, useEffect, useState } from 'react';
2
- import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
2
+ import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
3
3
  import { t } from '@/i18n';
4
4
 
5
5
  // 把 xterm 那一坨 (~300KB) 拆到独立 chunk —— 用户没打开终端就不下载。
@@ -17,7 +17,7 @@ const TerminalView = lazy(() =>
17
17
  * xterm 视图,不杀 shell;重新打开时通过 `terminal:ensure` 的 replay
18
18
  * buffer 补回面板关闭期间的输出。
19
19
  *
20
- * Electron 检测保留给浏览器预览 / dev 模式(没有 `window.electron` 就
20
+ * Electron 检测保留给浏览器预览 / dev 模式(没有 `window.steerableHost` 就
21
21
  * 连不到本地 PTY)。延迟到首个 effect 再渲染,避免检测闪烁。
22
22
  */
23
23
  export function TerminalPanel({
@@ -33,7 +33,7 @@ export function TerminalPanel({
33
33
  const [hasElectron, setHasElectron] = useState<boolean | null>(null);
34
34
 
35
35
  useEffect(() => {
36
- setHasElectron(isElectron() && !!getElectronBridge()?.terminal);
36
+ setHasElectron(hasHostBridge() && !!getHostBridge()?.terminal);
37
37
  }, []);
38
38
 
39
39
  if (hasElectron === null) {
@@ -5,7 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
5
5
  import { LuListTodo } from 'react-icons/lu';
6
6
  import '@xterm/xterm/css/xterm.css';
7
7
  import { DockHeaderButton, DockPanelHeader } from '@/components/DockPanelHeader';
8
- import { getElectronBridge } from '@/lib/electron-bridge';
8
+ import { getHostBridge } from '@/lib/host-bridge';
9
9
  import { BRAND_NAME } from '@/brand';
10
10
  import { t } from '@/i18n';
11
11
 
@@ -14,7 +14,7 @@ import { t } from '@/i18n';
14
14
  * `terminal.*` bridge. Ported from `deeppath/apps/web/src/app/agent/terminal/
15
15
  * TerminalView.tsx`, with these adaptations:
16
16
  *
17
- * - `window.electron` direct access → `getElectronBridge()` helper so the
17
+ * - `window.steerableHost` direct access → `getHostBridge()` helper so the
18
18
  * "no bridge" path is type-safe.
19
19
  * - Removed `'use client'` directive (not a Next.js project).
20
20
  * - Container is `h-full` instead of `h-screen` so the component is
@@ -45,11 +45,11 @@ export function TerminalView({
45
45
 
46
46
  useEffect(() => {
47
47
  if (!containerRef.current) return;
48
- const bridge = getElectronBridge();
48
+ const bridge = getHostBridge();
49
49
  const terminal = bridge?.terminal;
50
50
  if (!terminal) {
51
51
  setStatus('error');
52
- setStatusMsg(t('window.electron.terminal is unavailable'));
52
+ setStatusMsg(t('The host terminal is unavailable'));
53
53
  return;
54
54
  }
55
55
 
@@ -2,7 +2,7 @@ import { LuShieldAlert, LuTriangleAlert } from 'react-icons/lu';
2
2
  import {
3
3
  type ApprovalDecisionKind,
4
4
  type ApprovalPromptRequest,
5
- } from '@/lib/electron-bridge';
5
+ } from '@/lib/host-bridge';
6
6
  import { t } from '@/i18n';
7
7
 
8
8
  /**
@@ -9,10 +9,10 @@ import {
9
9
  type ReactNode,
10
10
  } from 'react';
11
11
  import {
12
- getElectronBridge,
12
+ getHostBridge,
13
13
  type ApprovalDecisionKind,
14
14
  type ApprovalPromptRequest,
15
- } from '@/lib/electron-bridge';
15
+ } from '@/lib/host-bridge';
16
16
  import { bindPromptToActiveChat, promptVisibleInChat } from './prompt-chat-scope';
17
17
 
18
18
  interface ApprovalPromptContextValue {
@@ -37,7 +37,7 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
37
37
  const decidedRequestIds = useRef(new Set<string>());
38
38
 
39
39
  useEffect(() => {
40
- const bridge = getElectronBridge();
40
+ const bridge = getHostBridge();
41
41
  if (!bridge?.approval) return;
42
42
  let active = true;
43
43
  const addRequests = (
@@ -76,7 +76,7 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
76
76
  }, []);
77
77
 
78
78
  const decide = useCallback((requestId: string, kind: ApprovalDecisionKind) => {
79
- const bridge = getElectronBridge();
79
+ const bridge = getHostBridge();
80
80
  decidedRequestIds.current.add(requestId);
81
81
  setQueue((previous) => previous.filter((request) => request.requestId !== requestId));
82
82
  void bridge?.approval?.decide({ requestId, kind });
@@ -8,7 +8,7 @@ import {
8
8
  useState,
9
9
  type ReactNode,
10
10
  } from 'react';
11
- import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bridge';
11
+ import { getHostBridge, type AskUserPromptRequest } from '@/lib/host-bridge';
12
12
  import { bindPromptToActiveChat, promptVisibleInChat } from './prompt-chat-scope';
13
13
 
14
14
  type AnswerValue = string | string[];
@@ -35,7 +35,7 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
35
35
  const answeredRequestIds = useRef(new Set<string>());
36
36
 
37
37
  useEffect(() => {
38
- const bridge = getElectronBridge();
38
+ const bridge = getHostBridge();
39
39
  if (!bridge?.askUser) return;
40
40
  let active = true;
41
41
  const addRequests = (
@@ -75,7 +75,7 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
75
75
 
76
76
  const answer = useCallback(
77
77
  (requestId: string, answers: Record<string, AnswerValue>) => {
78
- const bridge = getElectronBridge();
78
+ const bridge = getHostBridge();
79
79
  answeredRequestIds.current.add(requestId);
80
80
  setQueue((previous) => previous.filter((request) => request.requestId !== requestId));
81
81
  void bridge?.askUser?.answer({ requestId, answers });
@@ -10,7 +10,7 @@ import {
10
10
  LuPlus,
11
11
  } from 'react-icons/lu';
12
12
  import { CreateProjectModal } from '@/components/CreateProjectModal';
13
- import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
13
+ import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
14
14
  import {
15
15
  createProject,
16
16
  updateChatProject,
@@ -219,8 +219,8 @@ export function ChatProjectBadge({
219
219
 
220
220
  const changeFolder = () =>
221
221
  run(async () => {
222
- if (!isElectron() || !project) return;
223
- const result = await getElectronBridge()!.local?.selectDirectory({
222
+ if (!hasHostBridge() || !project) return;
223
+ const result = await getHostBridge()!.local?.selectDirectory({
224
224
  title: t('Choose a new folder for "{name}"', { name: project.name }),
225
225
  });
226
226
  if (!result || result.canceled || result.filePaths.length === 0) return;
@@ -18,7 +18,7 @@ import {
18
18
  mergeTaskWorktree,
19
19
  type LocalTask,
20
20
  } from '@/lib/local-api';
21
- import { getElectronBridge } from '@/lib/electron-bridge';
21
+ import { getHostBridge } from '@/lib/host-bridge';
22
22
  import { t } from '@/i18n';
23
23
 
24
24
  /**
@@ -81,7 +81,7 @@ export function TaskPanelModal({
81
81
  // 任务终态推送 → 重拉列表。preload 只透传 chatId/taskId/status,
82
82
  // 记录本体以 SQLite 为准。
83
83
  useEffect(() => {
84
- const bridge = getElectronBridge();
84
+ const bridge = getHostBridge();
85
85
  if (!bridge?.onTaskUpdated) return;
86
86
  return bridge.onTaskUpdated((payload) => {
87
87
  if (payload.chatId === chatId) void refresh();
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
2
2
  import { LuArrowDown, LuLoaderCircle, LuTerminal } from 'react-icons/lu';
3
3
  import { DockHeaderButton, DockPanelHeader } from '@/components/DockPanelHeader';
4
4
  import { getChildProcess, getTaskProcess } from '@/lib/local-api';
5
- import { getElectronBridge } from '@/lib/electron-bridge';
5
+ import { getHostBridge } from '@/lib/host-bridge';
6
6
  import { t } from '@/i18n';
7
7
  import { Markdown } from '@/components/chat/Markdown';
8
8
  import { TurnProcessGroup } from '@/components/chat/TurnProcessGroup';
@@ -154,7 +154,7 @@ export function TaskProcessPanel({
154
154
 
155
155
  useEffect(() => {
156
156
  if (childRecordId) return;
157
- const bridge = getElectronBridge();
157
+ const bridge = getHostBridge();
158
158
  if (!bridge?.onTaskProcess) return;
159
159
  return bridge.onTaskProcess((payload) => {
160
160
  if (payload.taskId !== inspected.id) return;
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { listChatTasks, type LocalTask } from '@/lib/local-api';
13
- import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
13
+ import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
14
14
 
15
15
  export interface ChatTaskSummary {
16
16
  total: number;
@@ -68,7 +68,7 @@ export function useChatTasks(chatId: string | null): ChatTasksState {
68
68
  const seenRef = useRef<Map<string, LocalTask['status']> | null>(null);
69
69
 
70
70
  const refresh = useCallback(async () => {
71
- if (!chatId || !isElectron()) {
71
+ if (!chatId || !hasHostBridge()) {
72
72
  setTasks(NO_TASKS);
73
73
  return;
74
74
  }
@@ -105,7 +105,7 @@ export function useChatTasks(chatId: string | null): ChatTasksState {
105
105
 
106
106
  useEffect(() => {
107
107
  if (!chatId) return;
108
- const bridge = getElectronBridge();
108
+ const bridge = getHostBridge();
109
109
  if (!bridge?.onTaskUpdated) return;
110
110
  return bridge.onTaskUpdated((payload) => {
111
111
  if (payload.chatId === chatId) void refresh();
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import { LuLoaderCircle, LuSearch } from 'react-icons/lu';
3
3
  import { t } from '@/i18n';
4
- import { isElectron } from '@/lib/electron-bridge';
4
+ import { hasHostBridge } from '@/lib/host-bridge';
5
5
  import {
6
6
  listChatAgentSkills,
7
7
  listChatAgentTools,
@@ -154,7 +154,7 @@ export function AgentCapabilityFields({
154
154
  const [toolFilter, setToolFilter] = useState('');
155
155
 
156
156
  const fetchCatalogs = useCallback(async () => {
157
- if (!isElectron()) return;
157
+ if (!hasHostBridge()) return;
158
158
  setLoading(true);
159
159
  try {
160
160
  const [skillRes, toolRes] = await Promise.all([
@@ -7,7 +7,7 @@ import {
7
7
  } from 'react-icons/lu';
8
8
  import { ConfirmDialog } from '@/components/ConfirmDialog';
9
9
  import { t } from '@/i18n';
10
- import { isElectron } from '@/lib/electron-bridge';
10
+ import { hasHostBridge } from '@/lib/host-bridge';
11
11
  import {
12
12
  archiveChatAgent,
13
13
  createChatAgent,
@@ -101,7 +101,7 @@ export function AgentsSettingsPanel({
101
101
  const [error, setError] = useState<string | null>(null);
102
102
 
103
103
  const fetchAgents = useCallback(async () => {
104
- if (!isElectron()) return;
104
+ if (!hasHostBridge()) return;
105
105
  setLoading(true);
106
106
  setError(null);
107
107
  try {
@@ -151,7 +151,7 @@ export function AgentsSettingsPanel({
151
151
 
152
152
  const handleSave = async () => {
153
153
  const trimmedName = name.trim();
154
- if (!trimmedName || !isElectron()) return;
154
+ if (!trimmedName || !hasHostBridge()) return;
155
155
  setSaving(true);
156
156
  setError(null);
157
157
  try {
@@ -0,0 +1,67 @@
1
+ import { useState } from 'react';
2
+ import { LuLoader, LuTerminal } from 'react-icons/lu';
3
+ import { t } from '@/i18n';
4
+ import { installCommandLineTool } from '@/lib/local-api';
5
+
6
+ export function CliInstallSettingsPanel(props: {
7
+ binName: string;
8
+ install?: () => Promise<{ path: string; onPath: boolean }>;
9
+ }) {
10
+ const install = props.install ?? (() => installCommandLineTool(props.binName));
11
+ const [running, setRunning] = useState(false);
12
+ const [message, setMessage] = useState<string | null>(null);
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ const onClick = async () => {
16
+ setRunning(true);
17
+ setMessage(null);
18
+ setError(null);
19
+ try {
20
+ const result = await install();
21
+ setMessage(result.onPath
22
+ ? t('Installed {path}', { path: result.path })
23
+ : t('Installed {path}. Add its directory to PATH.', { path: result.path }));
24
+ } catch (err) {
25
+ setError(err instanceof Error ? err.message : String(err));
26
+ } finally {
27
+ setRunning(false);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="space-y-2 rounded-agent-md border border-agent-border bg-agent-card p-2.5" data-testid="cli-install-settings">
33
+ <p className="text-xs text-agent-muted-foreground">
34
+ {t('Install the command-line tool on your PATH so you can run it from a terminal.')}
35
+ </p>
36
+ <button
37
+ type="button"
38
+ data-testid="cli-install-button"
39
+ onClick={() => void onClick()}
40
+ disabled={running}
41
+ className="inline-flex items-center gap-1.5 rounded-agent-md bg-agent-primary px-3 py-1.5 text-xs font-medium text-agent-primary-foreground transition hover:bg-agent-primary/90 disabled:opacity-50"
42
+ >
43
+ {running ? (
44
+ <>
45
+ <LuLoader className="h-3.5 w-3.5 animate-spin" />
46
+ {t('Installing…')}
47
+ </>
48
+ ) : (
49
+ <>
50
+ <LuTerminal className="h-3.5 w-3.5" />
51
+ {t('Install command-line tool')}
52
+ </>
53
+ )}
54
+ </button>
55
+ {message && (
56
+ <p data-testid="cli-install-message" className="text-xs text-agent-foreground">
57
+ {message}
58
+ </p>
59
+ )}
60
+ {error && (
61
+ <p data-testid="cli-install-error" className="text-xs text-agent-destructive">
62
+ {t('Install failed: {error}', { error })}
63
+ </p>
64
+ )}
65
+ </div>
66
+ );
67
+ }
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
2
2
  import { LuChartBar } from 'react-icons/lu';
3
3
  import { BRAND_NAME } from '@/brand';
4
4
  import { t } from '@/i18n';
5
- import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
5
+ import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
6
6
 
7
7
  type InsightsWire = {
8
8
  installId: string;
@@ -34,8 +34,8 @@ export function InsightsConsentBanner() {
34
34
  const [saving, setSaving] = useState(false);
35
35
 
36
36
  useEffect(() => {
37
- if (!isElectron()) return;
38
- void getElectronBridge()!
37
+ if (!hasHostBridge()) return;
38
+ void getHostBridge()!
39
39
  .localBackend.request<InsightsWire>({ method: 'GET', path: '/api/v2/local-settings/insights' })
40
40
  .then((data) => {
41
41
  if (!data.promptedAt) setOpen(true);
@@ -44,10 +44,10 @@ export function InsightsConsentBanner() {
44
44
  }, []);
45
45
 
46
46
  const save = useCallback(async (upload: boolean) => {
47
- if (!isElectron()) return;
47
+ if (!hasHostBridge()) return;
48
48
  setSaving(true);
49
49
  try {
50
- await getElectronBridge()!.localBackend.request({
50
+ await getHostBridge()!.localBackend.request({
51
51
  method: 'POST',
52
52
  path: '/api/v2/local-settings/insights',
53
53
  body: {
@@ -112,11 +112,11 @@ export function InsightsSettingsPanel() {
112
112
  const [error, setError] = useState<string | null>(null);
113
113
 
114
114
  const load = useCallback(async () => {
115
- if (!isElectron()) return;
115
+ if (!hasHostBridge()) return;
116
116
  setLoading(true);
117
117
  setError(null);
118
118
  try {
119
- const res = await getElectronBridge()!.localBackend.request<InsightsWire>({
119
+ const res = await getHostBridge()!.localBackend.request<InsightsWire>({
120
120
  method: 'GET',
121
121
  path: '/api/v2/local-settings/insights',
122
122
  });
@@ -133,11 +133,11 @@ export function InsightsSettingsPanel() {
133
133
  }, [load]);
134
134
 
135
135
  const save = async () => {
136
- if (!isElectron()) return;
136
+ if (!hasHostBridge()) return;
137
137
  setSaving(true);
138
138
  setError(null);
139
139
  try {
140
- const res = await getElectronBridge()!.localBackend.request<InsightsWire>({
140
+ const res = await getHostBridge()!.localBackend.request<InsightsWire>({
141
141
  method: 'POST',
142
142
  path: '/api/v2/local-settings/insights',
143
143
  body: {
@@ -162,15 +162,15 @@ export function InsightsSettingsPanel() {
162
162
  };
163
163
 
164
164
  const exportFile = async () => {
165
- if (!isElectron()) return;
165
+ if (!hasHostBridge()) return;
166
166
  setError(null);
167
167
  try {
168
- const bundle = await getElectronBridge()!.localBackend.request<unknown>({
168
+ const bundle = await getHostBridge()!.localBackend.request<unknown>({
169
169
  method: 'GET',
170
170
  path: '/api/v2/insights/export',
171
171
  });
172
172
  const text = `${JSON.stringify(bundle, null, 2)}\n`;
173
- const saved = await getElectronBridge()!.local?.saveTextFile?.({
173
+ const saved = await getHostBridge()!.local?.saveTextFile?.({
174
174
  title: t('Export local insight records'),
175
175
  defaultPath: `deeppath-insights-${new Date().toISOString().slice(0, 10)}.json`,
176
176
  content: text,
@@ -182,10 +182,10 @@ export function InsightsSettingsPanel() {
182
182
  };
183
183
 
184
184
  const uploadNow = async () => {
185
- if (!isElectron()) return;
185
+ if (!hasHostBridge()) return;
186
186
  setError(null);
187
187
  try {
188
- const res = await getElectronBridge()!.localBackend.request<{ ok: boolean; detail: string }>({
188
+ const res = await getHostBridge()!.localBackend.request<{ ok: boolean; detail: string }>({
189
189
  method: 'POST',
190
190
  path: '/api/v2/insights/upload-local',
191
191
  });
@@ -214,7 +214,7 @@ export function InsightsSettingsPanel() {
214
214
  'Even if the user does not agree to upload, records stay on this device. You can export JSON and send it to the product team, or click "Upload now" once.',
215
215
  )}
216
216
  </p>
217
- {!isElectron() ? (
217
+ {!hasHostBridge() ? (
218
218
  <p className="text-[11px] text-agent-muted-foreground">{t('Open this in the desktop app')}</p>
219
219
  ) : loading ? (
220
220
  <p className="text-[11px] text-agent-muted-foreground">{t('Loading…')}</p>
@@ -33,7 +33,7 @@ import { modelCapabilityChips } from '@/components/settings/model-capabilities';
33
33
  import { ModelIdCombobox } from '@/components/settings/ModelIdCombobox';
34
34
  import { SettingsSaveButton } from '@/components/settings/SettingsSaveButton';
35
35
  import { t } from '@/i18n';
36
- import { isElectron } from '@/lib/electron-bridge';
36
+ import { hasHostBridge } from '@/lib/host-bridge';
37
37
  import {
38
38
  COMPAT_AUTO,
39
39
  formStateFromOverrides,
@@ -124,7 +124,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
124
124
  const [resolvedPreset, setResolvedPreset] = useState<ProviderPresetOverride | null>(null);
125
125
 
126
126
  const reload = useCallback(async () => {
127
- if (!isElectron()) {
127
+ if (!hasHostBridge()) {
128
128
  setError(t('Open this in the desktop app'));
129
129
  return;
130
130
  }
@@ -344,7 +344,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
344
344
  };
345
345
 
346
346
  const handleSave = async () => {
347
- if (!isElectron()) {
347
+ if (!hasHostBridge()) {
348
348
  setError(t('Open this in the desktop app'));
349
349
  return;
350
350
  }