From ea3ae583d6573f2599b97335a4fb02fc81883ffd Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Mon, 8 Jun 2026 23:27:52 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(agent):=20unified=20per-device?= =?UTF-8?q?=20working=20directory=20+=20execution-device=20UI=20(#15543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(agent): unified per-device working directory + execution-device UI Client UI consuming the backend contract (#15542). User-facing — validate before merge. - New `src/store/device` (SWR fetch + cwd writes) — single source of device data; `deviceCwd` helper moves here from the chat-input feature layer. - One `WorkingDirectoryPicker` for local + remote (native dialog vs manual path). - Shared `WorkspaceControls` strip composed by both chat-input bars. - GitStatus reads remote git via `useDeviceGitInfo` (read-only). - Execution-device switcher graduates out of labs → writes only executionTarget. - One-time migration of legacy localStorage recents into device.workingDirs. Co-Authored-By: Claude Opus 4.8 * ✨ feat(agent): wire executionTarget→runtimeMode + workingDirByDevice cwd The runtime-decision wiring, kept out of the backend contract PR so it's reviewed/validated together with the UI that drives it. - `helpers/executionTarget`: resolveRuntimeMode / executionTarget resolvers. - server tool gate (AgentToolsEngine) derives runtimeMode from `agencyConfig.executionTarget`, with a no-regression fallback to the legacy per-platform runtimeMode. - server cwd precedence (aiAgent resolveWorkspaceInit + hetero dispatch) now consumes `workingDirByDevice[targetDeviceId]`. Co-Authored-By: Claude Opus 4.8 * ✅ test(agent): cover executionTarget + workingDir helpers; drop dead lab key - Unit-test resolveRuntimeMode / resolveExecutionTarget and the working-dir precedence (locks the web default→cloud graduation + legacy fallback) - Remove the now-unused `executionDeviceSwitcher` lab i18n keys (toggle deleted) Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): guide web users to the desktop app in the device switcher On web with no remote device, replace the muted "no devices" dead-end with a prominent, clickable download-desktop card (and drop the now-duplicate header link). Desktop keeps the muted hint since local execution is already available. Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): fix execution-device copy for desktop + web - Desktop "no devices" hint no longer tells an already-on-desktop user to "install the desktop app" — just points at `lh connect`. - Tighten the web download-card description to the desktop's real benefit (run on your computer with local file access). Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): flatten the web download card to a plain row Drop the outer border/background so it reads as a normal menu row (like the sandbox option), and shorten the description to a single line so the row stops being taller than its neighbours. Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): reword download-card desc to "access to your computer" Co-Authored-By: Claude Opus 4.8 * ✨ feat(agent): add "no device" execution target (plain chat, no run tools) Restores the option to run an agent with no execution environment, lost when the per-platform runtimeMode was unified into executionTarget. Adds `none` to HeteroExecutionTarget (→ runtimeMode `none`), surfaces it at the top of the switcher on both web + desktop, and flips the web default back to `none` so an unconfigured web agent is plain chat again (desktop still defaults to local). Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): rename HeteroExecutionTarget→DeviceExecutionTarget, reorder switcher - Rename the type (it now carries `none`, so "device" target fits better than "hetero") across types + helpers + dispatcher + switcher. - Move "no device" to the bottom of the list (real targets first, opt-out last). - Reword the download card to "let agents connect directly to your computer". Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): move "no device" back to top, restore EN download copy "No device" sits above the dynamic device rows; keep the EN download-card wording as "Run agents with access to your computer". Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): swap switcher icons — MonitorOff for "no device", Box for sandbox Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): clarify execution-device info tooltip + "no device" desc - Info tooltip now explains the cloud sandbox is provided by the centralized LobeHub Marketplace, and that picking a device makes it the agent's runtime for reading/writing files and operating the computer. - "No device" description now conveys "no device enabled, can't operate a computer" instead of "plain chat". Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): move info icon beside the title, shorten "no device" desc - Info tooltip trigger now sits next to the "Execution Device" title instead of right-aligned; the download link stays on the right. - "No device" description trimmed to just "No device enabled". Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): zh tooltip wording — "提供服务" Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): reorder tooltip — device runtime first, marketplace last Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): trim tooltip — drop "设备"/devices and trailing period Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): tag the current machine's device row, drop duplicate "This device" When the desktop's own machine appears in the device list, badge that real row with a "This device" tag and hide the generic "This device" (local) option — no more two entries for the same machine. The local option still shows as a fallback when the machine isn't enrolled in the list yet. Co-Authored-By: Claude Opus 4.8 * 💄 feat(agent): hoist this-machine device above sandbox + auto-bind on first run Switcher-only (no routing/dispatch changes): - Order is now: no device → this device → cloud sandbox → other devices. - On desktop, when this machine is enrolled and online and the agent has no explicit target yet, default to it and persist the binding once. Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): widen gap between execution-device rows Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): hide "Get Desktop App" link on desktop Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): capitalize "Cloud Sandbox" label Co-Authored-By: Claude Opus 4.8 * 💄 feat(agent): web working-dir entry via "Add folder" modal instead of inline input The browser folder picker can't yield an absolute path (sandboxed handle), so on web / a remote device the working directory is entered manually. Replace the inline input with an "Add folder…" row that opens a modal for absolute-path entry; the local desktop machine still opens the native folder dialog. Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(agent): split working-dir footer into local/remote row components Replace the scattered `isLocalDevice ?` forks (icon, label, handler) with one branch that picks between two self-contained rows: ChooseLocalFolderRow (native dialog) and AddRemoteFolderRow (absolute-path modal). Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): use the device default cwd as the add-folder placeholder Co-Authored-By: Claude Opus 4.8 * ✨ feat(agent): validate manually-entered working dir via device statPath RPC Web / remote clients can't browse the target device's filesystem, so the "Add folder" modal now checks the typed path on the device before binding it. New `statPath` device RPC mirrors gitInfo end-to-end: - desktop WorkspaceCtr.statPath (fs.stat → exists / isDirectory) + RPC dispatch - server deviceGateway.statPath + device.statPath tRPC (invokeRpc relay) - modal blocks on a definitive negative (not found / not a directory); an unreachable device is treated as "can't verify" and allowed through Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(agent): route statPath through deviceService, not lambdaClient Components shouldn't import lambdaClient directly — add a thin deviceService wrapping device.statPath, and call it from the working-dir picker. Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(i18n): move working-directory strings from plugin to a device ns The working-directory / git control-bar strings (53 keys) were lumped under the `plugin` namespace. Move them to a dedicated `device` namespace and drop the now-redundant `localSystem.` prefix (`plugin:localSystem.workingDirectory.X` → `device:workingDirectory.X`). Updates the 4 consumer components; the `device` ns auto-registers via defaultResources. Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(agent): route all device TRPC calls through deviceService Components/hooks/stores shouldn't reach into lambdaClient.device.* directly. Expand deviceService with listDevices/updateDevice/listGitBranches/ checkoutGitBranch/checkCapability/getAgentProfile and migrate every imperative call site (device store, BranchSwitcher, CreatePlatformAgent, the remote-agent guard, RemoteAgentConfigCard) + the DeviceListItem type. lambdaQuery.device.* React-Query hooks are left as-is (a different pattern). Co-Authored-By: Claude Opus 4.8 * ✨ feat(agent): pull/push a remote device's branch over RPC Wire git pull/push through the device's pullGitBranch/pushGitBranch RPC so the web/remote GitStatus bar can sync, not just the local desktop over IPC. Shows the pull/push affordances for remote devices too. Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(agent): route git pull/push through deviceService too Add pullGitBranch/pushGitBranch to deviceService and switch GitStatus off the direct lambdaClient.device.* calls, so no component reaches the device router directly anymore. Co-Authored-By: Claude Opus 4.8 * 🐛 fix(agent): detect repoType for manually-added working dirs A directory added via the "Add folder" modal committed without a repoType, so a GitHub repo showed a plain folder icon. statPath now also returns the git repo type (detected on the target device); the modal threads it into the committed entry. Collapses the modal's separate validate+submit into one onSubmit that validates and enriches in a single round-trip. Co-Authored-By: Claude Opus 4.8 * 💄 style(agent): create new branch via a modal instead of inline footer "Checkout new branch…" now opens a focused modal (branch-name input + create) rather than expanding an inline footer inside the branch dropdown. Always creates + checks out the branch — no checkout/overwrite options. Errors show inline in the modal; drops the dead inline-create state/styles. Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(agent): route all git ops through a unified gitService Pick Electron IPC vs device RPC inside the service so UI / store / hooks stay transport-agnostic. Replace the bundled `gitInfo` device RPC with granular reads (branch / linked PR / working-tree / ahead-behind) that mirror the local IPC methods one-to-one, and move the git read SWR hooks into the device store (useFetchGitInfo / WorkingTreeStatus / AheadBehind). Co-Authored-By: Claude Opus 4.8 * ✨ feat(agent): route Review git ops through device RPC (remote-capable) Extend the device-RPC git pipeline to the 4 ops the Review panel needs (getGitWorkingTreePatches / getGitBranchDiff / listGitRemoteBranches / revertGitFile), mirroring the listGitBranches pattern end-to-end: desktop RPC dispatch → deviceGateway → device.* tRPC → gitService. Adds minimal DeviceGit* mirror types to @lobechat/types. Review (useReviewPatches / useGitRemoteBranches / FileItem) now goes through gitService with a deviceId, dropping the isDesktop gate so web/remote devices get the diff + revert too. Co-Authored-By: Claude Opus 4.8 * 🐛 fix(agent): resolve repoType from device store so remote Review tab shows useRepoType now reads the persisted workingDirs[].repoType from the device store (keyed by deviceId), so a remote device's git/github type — and thus the Review tab visibility — resolves without a local-only IPC probe. The IPC probe + localStorage fallback are kept only when the target is the local machine. Co-Authored-By: Claude Opus 4.8 * 💄 feat(agent): optimistic branch switch in the branch switcher Flip the displayed branch the instant a checkout is clicked (or a new branch created) instead of waiting for the IPC/RPC round-trip + gitInfo refetch. The git-info SWR cache is optimistically updated and reconciled on completion — a failed checkout rolls the label back and toasts the error. Co-Authored-By: Claude Opus 4.8 * ✨ feat: support remote device files panel * 💄 style: restore desktop this-device option * 🐛 fix: keep files panel local for this device --------- Co-authored-by: Claude Opus 4.8 --- .../main/controllers/GatewayConnectionCtr.ts | 62 ++- .../src/main/controllers/WorkspaceCtr.ts | 23 +- apps/desktop/src/main/env.ts | 5 + .../src/main/services/gatewayConnectionSrv.ts | 9 +- locales/ar/labs.json | 2 - locales/bg-BG/labs.json | 2 - locales/de-DE/labs.json | 2 - locales/en-US/chat.json | 10 +- locales/en-US/device.json | 56 +++ locales/en-US/labs.json | 2 - locales/en-US/plugin.json | 48 -- locales/es-ES/labs.json | 2 - locales/fa-IR/labs.json | 2 - locales/fr-FR/labs.json | 2 - locales/it-IT/labs.json | 2 - locales/ja-JP/labs.json | 2 - locales/ko-KR/labs.json | 2 - locales/nl-NL/labs.json | 2 - locales/pl-PL/labs.json | 2 - locales/pt-BR/labs.json | 2 - locales/ru-RU/labs.json | 2 - locales/tr-TR/labs.json | 2 - locales/vi-VN/labs.json | 2 - locales/zh-CN/chat.json | 8 +- locales/zh-CN/device.json | 56 +++ locales/zh-CN/labs.json | 2 - locales/zh-CN/plugin.json | 48 -- locales/zh-TW/labs.json | 2 - packages/database/src/models/device.ts | 3 +- packages/database/src/schemas/device.ts | 25 +- packages/types/src/agent/agencyConfig.ts | 10 +- packages/types/src/agentExecution/index.ts | 87 ---- packages/types/src/device.ts | 305 ++++++++++++ packages/types/src/index.ts | 1 + packages/types/src/user/preference.ts | 5 - .../ActionTag/useSlashActionItems.ts | 13 +- .../RuntimeConfig/AddWorkingDirModal.tsx | 102 ++++ .../RuntimeConfig/BranchSwitcher.tsx | 209 ++++---- .../RuntimeConfig/CreateBranchModal.tsx | 92 ++++ .../RuntimeConfig/DeviceWorkingDirectory.tsx | 256 ---------- .../ChatInput/RuntimeConfig/GitStatus.tsx | 106 ++-- .../RuntimeConfig/HeteroDeviceSwitcher.tsx | 188 ++++++-- .../RuntimeConfig/WorkingDirectory.tsx | 366 -------------- .../RuntimeConfig/WorkingDirectoryPicker.tsx | 388 +++++++++++++++ .../RuntimeConfig/WorkingDirectorySection.tsx | 58 +++ .../RuntimeConfig/WorkspaceControls.tsx | 74 +++ .../ChatInput/RuntimeConfig/index.tsx | 288 +---------- .../useCommitWorkingDirectory.ts | 118 +++++ .../RuntimeConfig/useGitAheadBehind.ts | 21 - .../ChatInput/RuntimeConfig/useGitInfo.ts | 42 -- .../RuntimeConfig/useMigrateDeviceRecents.ts | 40 ++ .../ChatInput/RuntimeConfig/useRepoType.ts | 46 +- .../RuntimeConfig/useUpdateDeviceCwd.ts | 63 --- .../RuntimeConfig/useWorkingTreeStatus.ts | 20 - src/features/CreatePlatformAgent/index.tsx | 7 +- src/helpers/agentWorkingDirectory.test.ts | 92 ++++ src/helpers/agentWorkingDirectory.ts | 54 +++ src/helpers/executionTarget.test.ts | 75 +++ src/helpers/executionTarget.ts | 61 +++ src/hooks/useEffectiveWorkingDirectory.ts | 53 ++ src/hooks/useRemoteAgentDeviceGuard.ts | 6 +- src/locales/default/chat.ts | 10 +- src/locales/default/device.ts | 60 +++ src/locales/default/index.ts | 2 + src/locales/default/labs.ts | 3 - src/locales/default/plugin.ts | 53 -- .../WorkingDirectoryBar.tsx | 125 +---- .../WorkingSidebar/Files/index.tsx | 79 +-- .../Files/useGitWorkingTreeFiles.ts | 20 +- .../WorkingSidebar/Files/useProjectFiles.ts | 18 +- .../WorkingSidebar/Review/FileItem.tsx | 7 +- .../WorkingSidebar/Review/FileRow.tsx | 7 +- .../Review/__tests__/FileItem.test.tsx | 4 +- .../WorkingSidebar/Review/index.tsx | 18 +- .../WorkingSidebar/Review/useReviewPatches.ts | 55 ++- .../WorkingSidebar/index.test.tsx | 260 ---------- .../Conversation/WorkingSidebar/index.tsx | 42 +- .../ProfileEditor/RemoteAgentConfigCard.tsx | 5 +- src/routes/(main)/settings/advanced/index.tsx | 15 - .../devices/features/DeviceDetailPanel.tsx | 4 +- .../settings/devices/features/DeviceItem.tsx | 23 +- .../(main)/settings/hooks/useCategory.tsx | 7 +- .../modules/Mecha/AgentToolsEngine/index.ts | 15 +- .../modules/Mecha/AgentToolsEngine/types.ts | 9 +- .../__tests__/deviceWorkingDirs.test.ts | 3 +- src/server/routers/lambda/device.ts | 248 +++++++++- .../routers/lambda/deviceWorkingDirs.ts | 2 +- .../__tests__/workspaceInitCache.test.ts | 3 +- src/server/services/aiAgent/index.ts | 35 +- .../services/aiAgent/workspaceInitCache.ts | 3 +- .../deviceGateway/__tests__/index.test.ts | 43 +- src/server/services/deviceGateway/index.ts | 451 +++++++++++++++++- src/services/device.ts | 40 ++ src/services/git.ts | 229 +++++++++ src/services/projectFile.ts | 27 ++ .../selectors/chatConfigByIdSelectors.ts | 15 +- .../slices/aiChat/actions/agentDispatcher.ts | 4 +- src/store/device/action.ts | 145 ++++++ .../device}/deviceCwd.test.ts | 3 +- .../device}/deviceCwd.ts | 23 +- src/store/device/gitHooks.ts | 48 ++ src/store/device/index.ts | 4 + src/store/device/initialState.ts | 11 + src/store/device/selectors.ts | 27 ++ src/store/device/store.ts | 26 + .../slices/preference/selectors/labPrefer.ts | 2 - 106 files changed, 3670 insertions(+), 2198 deletions(-) create mode 100644 locales/en-US/device.json create mode 100644 locales/zh-CN/device.json create mode 100644 packages/types/src/device.ts create mode 100644 src/features/ChatInput/RuntimeConfig/AddWorkingDirModal.tsx create mode 100644 src/features/ChatInput/RuntimeConfig/CreateBranchModal.tsx delete mode 100644 src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx delete mode 100644 src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx create mode 100644 src/features/ChatInput/RuntimeConfig/WorkingDirectoryPicker.tsx create mode 100644 src/features/ChatInput/RuntimeConfig/WorkingDirectorySection.tsx create mode 100644 src/features/ChatInput/RuntimeConfig/WorkspaceControls.tsx create mode 100644 src/features/ChatInput/RuntimeConfig/useCommitWorkingDirectory.ts delete mode 100644 src/features/ChatInput/RuntimeConfig/useGitAheadBehind.ts delete mode 100644 src/features/ChatInput/RuntimeConfig/useGitInfo.ts create mode 100644 src/features/ChatInput/RuntimeConfig/useMigrateDeviceRecents.ts delete mode 100644 src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts delete mode 100644 src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts create mode 100644 src/helpers/agentWorkingDirectory.test.ts create mode 100644 src/helpers/agentWorkingDirectory.ts create mode 100644 src/helpers/executionTarget.test.ts create mode 100644 src/helpers/executionTarget.ts create mode 100644 src/hooks/useEffectiveWorkingDirectory.ts create mode 100644 src/locales/default/device.ts delete mode 100644 src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx create mode 100644 src/services/device.ts create mode 100644 src/services/git.ts create mode 100644 src/services/projectFile.ts create mode 100644 src/store/device/action.ts rename src/{features/ChatInput/RuntimeConfig => store/device}/deviceCwd.test.ts (94%) rename src/{features/ChatInput/RuntimeConfig => store/device}/deviceCwd.ts (58%) create mode 100644 src/store/device/gitHooks.ts create mode 100644 src/store/device/index.ts create mode 100644 src/store/device/initialState.ts create mode 100644 src/store/device/selectors.ts create mode 100644 src/store/device/store.ts diff --git a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts index a345b7f003..9d82130977 100644 --- a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts +++ b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts @@ -361,8 +361,66 @@ export default class GatewayConnectionCtr extends ControllerModule { return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams); } - case 'gitInfo': { - return this.gitCtr.gitInfo(params as { isGithub?: boolean; scope: string }); + case 'getGitBranch': { + return this.gitCtr.getGitBranch((params as { path: string }).path); + } + + case 'getLinkedPullRequest': { + return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string }); + } + + case 'getGitWorkingTreeStatus': { + return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path); + } + + case 'getGitAheadBehind': { + return this.gitCtr.getGitAheadBehind((params as { path: string }).path); + } + + case 'listGitBranches': { + return this.gitCtr.listGitBranches((params as { path: string }).path); + } + + case 'checkoutGitBranch': { + return this.gitCtr.checkoutGitBranch( + params as { branch: string; create?: boolean; path: string }, + ); + } + + case 'pullGitBranch': { + return this.gitCtr.pullGitBranch(params as { path: string }); + } + + case 'pushGitBranch': { + return this.gitCtr.pushGitBranch(params as { path: string }); + } + + case 'getGitWorkingTreePatches': { + return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path); + } + + case 'getGitWorkingTreeFiles': { + return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path); + } + + case 'getProjectFileIndex': { + return this.localFileCtr.getProjectFileIndex(params as { scope?: string }); + } + + case 'getGitBranchDiff': { + return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string }); + } + + case 'listGitRemoteBranches': { + return this.gitCtr.listGitRemoteBranches((params as { path: string }).path); + } + + case 'revertGitFile': { + return this.gitCtr.revertGitFile(params as { filePath: string; path: string }); + } + + case 'statPath': { + return this.workspaceCtr.statPath(params as { path: string }); } default: { diff --git a/apps/desktop/src/main/controllers/WorkspaceCtr.ts b/apps/desktop/src/main/controllers/WorkspaceCtr.ts index 658ad210d8..ac29a2454a 100644 --- a/apps/desktop/src/main/controllers/WorkspaceCtr.ts +++ b/apps/desktop/src/main/controllers/WorkspaceCtr.ts @@ -1,4 +1,4 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { @@ -9,6 +9,7 @@ import { type ProjectSkillItem, } from '@lobechat/electron-client-ipc'; +import { detectRepoType } from '@/utils/git'; import { createLogger } from '@/utils/logger'; import { ControllerModule, IpcMethod } from './index'; @@ -193,6 +194,26 @@ export default class WorkspaceCtr extends ControllerModule { return { instructions, root, skills }; } + /** + * Check whether a path exists on this device and is a directory, plus its git + * repo type (`git` / `github` / none). Used to validate a manually-entered + * working directory from a web / remote client (which can't browse this + * device's filesystem) before binding it, and to render the right dir icon. + */ + @IpcMethod() + async statPath(params: { + path: string; + }): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> { + try { + const stats = await stat(params.path); + if (!stats.isDirectory()) return { exists: true, isDirectory: false }; + const repoType = await detectRepoType(params.path); + return { exists: true, isDirectory: true, repoType }; + } catch { + return { exists: false, isDirectory: false }; + } + } + /** * Read the project-root agent instructions files. Collects every present * candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both diff --git a/apps/desktop/src/main/env.ts b/apps/desktop/src/main/env.ts index 11440c13ef..6af11ddb4f 100644 --- a/apps/desktop/src/main/env.ts +++ b/apps/desktop/src/main/env.ts @@ -70,6 +70,11 @@ export const getDesktopEnv = memoize(() => // escape hatch: allow testing static renderer in dev via env DESKTOP_RENDERER_STATIC: envBoolean(false), + // device gateway url override (dev: point at a local `wrangler dev` instance, + // e.g. http://localhost:8787). Falls back to the stored value, then the + // production gateway. + DEVICE_GATEWAY_URL: z.string().url().optional(), + // Force use dev-app-update.yml even in packaged app (for testing updates) FORCE_DEV_UPDATE_CONFIG: envBoolean(false), diff --git a/apps/desktop/src/main/services/gatewayConnectionSrv.ts b/apps/desktop/src/main/services/gatewayConnectionSrv.ts index 28d6abadb2..50c1726e53 100644 --- a/apps/desktop/src/main/services/gatewayConnectionSrv.ts +++ b/apps/desktop/src/main/services/gatewayConnectionSrv.ts @@ -17,6 +17,7 @@ import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc'; import { app, powerSaveBlocker } from 'electron'; import { isDev } from '@/const/env'; +import { getDesktopEnv } from '@/env'; import { createLogger } from '@/utils/logger'; import { ServiceModule } from './index'; @@ -628,7 +629,13 @@ export default class GatewayConnectionService extends ServiceModule { // ─── Gateway URL ─── private getGatewayUrl(): string { - return this.app.storeManager.get('gatewayUrl') || DEFAULT_GATEWAY_URL; + // Env override wins (dev: point at a local `wrangler dev` gateway), then the + // user-configured store value, then the production default. + return ( + getDesktopEnv().DEVICE_GATEWAY_URL || + this.app.storeManager.get('gatewayUrl') || + DEFAULT_GATEWAY_URL + ); } // ─── Token Helpers ─── diff --git a/locales/ar/labs.json b/locales/ar/labs.json index e1942c29e5..0a193149f2 100644 --- a/locales/ar/labs.json +++ b/locales/ar/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "التكرار الذاتي للوكيل", "features.assistantMessageGroup.desc": "تجميع رسائل الوكيل ونتائج استدعاء الأدوات معًا للعرض", "features.assistantMessageGroup.title": "تجميع رسائل الوكيل", - "features.executionDeviceSwitcher.desc": "إظهار مفتاح تبديل جهاز التنفيذ في شريط أدوات الوكيل المتنوع بحيث يمكنك توجيه العمليات إلى هذا الجهاز أو إلى سحابة تجريبية أو إلى جهاز بعيد مرتبط.", - "features.executionDeviceSwitcher.title": "مفتاح تبديل جهاز التنفيذ", "features.gatewayMode.desc": "تنفيذ مهام الوكيل على الخادم عبر بوابة WebSocket بدلًا من التشغيل محليًا، مما يتيح تنفيذًا أسرع ويقلل من استهلاك موارد العميل.", "features.gatewayMode.title": "تنفيذ الوكيل من جانب الخادم (البوابة)", "features.groupChat.desc": "تمكين تنسيق الدردشة الجماعية متعددة الوكلاء.", diff --git a/locales/bg-BG/labs.json b/locales/bg-BG/labs.json index def8728278..7c8eb8ff36 100644 --- a/locales/bg-BG/labs.json +++ b/locales/bg-BG/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Само-итерация на агента", "features.assistantMessageGroup.desc": "Групиране на съобщенията от агента и резултатите от извикванията на инструменти заедно за показване", "features.assistantMessageGroup.title": "Групиране на съобщения от агент", - "features.executionDeviceSwitcher.desc": "Показване на превключвателя за изпълнителни устройства в хетерогенната лента с инструменти на агента, за да можете да насочвате изпълненията към това устройство, облачен пясъчник или свързано отдалечено устройство.", - "features.executionDeviceSwitcher.title": "Превключвател за изпълнителни устройства", "features.gatewayMode.desc": "Изпълнявайте задачите на агента на сървъра чрез Gateway WebSocket вместо локално. Осигурява по-бързо изпълнение и намалява използването на ресурси от клиента.", "features.gatewayMode.title": "Изпълнение на агента от страна на сървъра (Gateway)", "features.groupChat.desc": "Активиране на координация в групов чат с множество агенти.", diff --git a/locales/de-DE/labs.json b/locales/de-DE/labs.json index bb3c52bd37..a9d4b6e73f 100644 --- a/locales/de-DE/labs.json +++ b/locales/de-DE/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Agenten-Selbstiteration", "features.assistantMessageGroup.desc": "Agenten-Nachrichten und deren Tool-Ergebnisse gemeinsam anzeigen", "features.assistantMessageGroup.title": "Agenten-Nachrichten gruppieren", - "features.executionDeviceSwitcher.desc": "Zeigen Sie den Ausführungsgeräte-Umschalter in der heterogenen Agenten-Toolbar an, damit Sie Läufe auf dieses Gerät, eine Cloud-Sandbox oder ein verbundenes Remote-Gerät leiten können.", - "features.executionDeviceSwitcher.title": "Ausführungsgeräte-Umschalter", "features.gatewayMode.desc": "Führt Agentenaufgaben über die Gateway-WebSocket-Verbindung auf dem Server aus, statt sie lokal auszuführen. Ermöglicht eine schnellere Ausführung und verringert die Client-Ressourcennutzung.", "features.gatewayMode.title": "Serverseitige Agentenausführung (Gateway)", "features.groupChat.desc": "Koordination von Gruppenchats mit mehreren Agenten aktivieren.", diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index b4596ca86e..cafbeb85f6 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -209,14 +209,18 @@ "heteroAgent.cloudRepo.notSet": "No repo selected", "heteroAgent.cloudRepo.sectionTitle": "Repositories", "heteroAgent.executionTarget.downloadDesktop": "Get Desktop App", - "heteroAgent.executionTarget.infoTooltip": "Pick a remote device to drive that machine from the web. \"This device\" runs the agent locally and is only available inside the desktop app.", + "heteroAgent.executionTarget.downloadDesktopDesc": "Run agents with access to your computer", + "heteroAgent.executionTarget.downloadDesktopTitle": "Get the desktop app", + "heteroAgent.executionTarget.infoTooltip": "Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.", "heteroAgent.executionTarget.loading": "Loading devices…", "heteroAgent.executionTarget.local": "This device", "heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app", - "heteroAgent.executionTarget.noDevices": "No remote devices yet. Install the desktop app or run `lh connect` on another machine.", + "heteroAgent.executionTarget.noDevices": "No remote devices yet. Run `lh connect` on another machine to add one.", + "heteroAgent.executionTarget.none": "No device", + "heteroAgent.executionTarget.noneDesc": "No device enabled", "heteroAgent.executionTarget.offline": "Offline", "heteroAgent.executionTarget.online": "Online", - "heteroAgent.executionTarget.sandbox": "Cloud sandbox", + "heteroAgent.executionTarget.sandbox": "Cloud Sandbox", "heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox", "heteroAgent.executionTarget.title": "Execution Device", "heteroAgent.executionTarget.unknownDevice": "Unknown device", diff --git a/locales/en-US/device.json b/locales/en-US/device.json new file mode 100644 index 0000000000..417402257a --- /dev/null +++ b/locales/en-US/device.json @@ -0,0 +1,56 @@ +{ + "workingDirectory.addFolder": "Add folder…", + "workingDirectory.addFolderDesc": "Enter an absolute path on the target device, e.g. /Users/name/projects", + "workingDirectory.addFolderTitle": "Add working directory", + "workingDirectory.agentDescription": "Default working directory for all conversations with this Agent", + "workingDirectory.agentLevel": "Agent Working Directory", + "workingDirectory.branchSearchPlaceholder": "Search branches", + "workingDirectory.branchesEmpty": "No local branches", + "workingDirectory.branchesHeading": "Branches", + "workingDirectory.branchesLoading": "Loading branches…", + "workingDirectory.branchesNoMatch": "No matching branches", + "workingDirectory.cancel": "Cancel", + "workingDirectory.checkoutAction": "Checkout", + "workingDirectory.checkoutFailed": "Checkout failed", + "workingDirectory.chooseDifferentFolder": "Choose a folder...", + "workingDirectory.clear": "Clear", + "workingDirectory.createBranchAction": "Checkout new branch…", + "workingDirectory.createBranchTitle": "Create new branch", + "workingDirectory.current": "Current working directory", + "workingDirectory.detachedHead": "Detached HEAD at {{sha}}", + "workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}", + "workingDirectory.filesAdded": "Added", + "workingDirectory.filesDeleted": "Deleted", + "workingDirectory.filesEmpty": "No uncommitted changes", + "workingDirectory.filesLoading": "Loading changes…", + "workingDirectory.filesModified": "Modified", + "workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests", + "workingDirectory.newBranchPlaceholder": "feature/new-branch-name", + "workingDirectory.noRecent": "No recent directories", + "workingDirectory.notSet": "Click to set working directory", + "workingDirectory.pathNotDirectory": "This path is not a directory", + "workingDirectory.pathNotExist": "This path doesn't exist on the device", + "workingDirectory.placeholder": "Enter directory path, e.g. /Users/name/projects", + "workingDirectory.prTooltipWithExtra": "{{title}} (+{{count}} more open PR on this branch)", + "workingDirectory.pullAction": "Click to pull {{count}} commit(s) from {{upstream}}", + "workingDirectory.pullFailed": "Pull failed", + "workingDirectory.pullInProgress": "Pulling…", + "workingDirectory.pullNoop": "Already up to date", + "workingDirectory.pullSuccess": "Pulled successfully", + "workingDirectory.pushAction": "Click to push {{count}} commit(s) to {{target}}", + "workingDirectory.pushActionNew": "Click to create branch {{target}}", + "workingDirectory.pushFailed": "Push failed", + "workingDirectory.pushInProgress": "Pushing…", + "workingDirectory.pushNoop": "Everything up-to-date", + "workingDirectory.pushSuccess": "Pushed successfully", + "workingDirectory.recent": "Recent", + "workingDirectory.refreshGitStatus": "Refresh branch & PR status", + "workingDirectory.removeRecent": "Remove from recent", + "workingDirectory.selectFolder": "Select folder", + "workingDirectory.title": "Working Directory", + "workingDirectory.topicDescription": "Override Agent default for this conversation only", + "workingDirectory.topicLevel": "Conversation override", + "workingDirectory.topicOverride": "Override for this conversation", + "workingDirectory.uncommittedChanges_one": "Uncommitted changes: {{count}} file", + "workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files" +} diff --git a/locales/en-US/labs.json b/locales/en-US/labs.json index e6e5b2a1de..1815f03c1d 100644 --- a/locales/en-US/labs.json +++ b/locales/en-US/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Agent Self-iteration", "features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display", "features.assistantMessageGroup.title": "Agent Message Grouping", - "features.executionDeviceSwitcher.desc": "Surface the execution-device switcher in the heterogeneous agent toolbar so you can route runs to this device, a cloud sandbox, or a bound remote device.", - "features.executionDeviceSwitcher.title": "Execution Device Switcher", "features.gatewayMode.desc": "Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.", "features.gatewayMode.title": "Server-Side Agent Execution (Gateway)", "features.groupChat.desc": "Enable multi-agent group chat coordination.", diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index 2043f557c1..a47ad71b0e 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -552,54 +552,6 @@ "list.item.local.title": "Custom", "loading.content": "Calling Skill…", "loading.plugin": "Skill running…", - "localSystem.workingDirectory.agentDescription": "Default working directory for all conversations with this Agent", - "localSystem.workingDirectory.agentLevel": "Agent Working Directory", - "localSystem.workingDirectory.branchSearchPlaceholder": "Search branches", - "localSystem.workingDirectory.branchesEmpty": "No local branches", - "localSystem.workingDirectory.branchesHeading": "Branches", - "localSystem.workingDirectory.branchesLoading": "Loading branches…", - "localSystem.workingDirectory.branchesNoMatch": "No matching branches", - "localSystem.workingDirectory.cancel": "Cancel", - "localSystem.workingDirectory.checkoutAction": "Checkout", - "localSystem.workingDirectory.checkoutFailed": "Checkout failed", - "localSystem.workingDirectory.chooseDifferentFolder": "Choose a folder...", - "localSystem.workingDirectory.clear": "Clear", - "localSystem.workingDirectory.createBranchAction": "Checkout new branch…", - "localSystem.workingDirectory.current": "Current working directory", - "localSystem.workingDirectory.detachedHead": "Detached HEAD at {{sha}}", - "localSystem.workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}", - "localSystem.workingDirectory.filesAdded": "Added", - "localSystem.workingDirectory.filesDeleted": "Deleted", - "localSystem.workingDirectory.filesEmpty": "No uncommitted changes", - "localSystem.workingDirectory.filesLoading": "Loading changes…", - "localSystem.workingDirectory.filesModified": "Modified", - "localSystem.workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests", - "localSystem.workingDirectory.newBranchPlaceholder": "feature/new-branch-name", - "localSystem.workingDirectory.noRecent": "No recent directories", - "localSystem.workingDirectory.notSet": "Click to set working directory", - "localSystem.workingDirectory.placeholder": "Enter directory path, e.g. /Users/name/projects", - "localSystem.workingDirectory.prTooltipWithExtra": "{{title}} (+{{count}} more open PR on this branch)", - "localSystem.workingDirectory.pullAction": "Click to pull {{count}} commit(s) from {{upstream}}", - "localSystem.workingDirectory.pullFailed": "Pull failed", - "localSystem.workingDirectory.pullInProgress": "Pulling…", - "localSystem.workingDirectory.pullNoop": "Already up to date", - "localSystem.workingDirectory.pullSuccess": "Pulled successfully", - "localSystem.workingDirectory.pushAction": "Click to push {{count}} commit(s) to {{target}}", - "localSystem.workingDirectory.pushActionNew": "Click to create branch {{target}}", - "localSystem.workingDirectory.pushFailed": "Push failed", - "localSystem.workingDirectory.pushInProgress": "Pushing…", - "localSystem.workingDirectory.pushNoop": "Everything up-to-date", - "localSystem.workingDirectory.pushSuccess": "Pushed successfully", - "localSystem.workingDirectory.recent": "Recent", - "localSystem.workingDirectory.refreshGitStatus": "Refresh branch & PR status", - "localSystem.workingDirectory.removeRecent": "Remove from recent", - "localSystem.workingDirectory.selectFolder": "Select folder", - "localSystem.workingDirectory.title": "Working Directory", - "localSystem.workingDirectory.topicDescription": "Override Agent default for this conversation only", - "localSystem.workingDirectory.topicLevel": "Conversation override", - "localSystem.workingDirectory.topicOverride": "Override for this conversation", - "localSystem.workingDirectory.uncommittedChanges_one": "Uncommitted changes: {{count}} file", - "localSystem.workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files", "mcpEmpty.deployment": "No deployment options", "mcpEmpty.prompts": "No prompts", "mcpEmpty.resources": "No resources", diff --git a/locales/es-ES/labs.json b/locales/es-ES/labs.json index ffb2ef5761..7264b7f0db 100644 --- a/locales/es-ES/labs.json +++ b/locales/es-ES/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Autoiteración del agente", "features.assistantMessageGroup.desc": "Agrupa los mensajes del agente y los resultados de sus herramientas para mostrarlos juntos", "features.assistantMessageGroup.title": "Agrupación de Mensajes del Agente", - "features.executionDeviceSwitcher.desc": "Mostrar el conmutador de dispositivo de ejecución en la barra de herramientas del agente heterogéneo para que puedas dirigir las ejecuciones a este dispositivo, un sandbox en la nube o un dispositivo remoto vinculado.", - "features.executionDeviceSwitcher.title": "Conmutador de Dispositivo de Ejecución", "features.gatewayMode.desc": "Ejecuta las tareas del agente en el servidor a través del WebSocket de Gateway en lugar de hacerlo localmente. Permite una ejecución más rápida y reduce el uso de recursos del cliente.", "features.gatewayMode.title": "Ejecución del agente del lado del servidor (Gateway)", "features.groupChat.desc": "Activa la coordinación de chat grupal con múltiples agentes.", diff --git a/locales/fa-IR/labs.json b/locales/fa-IR/labs.json index da81eb7554..b5107800ae 100644 --- a/locales/fa-IR/labs.json +++ b/locales/fa-IR/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "خود‌تکراری عامل", "features.assistantMessageGroup.desc": "نمایش گروهی پیام‌های عامل و نتایج ابزارهای فراخوانی‌شده به‌صورت یکجا", "features.assistantMessageGroup.title": "گروه‌بندی پیام‌های عامل", - "features.executionDeviceSwitcher.desc": "نمایش تغییر‌دهنده دستگاه اجرا در نوار ابزار نماینده ناهمگن تا بتوانید اجراها را به این دستگاه، یک محیط ابری یا یک دستگاه راه دور متصل هدایت کنید.", - "features.executionDeviceSwitcher.title": "تغییر‌دهنده دستگاه اجرا", "features.gatewayMode.desc": "اجرای وظایف ایجنت روی سرور از طریق وب‌سوکت Gateway به‌جای اجرای محلی. این کار سرعت اجرا را افزایش داده و مصرف منابع در دستگاه کاربر را کاهش می‌دهد.", "features.gatewayMode.title": "اجرای ایجنت در سمت سرور (Gateway)", "features.groupChat.desc": "فعال‌سازی هماهنگی گفت‌وگوی گروهی چندعاملی.", diff --git a/locales/fr-FR/labs.json b/locales/fr-FR/labs.json index 6715c61790..efb25f94c6 100644 --- a/locales/fr-FR/labs.json +++ b/locales/fr-FR/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Auto‑itération de l’agent", "features.assistantMessageGroup.desc": "Regroupez les messages de l'agent et les résultats de leurs appels d'outils pour les afficher ensemble", "features.assistantMessageGroup.title": "Regroupement des messages de l'agent", - "features.executionDeviceSwitcher.desc": "Afficher le commutateur de périphérique d'exécution dans la barre d'outils hétérogène de l'agent afin de pouvoir rediriger les exécutions vers cet appareil, un bac à sable cloud ou un appareil distant lié.", - "features.executionDeviceSwitcher.title": "Commutateur de Périphérique d'Exécution", "features.gatewayMode.desc": "Exécute les tâches de l’agent sur le serveur via un WebSocket Gateway au lieu de les exécuter localement. Permet une exécution plus rapide et réduit l’utilisation des ressources du client.", "features.gatewayMode.title": "Exécution de l’agent côté serveur (Gateway)", "features.groupChat.desc": "Activez la coordination de discussions de groupe multi-agents.", diff --git a/locales/it-IT/labs.json b/locales/it-IT/labs.json index d2d06c0ef3..fd923998bb 100644 --- a/locales/it-IT/labs.json +++ b/locales/it-IT/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Auto-iterazione dell'agente", "features.assistantMessageGroup.desc": "Raggruppa i messaggi dell'agente e i risultati delle chiamate agli strumenti per una visualizzazione unificata", "features.assistantMessageGroup.title": "Raggruppamento Messaggi Agente", - "features.executionDeviceSwitcher.desc": "Visualizza il selettore del dispositivo di esecuzione nella barra degli strumenti eterogenea dell'agente, così puoi instradare le esecuzioni su questo dispositivo, un sandbox cloud o un dispositivo remoto associato.", - "features.executionDeviceSwitcher.title": "Selettore del Dispositivo di Esecuzione", "features.gatewayMode.desc": "Esegui le attività dell’agente sul server tramite Gateway WebSocket invece di eseguirle in locale. Consente un’esecuzione più rapida e riduce l’utilizzo delle risorse del client.", "features.gatewayMode.title": "Esecuzione dell’Agente Lato Server (Gateway)", "features.groupChat.desc": "Abilita il coordinamento della chat di gruppo con più agenti.", diff --git a/locales/ja-JP/labs.json b/locales/ja-JP/labs.json index be5689c77e..261410f24e 100644 --- a/locales/ja-JP/labs.json +++ b/locales/ja-JP/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "エージェントの自己反復", "features.assistantMessageGroup.desc": "アシスタントのメッセージとそのツール呼び出し結果をグループ化して表示します", "features.assistantMessageGroup.title": "アシスタントメッセージのグループ化表示", - "features.executionDeviceSwitcher.desc": "異種エージェントツールバーに実行デバイススイッチャーを表示し、このデバイス、クラウドサンドボックス、またはバインドされたリモートデバイスに実行をルーティングできます。", - "features.executionDeviceSwitcher.title": "実行デバイススイッチャー", "features.gatewayMode.desc": "エージェントのタスクをローカルではなく Gateway WebSocket を介してサーバー上で実行します。これにより、より高速な処理が可能になり、クライアント側のリソース消費を削減できます。", "features.gatewayMode.title": "サーバーサイドエージェント実行(Gateway)", "features.groupChat.desc": "複数のAIアシスタントによるグループチャット機能を有効にします。", diff --git a/locales/ko-KR/labs.json b/locales/ko-KR/labs.json index 9ca6250906..2ac672ea15 100644 --- a/locales/ko-KR/labs.json +++ b/locales/ko-KR/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "에이전트 자기 반복", "features.assistantMessageGroup.desc": "도우미 메시지와 해당 도구 호출 결과를 그룹으로 묶어 표시합니다", "features.assistantMessageGroup.title": "도우미 메시지 그룹화", - "features.executionDeviceSwitcher.desc": "이종 에이전트 툴바에서 실행 장치 전환기를 표시하여 실행을 이 장치, 클라우드 샌드박스 또는 연결된 원격 장치로 라우팅할 수 있습니다.", - "features.executionDeviceSwitcher.title": "실행 장치 전환기", "features.gatewayMode.desc": "로컬에서 실행하는 대신 Gateway WebSocket을 통해 서버에서 에이전트 작업을 실행합니다. 더 빠른 처리 속도를 제공하고 클라이언트 리소스 사용을 줄여줍니다.", "features.gatewayMode.title": "서버 사이드 에이전트 실행(Gateway)", "features.groupChat.desc": "다중 도우미 그룹 채팅 조정 기능을 활성화합니다.", diff --git a/locales/nl-NL/labs.json b/locales/nl-NL/labs.json index 8369951790..2ea829f87b 100644 --- a/locales/nl-NL/labs.json +++ b/locales/nl-NL/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Agent-zelfiteratie", "features.assistantMessageGroup.desc": "Groepeer berichten van de agent en de resultaten van hun tool-aanroepen samen voor weergave", "features.assistantMessageGroup.title": "Agentberichtengroepering", - "features.executionDeviceSwitcher.desc": "Toon de uitvoerapparaat-schakelaar in de heterogene agentwerkbalk, zodat je runs kunt routeren naar dit apparaat, een cloud-sandbox of een gekoppeld extern apparaat.", - "features.executionDeviceSwitcher.title": "Uitvoerapparaat-schakelaar", "features.gatewayMode.desc": "Voer agenttaken op de server uit via de Gateway‑WebSocket in plaats van ze lokaal uit te voeren. Zorgt voor snellere uitvoering en vermindert het gebruik van clientbronnen.", "features.gatewayMode.title": "Server-side uitvoering van agenten (Gateway)", "features.groupChat.desc": "Schakel coördinatie van groepschats met meerdere agenten in.", diff --git a/locales/pl-PL/labs.json b/locales/pl-PL/labs.json index e751b284c2..af3df1b09c 100644 --- a/locales/pl-PL/labs.json +++ b/locales/pl-PL/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Samoiteracja agenta", "features.assistantMessageGroup.desc": "Grupuj wiadomości agenta i wyniki wywołań narzędzi razem do wyświetlenia", "features.assistantMessageGroup.title": "Grupowanie Wiadomości Agenta", - "features.executionDeviceSwitcher.desc": "Wyświetl przełącznik urządzeń wykonawczych na pasku narzędzi agenta heterogenicznego, aby móc kierować uruchomienia na to urządzenie, chmurę sandbox lub powiązane urządzenie zdalne.", - "features.executionDeviceSwitcher.title": "Przełącznik Urządzeń Wykonawczych", "features.gatewayMode.desc": "Wykonuj zadania agenta na serwerze przez Gateway WebSocket zamiast lokalnie. Umożliwia to szybsze wykonywanie i zmniejsza wykorzystanie zasobów po stronie klienta.", "features.gatewayMode.title": "Wykonywanie agenta po stronie serwera (Gateway)", "features.groupChat.desc": "Włącz koordynację czatu grupowego z wieloma agentami.", diff --git a/locales/pt-BR/labs.json b/locales/pt-BR/labs.json index cb7c383c05..7f2009b27c 100644 --- a/locales/pt-BR/labs.json +++ b/locales/pt-BR/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Autoiteração do Agente", "features.assistantMessageGroup.desc": "Agrupe mensagens do agente e os resultados das chamadas de ferramentas para exibição conjunta", "features.assistantMessageGroup.title": "Agrupamento de Mensagens do Agente", - "features.executionDeviceSwitcher.desc": "Exibir o alternador de dispositivo de execução na barra de ferramentas de agentes heterogêneos para que você possa direcionar execuções para este dispositivo, um sandbox na nuvem ou um dispositivo remoto vinculado.", - "features.executionDeviceSwitcher.title": "Alternador de Dispositivo de Execução", "features.gatewayMode.desc": "Execute tarefas de agente no servidor via Gateway WebSocket em vez de executá-las localmente. Permite uma execução mais rápida e reduz o uso de recursos do cliente.", "features.gatewayMode.title": "Execução de Agente no Servidor (Gateway)", "features.groupChat.desc": "Ative a coordenação de bate-papo em grupo com múltiplos agentes.", diff --git a/locales/ru-RU/labs.json b/locales/ru-RU/labs.json index 8aed51279b..cd7ff8f817 100644 --- a/locales/ru-RU/labs.json +++ b/locales/ru-RU/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Самоитерация агента", "features.assistantMessageGroup.desc": "Группируйте сообщения агента и результаты вызова инструментов для совместного отображения", "features.assistantMessageGroup.title": "Группировка сообщений агента", - "features.executionDeviceSwitcher.desc": "Отображать переключатель устройства выполнения в панели инструментов гетерогенного агента, чтобы вы могли направлять выполнение на это устройство, облачную песочницу или подключенное удаленное устройство.", - "features.executionDeviceSwitcher.title": "Переключатель устройства выполнения", "features.gatewayMode.desc": "Выполняйте задачи агента на сервере через WebSocket Gateway вместо локального запуска. Обеспечивает более быструю работу и снижает нагрузку на ресурсы клиента.", "features.gatewayMode.title": "Серверное выполнение агента (Gateway)", "features.groupChat.desc": "Включите координацию группового чата с несколькими агентами.", diff --git a/locales/tr-TR/labs.json b/locales/tr-TR/labs.json index 74bec7fe24..6123077ed3 100644 --- a/locales/tr-TR/labs.json +++ b/locales/tr-TR/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Aracının Kendi Kendine Yinelemesi", "features.assistantMessageGroup.desc": "Temsilci mesajlarını ve bunlara ait araç çağrısı sonuçlarını birlikte gruplayarak görüntüleyin", "features.assistantMessageGroup.title": "Temsilci Mesaj Gruplama", - "features.executionDeviceSwitcher.desc": "Heterojen ajan araç çubuğunda yürütme cihazı değiştiricisini göstererek çalıştırmaları bu cihaza, bir bulut sanal alanına veya bağlı bir uzak cihaza yönlendirebilirsiniz.", - "features.executionDeviceSwitcher.title": "Yürütme Cihazı Değiştirici", "features.gatewayMode.desc": "Aracı görevlerini yerel olarak çalıştırmak yerine Gateway WebSocket üzerinden sunucuda yürütün. Daha hızlı yürütme sağlar ve istemci kaynak kullanımını azaltır.", "features.gatewayMode.title": "Sunucu Taraflı Aracı Yürütme (Gateway)", "features.groupChat.desc": "Çoklu temsilci grup sohbeti koordinasyonunu etkinleştirin.", diff --git a/locales/vi-VN/labs.json b/locales/vi-VN/labs.json index b8e90ee032..353f6075d3 100644 --- a/locales/vi-VN/labs.json +++ b/locales/vi-VN/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Tự lặp của tác nhân", "features.assistantMessageGroup.desc": "Nhóm các tin nhắn của tác nhân và kết quả gọi công cụ lại với nhau để hiển thị", "features.assistantMessageGroup.title": "Nhóm Tin Nhắn Tác Nhân", - "features.executionDeviceSwitcher.desc": "Hiển thị công cụ chuyển đổi thiết bị thực thi trong thanh công cụ đại lý không đồng nhất để bạn có thể định tuyến các lần chạy đến thiết bị này, một sandbox đám mây hoặc một thiết bị từ xa được liên kết.", - "features.executionDeviceSwitcher.title": "Công Cụ Chuyển Đổi Thiết Bị Thực Thi", "features.gatewayMode.desc": "Thực thi các tác vụ của tác nhân trên máy chủ thông qua Gateway WebSocket thay vì chạy cục bộ. Giúp tăng tốc độ xử lý và giảm mức sử dụng tài nguyên trên thiết bị của người dùng.", "features.gatewayMode.title": "Thực thi tác nhân phía máy chủ (Gateway)", "features.groupChat.desc": "Kích hoạt phối hợp trò chuyện nhóm với nhiều tác nhân.", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 79585af88a..799c20d2c9 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -209,11 +209,15 @@ "heteroAgent.cloudRepo.notSet": "未选择仓库", "heteroAgent.cloudRepo.sectionTitle": "代码仓库", "heteroAgent.executionTarget.downloadDesktop": "下载桌面端", - "heteroAgent.executionTarget.infoTooltip": "选择「远程设备」后可在网页中驱动该机器;「本机」仅在桌面端内运行 agent。", + "heteroAgent.executionTarget.downloadDesktopDesc": "让 Agent 直接连接你的电脑", + "heteroAgent.executionTarget.downloadDesktopTitle": "下载桌面端", + "heteroAgent.executionTarget.infoTooltip": "选择某台设备后,Agent 将以该设备为运行环境,读写文件、操作电脑。云端沙箱由 LobeHub Marketplace 提供服务", "heteroAgent.executionTarget.loading": "正在加载设备…", "heteroAgent.executionTarget.local": "本机", "heteroAgent.executionTarget.localDesc": "在当前桌面端以本地进程运行", - "heteroAgent.executionTarget.noDevices": "暂无远程设备。在另一台机器上安装桌面端或执行 `lh connect` 接入。", + "heteroAgent.executionTarget.noDevices": "暂无远程设备。在另一台机器上执行 `lh connect` 即可接入。", + "heteroAgent.executionTarget.none": "无设备", + "heteroAgent.executionTarget.noneDesc": "不启用任何设备", "heteroAgent.executionTarget.offline": "离线", "heteroAgent.executionTarget.online": "在线", "heteroAgent.executionTarget.sandbox": "云端沙箱", diff --git a/locales/zh-CN/device.json b/locales/zh-CN/device.json new file mode 100644 index 0000000000..9449015de1 --- /dev/null +++ b/locales/zh-CN/device.json @@ -0,0 +1,56 @@ +{ + "workingDirectory.addFolder": "添加目录…", + "workingDirectory.addFolderDesc": "输入目标设备上的绝对路径,如 /Users/name/projects", + "workingDirectory.addFolderTitle": "添加工作目录", + "workingDirectory.agentDescription": "该助手下所有对话的默认工作目录", + "workingDirectory.agentLevel": "代理工作目录", + "workingDirectory.branchSearchPlaceholder": "搜索分支", + "workingDirectory.branchesEmpty": "暂无本地分支", + "workingDirectory.branchesHeading": "分支", + "workingDirectory.branchesLoading": "加载分支中…", + "workingDirectory.branchesNoMatch": "没有匹配的分支", + "workingDirectory.cancel": "取消", + "workingDirectory.checkoutAction": "切换分支", + "workingDirectory.checkoutFailed": "切换分支失败", + "workingDirectory.chooseDifferentFolder": "选择其他文件夹", + "workingDirectory.clear": "清除目录", + "workingDirectory.createBranchAction": "检出新分支…", + "workingDirectory.createBranchTitle": "创建新分支", + "workingDirectory.current": "当前工作目录", + "workingDirectory.detachedHead": "游离 HEAD,当前提交 {{sha}}", + "workingDirectory.diffStatTooltip": "新增 {{added}} · 修改 {{modified}} · 删除 {{deleted}}", + "workingDirectory.filesAdded": "新增", + "workingDirectory.filesDeleted": "删除", + "workingDirectory.filesEmpty": "没有未提交的变更", + "workingDirectory.filesLoading": "加载中…", + "workingDirectory.filesModified": "修改", + "workingDirectory.ghMissing": "安装并登录 GitHub CLI(gh)即可显示关联的 Pull Request", + "workingDirectory.newBranchPlaceholder": "feature/新分支名称", + "workingDirectory.noRecent": "暂无最近目录", + "workingDirectory.notSet": "点击设置工作目录", + "workingDirectory.pathNotDirectory": "该路径不是一个目录", + "workingDirectory.pathNotExist": "该设备上不存在此路径", + "workingDirectory.placeholder": "输入目录路径,如 /Users/name/projects", + "workingDirectory.prTooltipWithExtra": "{{title}}(此分支还有 {{count}} 个开放 PR)", + "workingDirectory.pullAction": "点击从 {{upstream}} 拉取 {{count}} 个提交", + "workingDirectory.pullFailed": "拉取失败", + "workingDirectory.pullInProgress": "拉取中…", + "workingDirectory.pullNoop": "已是最新", + "workingDirectory.pullSuccess": "拉取成功", + "workingDirectory.pushAction": "点击推送 {{count}} 个提交到 {{target}}", + "workingDirectory.pushActionNew": "点击创建 {{target}} 分支", + "workingDirectory.pushFailed": "推送失败", + "workingDirectory.pushInProgress": "推送中…", + "workingDirectory.pushNoop": "已是最新", + "workingDirectory.pushSuccess": "推送成功", + "workingDirectory.recent": "最近使用", + "workingDirectory.refreshGitStatus": "刷新分支与 PR 状态", + "workingDirectory.removeRecent": "从最近目录中移除", + "workingDirectory.selectFolder": "选择文件夹", + "workingDirectory.title": "工作目录", + "workingDirectory.topicDescription": "仅覆盖当前对话的工作目录", + "workingDirectory.topicLevel": "对话覆盖", + "workingDirectory.topicOverride": "为当前对话覆盖设置", + "workingDirectory.uncommittedChanges_one": "未提交的更改:{{count}} 个文件", + "workingDirectory.uncommittedChanges_other": "未提交的更改:{{count}} 个文件" +} diff --git a/locales/zh-CN/labs.json b/locales/zh-CN/labs.json index 30461cf423..f363f184c6 100644 --- a/locales/zh-CN/labs.json +++ b/locales/zh-CN/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "Agent 自我迭代", "features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示", "features.assistantMessageGroup.title": "代理消息分组", - "features.executionDeviceSwitcher.desc": "在异构 Agent 工具栏中展示「执行设备」切换器,可将运行任务路由到本机、云端沙箱或已绑定的远程设备。", - "features.executionDeviceSwitcher.title": "执行设备切换器", "features.gatewayMode.desc": "通过 Gateway 在服务端执行 Agent 任务。可实现关闭浏览器后仍然执行 agent。", "features.gatewayMode.title": "服务端代理执行(Gateway)", "features.groupChat.desc": "启用多代理协同群聊功能。", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index f9211e3ce2..8c539e5c15 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -552,54 +552,6 @@ "list.item.local.title": "自定义", "loading.content": "调用技能中…", "loading.plugin": "技能运行中…", - "localSystem.workingDirectory.agentDescription": "该助手下所有对话的默认工作目录", - "localSystem.workingDirectory.agentLevel": "代理工作目录", - "localSystem.workingDirectory.branchSearchPlaceholder": "搜索分支", - "localSystem.workingDirectory.branchesEmpty": "暂无本地分支", - "localSystem.workingDirectory.branchesHeading": "分支", - "localSystem.workingDirectory.branchesLoading": "加载分支中…", - "localSystem.workingDirectory.branchesNoMatch": "没有匹配的分支", - "localSystem.workingDirectory.cancel": "取消", - "localSystem.workingDirectory.checkoutAction": "切换分支", - "localSystem.workingDirectory.checkoutFailed": "切换分支失败", - "localSystem.workingDirectory.chooseDifferentFolder": "选择其他文件夹", - "localSystem.workingDirectory.clear": "清除目录", - "localSystem.workingDirectory.createBranchAction": "检出新分支…", - "localSystem.workingDirectory.current": "当前工作目录", - "localSystem.workingDirectory.detachedHead": "游离 HEAD,当前提交 {{sha}}", - "localSystem.workingDirectory.diffStatTooltip": "新增 {{added}} · 修改 {{modified}} · 删除 {{deleted}}", - "localSystem.workingDirectory.filesAdded": "新增", - "localSystem.workingDirectory.filesDeleted": "删除", - "localSystem.workingDirectory.filesEmpty": "没有未提交的变更", - "localSystem.workingDirectory.filesLoading": "加载中…", - "localSystem.workingDirectory.filesModified": "修改", - "localSystem.workingDirectory.ghMissing": "安装并登录 GitHub CLI(gh)即可显示关联的 Pull Request", - "localSystem.workingDirectory.newBranchPlaceholder": "feature/新分支名称", - "localSystem.workingDirectory.noRecent": "暂无最近目录", - "localSystem.workingDirectory.notSet": "点击设置工作目录", - "localSystem.workingDirectory.placeholder": "输入目录路径,如 /Users/name/projects", - "localSystem.workingDirectory.prTooltipWithExtra": "{{title}}(此分支还有 {{count}} 个开放 PR)", - "localSystem.workingDirectory.pullAction": "点击从 {{upstream}} 拉取 {{count}} 个提交", - "localSystem.workingDirectory.pullFailed": "拉取失败", - "localSystem.workingDirectory.pullInProgress": "拉取中…", - "localSystem.workingDirectory.pullNoop": "已是最新", - "localSystem.workingDirectory.pullSuccess": "拉取成功", - "localSystem.workingDirectory.pushAction": "点击推送 {{count}} 个提交到 {{target}}", - "localSystem.workingDirectory.pushActionNew": "点击创建 {{target}} 分支", - "localSystem.workingDirectory.pushFailed": "推送失败", - "localSystem.workingDirectory.pushInProgress": "推送中…", - "localSystem.workingDirectory.pushNoop": "已是最新", - "localSystem.workingDirectory.pushSuccess": "推送成功", - "localSystem.workingDirectory.recent": "最近使用", - "localSystem.workingDirectory.refreshGitStatus": "刷新分支与 PR 状态", - "localSystem.workingDirectory.removeRecent": "从最近目录中移除", - "localSystem.workingDirectory.selectFolder": "选择文件夹", - "localSystem.workingDirectory.title": "工作目录", - "localSystem.workingDirectory.topicDescription": "仅覆盖当前对话的工作目录", - "localSystem.workingDirectory.topicLevel": "对话覆盖", - "localSystem.workingDirectory.topicOverride": "为当前对话覆盖设置", - "localSystem.workingDirectory.uncommittedChanges_one": "未提交的更改:{{count}} 个文件", - "localSystem.workingDirectory.uncommittedChanges_other": "未提交的更改:{{count}} 个文件", "mcpEmpty.deployment": "暂无部署选项", "mcpEmpty.prompts": "该技能暂无提示词", "mcpEmpty.resources": "该技能暂无资源", diff --git a/locales/zh-TW/labs.json b/locales/zh-TW/labs.json index 711a670498..2dbe0089b1 100644 --- a/locales/zh-TW/labs.json +++ b/locales/zh-TW/labs.json @@ -5,8 +5,6 @@ "features.agentSelfIteration.title": "代理自我迭代", "features.assistantMessageGroup.desc": "將助手訊息及其工具調用結果彙整成群組顯示", "features.assistantMessageGroup.title": "助手訊息彙整群組", - "features.executionDeviceSwitcher.desc": "在異構代理工具列中顯示執行設備切換器,讓您可以將執行路由至此設備、雲端沙盒或綁定的遠端設備。", - "features.executionDeviceSwitcher.title": "執行設備切換器", "features.gatewayMode.desc": "透過 Gateway WebSocket 在伺服器端執行代理任務,而非在本機運行。可加快執行速度並降低用戶端資源使用。", "features.gatewayMode.title": "伺服器端代理執行(Gateway)", "features.groupChat.desc": "啟用多智能體群組聊天編排功能。", diff --git a/packages/database/src/models/device.ts b/packages/database/src/models/device.ts index 0e173d18d9..d865f4df31 100644 --- a/packages/database/src/models/device.ts +++ b/packages/database/src/models/device.ts @@ -1,6 +1,7 @@ +import type { WorkingDirEntry } from '@lobechat/types'; import { and, desc, eq } from 'drizzle-orm'; -import type { DeviceItem, WorkingDirEntry } from '../schemas'; +import type { DeviceItem } from '../schemas'; import { devices } from '../schemas'; import type { LobeChatDatabase } from '../type'; diff --git a/packages/database/src/schemas/device.ts b/packages/database/src/schemas/device.ts index 3ac733c260..65d0823b5c 100644 --- a/packages/database/src/schemas/device.ts +++ b/packages/database/src/schemas/device.ts @@ -1,33 +1,10 @@ -import type { WorkspaceInitResult } from '@lobechat/types'; +import type { WorkingDirEntry } from '@lobechat/types'; import { index, jsonb, pgTable, text, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core'; import { timestamps, timestamptz } from './_helpers'; import { users } from './user'; import { workspaces } from './workspace'; -/** - * A working directory the device has used. Structured (rather than a bare path - * string) so metadata such as the detected repo type survives — a remote client - * viewing this device can't re-probe its filesystem, so whatever isn't captured - * here at the source is lost. Mirrors the client-local `RecentDirEntry` shape. - */ -export interface WorkingDirEntry { - path: string; - repoType?: 'git' | 'github'; - /** - * Cached "workspace init" scan of this directory (AGENTS.md + project skills). - * Populated server-side at run start via `deviceGateway.initWorkspace` and - * reused within the TTL gated by `workspaceScannedAt`. Also read directly by - * the web UI to render the project's skills / instructions. - */ - workspace?: WorkspaceInitResult; - /** - * Epoch ms when `workspace` was last scanned. Hoisted to the top level (out of - * `workspace`) so freshness can be checked without deserializing the payload. - */ - workspaceScannedAt?: number; -} - /** * Stable device identity anchor — one row per physical machine per user. * diff --git a/packages/types/src/agent/agencyConfig.ts b/packages/types/src/agent/agencyConfig.ts index 46ce3a8dcc..8c3bf675f3 100644 --- a/packages/types/src/agent/agencyConfig.ts +++ b/packages/types/src/agent/agencyConfig.ts @@ -38,13 +38,14 @@ export interface HeterogeneousProviderConfig { /** * Where a hetero agent runs. + * - `none` : no execution environment — plain chat, no built-in run tools * - `local` : in-process spawn on the user's Electron desktop (desktop only) * - `device` : dispatched to an `lh connect` device identified by `boundDeviceId` * - `sandbox` : server-spawned cloud sandbox * * Remote hetero agents (`openclaw` | `hermes`) are always `device`. */ -export type HeteroExecutionTarget = 'device' | 'local' | 'sandbox'; +export type DeviceExecutionTarget = 'device' | 'local' | 'none' | 'sandbox'; /** * Agent agency configuration. @@ -58,10 +59,11 @@ export interface LobeAgentAgencyConfig { */ boundDeviceId?: string; /** - * Execution target for the hetero agent. When omitted, the server falls back - * to `'sandbox'` (or `'device'` for remote hetero providers). + * Execution target for the hetero agent. When omitted, resolves to a + * platform default: `'local'` on desktop, `'none'` on web (or `'device'` for + * remote hetero providers). */ - executionTarget?: HeteroExecutionTarget; + executionTarget?: DeviceExecutionTarget; heterogeneousProvider?: HeterogeneousProviderConfig; /** * Ad-hoc verify criteria mounted directly on this agent, in addition to any diff --git a/packages/types/src/agentExecution/index.ts b/packages/types/src/agentExecution/index.ts index 43b3e1f9c5..ee057de22e 100644 --- a/packages/types/src/agentExecution/index.ts +++ b/packages/types/src/agentExecution/index.ts @@ -93,93 +93,6 @@ export interface ExecAgentAppContext { topicId?: string | null; } -/** - * A project-level skill discovered on the device filesystem - * (`.agents/skills` / `.claude/skills`) by the client at request time. - * Only frontmatter + the absolute SKILL.md path are carried; the SKILL.md - * body and directory tree are loaded on demand at activation time via the - * readFile / listFiles tools. - */ -export interface ProjectSkillMeta { - /** Skill description from SKILL.md frontmatter. */ - description?: string; - /** Skill name from frontmatter (falls back to the directory name). */ - name: string; - /** Absolute path to the skill's SKILL.md on the device filesystem. */ - path: string; -} - -/** - * A single project-root agent instructions file (`AGENTS.md` / `CLAUDE.md`) read - * from the device filesystem during workspace init. Unlike skills (metadata - * only), the full body is carried so it can be injected into the system role and - * rendered in web without a second device round-trip. Carried as a list on - * {@link WorkspaceInitResult} since multiple files can coexist (e.g. both - * `AGENTS.md` and `CLAUDE.md`, or future nested files). - */ -export interface WorkspaceInstructions { - /** Full file content (capped at read time, e.g. 64KB). */ - content: string; - /** Source file the instructions were read from. */ - source: 'AGENTS.md' | 'CLAUDE.md'; -} - -/** - * Result of scanning a bound project directory ("workspace init"): the agent - * instructions file plus the project-level skills discovered under - * `.agents/skills` + `.claude/skills`. Produced in a single device round-trip - * (`deviceGateway.initWorkspace`) and cached on `devices.workingDirs[].workspace` - * so subsequent runs within the TTL — and the web UI — reuse it without - * re-scanning. Intentionally open to growth (env info, git status, …) as more - * environment-preparation logic lands. - * - * The scanned root is not stored here — it is always the enclosing - * `WorkingDirEntry.path`. - */ -export interface WorkspaceInitResult { - /** - * Project-root agent instructions files (`AGENTS.md` / `CLAUDE.md`). Empty - * when none are present. - */ - instructions: WorkspaceInstructions[]; - /** Project-level skills discovered under the project root (metadata only). */ - skills: ProjectSkillMeta[]; -} - -/** - * Git status of a device's working directory, returned by the `gitInfo` device - * RPC so a remote device (or web client) can render branch / file changes / PR - * the same way the local desktop does. Field shapes mirror the desktop git - * service so the UI consumes both paths interchangeably. - */ -export interface DeviceGitInfo { - /** Commit divergence vs the upstream tracking ref. */ - aheadBehind: { - ahead: number; - behind: number; - hasUpstream: boolean; - pushTarget?: string; - pushTargetExists?: boolean; - upstream?: string; - }; - /** Branch name + linked GitHub pull request (when the repo is a GitHub remote). */ - info: { - branch?: string; - detached?: boolean; - extraCount?: number; - ghMissing?: boolean; - pullRequest?: { number: number; state: string; title: string; url: string } | null; - }; - /** Working-tree dirty-file counts. */ - workingStatus: { - added: number; - clean: boolean; - deleted: number; - modified: number; - total: number; - }; -} - /** * Parameters for execAgent - execute a single Agent * Either agentId or slug must be provided diff --git a/packages/types/src/device.ts b/packages/types/src/device.ts new file mode 100644 index 0000000000..a3a5fb5f71 --- /dev/null +++ b/packages/types/src/device.ts @@ -0,0 +1,305 @@ +/** + * A project-level skill discovered on the device filesystem + * (`.agents/skills` / `.claude/skills`) by the client at request time. + * Only frontmatter + the absolute SKILL.md path are carried; the SKILL.md + * body and directory tree are loaded on demand at activation time via the + * readFile / listFiles tools. + */ +export interface ProjectSkillMeta { + /** Skill description from SKILL.md frontmatter. */ + description?: string; + /** Skill name from frontmatter (falls back to the directory name). */ + name: string; + /** Absolute path to the skill's SKILL.md on the device filesystem. */ + path: string; +} + +/** + * A single project-root agent instructions file (`AGENTS.md` / `CLAUDE.md`) read + * from the device filesystem during workspace init. Unlike skills (metadata + * only), the full body is carried so it can be injected into the system role and + * rendered in web without a second device round-trip. Carried as a list on + * {@link WorkspaceInitResult} since multiple files can coexist (e.g. both + * `AGENTS.md` and `CLAUDE.md`, or future nested files). + */ +export interface WorkspaceInstructions { + /** Full file content (capped at read time, e.g. 64KB). */ + content: string; + /** Source file the instructions were read from. */ + source: 'AGENTS.md' | 'CLAUDE.md'; +} + +/** + * Result of scanning a bound project directory ("workspace init"): the agent + * instructions file plus the project-level skills discovered under + * `.agents/skills` + `.claude/skills`. Produced in a single device round-trip + * (`deviceGateway.initWorkspace`) and cached on `devices.workingDirs[].workspace` + * so subsequent runs within the TTL — and the web UI — reuse it without + * re-scanning. Intentionally open to growth (env info, git status, …) as more + * environment-preparation logic lands. + * + * The scanned root is not stored here — it is always the enclosing + * `WorkingDirEntry.path`. + */ +export interface WorkspaceInitResult { + /** + * Project-root agent instructions files (`AGENTS.md` / `CLAUDE.md`). Empty + * when none are present. + */ + instructions: WorkspaceInstructions[]; + /** Project-level skills discovered under the project root (metadata only). */ + skills: ProjectSkillMeta[]; +} + +/** + * A working directory a device has used. Structured (rather than a bare path + * string) so metadata such as the detected repo type survives — a remote client + * viewing this device can't re-probe its filesystem, so whatever isn't captured + * here at the source is lost. Mirrors the client-local `RecentDirEntry` shape. + */ +export interface WorkingDirEntry { + path: string; + repoType?: 'git' | 'github'; + /** + * Cached "workspace init" scan of this directory (AGENTS.md + project skills). + * Populated server-side at run start via `deviceGateway.initWorkspace` and + * reused within the TTL gated by `workspaceScannedAt`. Also read directly by + * the web UI to render the project's skills / instructions. + */ + workspace?: WorkspaceInitResult; + /** + * Epoch ms when `workspace` was last scanned. Hoisted to the top level (out of + * `workspace`) so freshness can be checked without deserializing the payload. + */ + workspaceScannedAt?: number; +} + +/** A single live gateway WebSocket connection belonging to a device. */ +export interface DeviceChannel { + channel: string | null; + connectedAt: string; + hostname: string | null; + platform: string | null; +} + +/** + * A device row as returned by the `device.listDevices` query — either a + * registered device or an online-only "ghost" (connected but not yet persisted). + * The server query is annotated to return `DeviceListItem[]`, so this type is the + * contract rather than something inferred from the router. + */ +export interface DeviceListItem { + channels: DeviceChannel[]; + defaultCwd: string | null; + deviceId: string; + friendlyName: string | null; + hostname: string | null; + identitySource: string | null; + lastSeen: string; + online: boolean; + platform: string | null; + registered: boolean; + workingDirs: WorkingDirEntry[]; +} + +/** + * Branch name + detached-HEAD flag for a working directory, returned by the + * `getGitBranch` device RPC. Mirrors the desktop `GitBranchInfo`. + */ +export interface DeviceGitBranchInfo { + /** Branch short name, or short SHA when in detached HEAD state. */ + branch?: string; + /** True when HEAD is detached (no branch ref). */ + detached?: boolean; +} + +/** A GitHub pull request linked to a branch. */ +export interface DeviceGitLinkedPullRequest { + number: number; + state: string; + title: string; + url: string; +} + +/** + * Result of the `getLinkedPullRequest` device RPC: the PR linked to a branch + * (when the repo is a GitHub remote). Mirrors the desktop shape. + */ +export interface DeviceGitLinkedPullRequestResult { + /** Additional open PRs targeting the same head branch, beyond the primary one. */ + extraCount?: number; + /** Null when no open PR is linked to the branch. */ + pullRequest: DeviceGitLinkedPullRequest | null; + /** 'ok' — lookup succeeded; 'gh-missing' — gh CLI unavailable; 'error' — other failure. */ + status: 'error' | 'gh-missing' | 'ok'; +} + +/** + * Working-tree dirty-file counts for a working directory, returned by the + * `getGitWorkingTreeStatus` device RPC. Mirrors the desktop shape. + */ +export interface DeviceGitWorkingTreeStatus { + added: number; + clean: boolean; + deleted: number; + modified: number; + total: number; +} + +/** + * Commit divergence vs the upstream tracking ref, returned by the + * `getGitAheadBehind` device RPC. Mirrors the desktop shape. + */ +export interface DeviceGitAheadBehind { + ahead: number; + behind: number; + hasUpstream: boolean; + pushTarget?: string; + pushTargetExists?: boolean; + upstream?: string; +} + +/** + * One local branch on a device's working directory, returned by the + * `listGitBranches` device RPC. Mirrors the desktop `GitBranchListItem` so the + * branch switcher consumes the IPC and RPC paths interchangeably. + */ +export interface DeviceGitBranchListItem { + current: boolean; + name: string; + upstream?: string; +} + +/** Result of the `checkoutGitBranch` device RPC. Mirrors the desktop shape. */ +export interface DeviceGitCheckoutResult { + error?: string; + success: boolean; +} + +/** + * Result of the `pullGitBranch` / `pushGitBranch` device RPCs. Mirrors the + * desktop `GitPullResult` / `GitPushResult` (identical shapes). + */ +export interface DeviceGitSyncResult { + error?: string; + /** True when git reported the branch was already up-to-date. */ + noop?: boolean; + success: boolean; +} + +/** + * One per-file diff block in a working-tree / branch diff. Mirrors the desktop + * `GitWorkingTreePatch` — shared by both the unstaged and branch-diff RPCs. + */ +export interface DeviceGitWorkingTreePatch { + /** Number of `+` lines in the patch. */ + additions: number; + /** Number of `-` lines in the patch. */ + deletions: number; + /** Repo-relative path of the file. */ + filePath: string; + /** True when git reported `Binary files … differ` for this entry. */ + isBinary: boolean; + /** Unified diff text. Empty when binary or truncated. */ + patch: string; + /** Diff bucket. */ + status: 'added' | 'modified' | 'deleted'; + /** Patch elided because it exceeded the per-file size cap. */ + truncated: boolean; +} + +/** + * Patches collected from a dirty submodule. Mirrors the desktop + * `SubmoduleWorkingTreePatches`; composes {@link DeviceGitWorkingTreePatch}. + */ +export interface DeviceSubmoduleWorkingTreePatches { + /** Absolute path on disk — used as the `cwd` for per-submodule ops. */ + absolutePath: string; + /** Current branch short name inside the submodule, or short SHA when detached. */ + branch?: string; + /** True when the submodule's HEAD is detached. */ + detached?: boolean; + /** Display name — the submodule's directory basename. */ + name: string; + /** Per-file diff blocks inside this submodule. */ + patches: DeviceGitWorkingTreePatch[]; + /** Path relative to the parent repo root. */ + relativePath: string; +} + +/** + * Result of the `getGitWorkingTreePatches` device RPC. Mirrors the desktop + * `GitWorkingTreePatches`. + */ +export interface DeviceGitWorkingTreePatches { + /** All dirty file patches in the parent repo. */ + patches: DeviceGitWorkingTreePatch[]; + /** One group per dirty submodule. Undefined when none. */ + submodules?: DeviceSubmoduleWorkingTreePatches[]; +} + +/** + * Result of the `getGitBranchDiff` device RPC. Mirrors the desktop + * `GitBranchDiffPatches`. + */ +export interface DeviceGitBranchDiffPatches { + /** Resolved base ref the diff was taken against. Undefined when unresolved. */ + baseRef?: string; + /** Current branch short name, or short SHA when detached. */ + headRef?: string; + /** Per-file diff blocks for the parent repo. */ + patches: DeviceGitWorkingTreePatch[]; + /** One group per submodule whose pointer differs. Undefined when none. */ + submodules?: DeviceSubmoduleWorkingTreePatches[]; +} + +/** + * One remote branch under `refs/remotes/origin/*`, returned by the + * `listGitRemoteBranches` device RPC. Mirrors the desktop `GitRemoteBranchListItem`. + */ +export interface DeviceGitRemoteBranchListItem { + /** Whether this ref is the resolved default branch (origin/HEAD target). */ + isDefault: boolean; + /** Short ref name, e.g. `origin/canary`. */ + name: string; +} + +/** Result of the `revertGitFile` device RPC. Mirrors the desktop `GitFileRevertResult`. */ +export interface DeviceGitFileRevertResult { + error?: string; + success: boolean; +} + +/** + * Repo-relative paths of dirty working-tree files for a directory on a remote + * device, returned by the `getGitWorkingTreeFiles` device RPC. Powers the Files + * tab's git-status overlay. Mirrors the desktop `GitWorkingTreeFiles`. + */ +export interface DeviceGitWorkingTreeFiles { + added: string[]; + deleted: string[]; + modified: string[]; +} + +/** One entry in a device's project file index. Mirrors `ProjectFileIndexEntry`. */ +export interface DeviceProjectFileIndexEntry { + isDirectory: boolean; + name: string; + /** Absolute path on the device filesystem. */ + path: string; + /** Path relative to the project root; directories end with `/`. */ + relativePath: string; +} + +/** + * Project file index (tree) for a directory on a remote device, returned by the + * `getProjectFileIndex` device RPC. Powers the Files tab's tree. Mirrors the + * desktop `ProjectFileIndexResult`. + */ +export interface DeviceProjectFileIndexResult { + entries: DeviceProjectFileIndexEntry[]; + indexedAt: string; + root: string; + source: 'git' | 'glob'; + totalCount: number; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 516ce12846..38bd0762d7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,6 +11,7 @@ export * from './chunk'; export * from './clientDB'; export * from './conversation'; export * from './creds'; +export * from './device'; export * from './discover'; export * from './document'; export * from './eval'; diff --git a/packages/types/src/user/preference.ts b/packages/types/src/user/preference.ts index 46130f72ac..fccfdb84f7 100644 --- a/packages/types/src/user/preference.ts +++ b/packages/types/src/user/preference.ts @@ -46,11 +46,6 @@ export const UserLabSchema = z.object({ * enable the floating chat panel in agent document preview */ enableAgentDocumentFloatingChatPanel: z.boolean().optional(), - /** - * surface the execution-device switcher for heterogeneous agents - * (lets users pick local / cloud sandbox / a bound device) - */ - enableExecutionDeviceSwitcher: z.boolean().optional(), /** * enable server-side agent execution via Gateway WebSocket */ diff --git a/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts b/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts index 6dfbfe68cb..11f3277321 100644 --- a/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts +++ b/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts @@ -9,12 +9,10 @@ import { ArchiveIcon, MessageSquarePlusIcon } from 'lucide-react'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useEffectiveWorkingDirectory } from '@/hooks/useEffectiveWorkingDirectory'; import { useClientDataSWR } from '@/libs/swr'; import { localFileService } from '@/services/electron/localFileService'; -import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; import { useToolStore } from '@/store/tool'; import { agentDocumentSkillsSelectors } from '@/store/tool/selectors'; import type { AgentDocumentSkillItem } from '@/store/tool/slices/agentDocumentSkills/initialState'; @@ -50,11 +48,10 @@ export const useSlashActionItems = (): SlashOptions['items'] => { // cwd. Both homogeneous and heterogeneous runtimes accept project skills now // (see commit dd4a4e7595), so we no longer gate on the hetero provider. const agentId = useAgentId(); - const agentWorkingDirectory = useAgentStore((s) => - agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined, - ); - const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); - const workingDirectory = topicWorkingDirectory || agentWorkingDirectory; + // Unified cwd: topic > agent's per-device choice > device default > home. + // This is what makes project skills load even when only a device default is + // set (and for local-device runs), not just an explicit agent/topic pick. + const workingDirectory = useEffectiveWorkingDirectory(agentId); const projectSkillsEnabled = isDesktop && !!workingDirectory; const { data: projectSkillsData } = useClientDataSWR( diff --git a/src/features/ChatInput/RuntimeConfig/AddWorkingDirModal.tsx b/src/features/ChatInput/RuntimeConfig/AddWorkingDirModal.tsx new file mode 100644 index 0000000000..740c2e97f0 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/AddWorkingDirModal.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { Button, Flexbox, Input, Text } from '@lobehub/ui'; +import { createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui'; +import { type InputRef } from 'antd'; +import { cssVar } from 'antd-style'; +import { t } from 'i18next'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface AddWorkingDirContentProps { + /** + * Submit the entered path. Return an error message to show inline and keep the + * modal open; return undefined on success (the modal closes). Lets the caller + * validate (e.g. statPath) and enrich (repoType) in one round-trip. + */ + onSubmit: (path: string) => Promise; + placeholder?: string; +} + +const AddWorkingDirContent = memo(({ onSubmit, placeholder }) => { + const { t: tPlugin } = useTranslation('device'); + const { t: tCommon } = useTranslation('common'); + const { close } = useModalContext(); + const [value, setValue] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const inputRef = useRef(null); + + useEffect(() => { + queueMicrotask(() => inputRef.current?.focus()); + }, []); + + const handleSubmit = useCallback(async () => { + if (loading) return; + const next = value.trim(); + if (!next) { + close(); + return; + } + setLoading(true); + try { + const message = await onSubmit(next); + if (message) { + setError(message); + return; + } + close(); + } finally { + setLoading(false); + } + }, [close, loading, onSubmit, value]); + + return ( + + + {tPlugin('workingDirectory.addFolderDesc')} + + + { + setValue(e.target.value); + setError(undefined); + }} + /> + {error ? {error} : null} + + + + + + + ); +}); + +AddWorkingDirContent.displayName = 'AddWorkingDirContent'; + +/** + * Manual absolute-path entry for the working directory. Used when the target + * filesystem isn't browsable from here (web, or a remote device) — the browser + * has no way to resolve an absolute path from its sandboxed folder picker. + */ +export const openAddWorkingDirModal = (options: { + onSubmit: (path: string) => Promise; + placeholder?: string; +}): ModalInstance => + createModal({ + content: , + footer: null, + maskClosable: true, + styles: { header: { borderBottom: 'none' } }, + title: t('workingDirectory.addFolderTitle', { ns: 'device' }), + width: 'min(90vw, 480px)', + }); diff --git a/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx b/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx index d601d36b25..5890f70b1b 100644 --- a/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx +++ b/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx @@ -1,5 +1,4 @@ -import type { GitBranchListItem } from '@lobechat/electron-client-ipc'; -import { Button, Icon, Input } from '@lobehub/ui'; +import { Icon, Input } from '@lobehub/ui'; import { DropdownMenuItem, DropdownMenuPopup, @@ -17,14 +16,15 @@ import { RefreshCwIcon, SearchIcon, } from 'lucide-react'; -import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; import { message } from '@/components/AntdStaticMethods'; -import { electronGitService } from '@/services/electron/git'; +import { gitService } from '@/services/git'; +import { useFetchGitWorkingTreeStatus } from '@/store/device'; -import { useWorkingTreeStatus } from './useWorkingTreeStatus'; +import { openCreateBranchModal } from './CreateBranchModal'; const styles = createStaticStyles(({ css }) => ({ branchLabel: css` @@ -42,15 +42,6 @@ const styles = createStaticStyles(({ css }) => ({ /* Cancel DropdownMenuPopup's default 4px padding so our sections align edge-to-edge */ margin: -4px; `, - createFooter: css` - display: flex; - gap: 6px; - align-items: center; - - padding-block: 6px; - padding-inline: 8px; - border-block-start: 1px solid ${cssVar.colorSplit}; - `, createItemWrapper: css` padding: 4px; border-block-start: 1px solid ${cssVar.colorSplit}; @@ -58,9 +49,6 @@ const styles = createStaticStyles(({ css }) => ({ createItem: css` border-radius: calc(${cssVar.borderRadius} - 4px); `, - createInput: css` - flex: 1; - `, emptyState: css` padding-block: 12px; font-size: 12px; @@ -139,11 +127,9 @@ const styles = createStaticStyles(({ css }) => ({ `, section: css` flex: 1; - font-size: 11px; font-weight: 500; color: ${cssVar.colorTextTertiary}; - text-transform: uppercase; `, sectionRow: css` display: flex; @@ -167,21 +153,35 @@ const styles = createStaticStyles(({ css }) => ({ interface BranchSwitcherProps { children: ReactElement; currentBranch?: string; + /** + * When set, branch list + checkout go through the `device.*` RPCs (web / remote + * device). Omit for the local machine, which talks to Electron over IPC. + */ + deviceId?: string; onAfterCheckout?: () => void; onExternalRefresh?: () => void | Promise; onOpenChange: (open: boolean) => void; + /** Reflect a branch switch in the UI immediately, before the checkout lands. */ + onOptimisticCheckout?: (branch: string) => void; open: boolean; path: string; } const BranchSwitcher = memo( - ({ path, currentBranch, open, onOpenChange, onAfterCheckout, onExternalRefresh, children }) => { - const { t } = useTranslation('plugin'); + ({ + path, + currentBranch, + deviceId, + open, + onOpenChange, + onAfterCheckout, + onExternalRefresh, + onOptimisticCheckout, + children, + }) => { + const { t } = useTranslation('device'); const [search, setSearch] = useState(''); - const [isCreating, setIsCreating] = useState(false); - const [newBranch, setNewBranch] = useState(''); const [busyBranch, setBusyBranch] = useState(null); - const createInputRef = useRef(null); const { data: branches = [], @@ -189,11 +189,14 @@ const BranchSwitcher = memo( error: branchesError, mutate: mutateBranches, } = useSWR( - open ? ['git-branches', path] : null, - () => electronGitService.listGitBranches(path), + open ? ['git-branches', deviceId ?? 'local', path] : null, + () => gitService.listGitBranches({ deviceId, path }), { revalidateOnFocus: false, shouldRetryOnError: false }, ); - const { data: workingStatus, mutate: mutateWorkingStatus } = useWorkingTreeStatus(path); + const { data: workingStatus, mutate: mutateWorkingStatus } = useFetchGitWorkingTreeStatus( + deviceId, + path, + ); const [isRefreshing, setIsRefreshing] = useState(false); const handleRefresh = useCallback(async () => { @@ -211,19 +214,9 @@ const BranchSwitcher = memo( }, [isRefreshing, mutateBranches, mutateWorkingStatus, onExternalRefresh]); useEffect(() => { - if (!open) { - setSearch(''); - setIsCreating(false); - setNewBranch(''); - } + if (!open) setSearch(''); }, [open]); - useEffect(() => { - if (isCreating) { - createInputRef.current?.focus(); - } - }, [isCreating]); - const filtered = useMemo(() => { const query = search.trim().toLowerCase(); if (!query) return branches; @@ -238,30 +231,59 @@ const BranchSwitcher = memo( return; } setBusyBranch(branch); + // Reflect the switch instantly and close; the checkout + revalidate + // reconcile in the background (a failure rolls the label back). + onOptimisticCheckout?.(branch); + onOpenChange(false); try { - const result = await electronGitService.checkoutGitBranch({ - branch, - create, - path, - }); - if (result.success) { - onAfterCheckout?.(); - onOpenChange(false); - } else { - message.error(result.error || t('localSystem.workingDirectory.checkoutFailed')); + const result = await gitService.checkoutGitBranch({ branch, create, deviceId, path }); + if (!result.success) { + message.error(result.error || t('workingDirectory.checkoutFailed')); } } finally { + onAfterCheckout?.(); setBusyBranch(null); } }, - [busyBranch, currentBranch, onAfterCheckout, onOpenChange, path, t], + [ + busyBranch, + currentBranch, + deviceId, + onAfterCheckout, + onOptimisticCheckout, + onOpenChange, + path, + t, + ], ); - const handleCreateSubmit = useCallback(() => { - const name = newBranch.trim(); - if (!name) return; - void handleCheckout(name, true); - }, [handleCheckout, newBranch]); + // Create + checkout a new branch from the modal. Returns an error message + // for inline display (keeps the modal open), or undefined on success. + const handleCreateBranch = useCallback( + async (name: string): Promise => { + onOptimisticCheckout?.(name); + const result = await gitService.checkoutGitBranch({ + branch: name, + create: true, + deviceId, + path, + }); + // Reconcile either way: success fills in PR / ahead-behind, failure rolls + // the optimistic label back to the real branch. + onAfterCheckout?.(); + if (result.success) { + onOpenChange(false); + return undefined; + } + return result.error || t('workingDirectory.checkoutFailed'); + }, + [deviceId, onAfterCheckout, onOptimisticCheckout, onOpenChange, path, t], + ); + + const openCreateBranch = useCallback(() => { + onOpenChange(false); + openCreateBranchModal({ onSubmit: handleCreateBranch }); + }, [handleCreateBranch, onOpenChange]); return ( @@ -273,7 +295,7 @@ const BranchSwitcher = memo(
} size="small" value={search} @@ -285,9 +307,7 @@ const BranchSwitcher = memo(
-
- {t('localSystem.workingDirectory.branchesHeading')} -
+
{t('workingDirectory.branchesHeading')}
(
{isLoading && branches.length === 0 && ( -
- {t('localSystem.workingDirectory.branchesLoading')} -
+
{t('workingDirectory.branchesLoading')}
)} {!isLoading && branchesError && (
- {(branchesError as Error)?.message || - t('localSystem.workingDirectory.branchesEmpty')} + {(branchesError as Error)?.message || t('workingDirectory.branchesEmpty')}
)} {!isLoading && !branchesError && filtered.length === 0 && (
{search.trim() - ? t('localSystem.workingDirectory.branchesNoMatch') - : t('localSystem.workingDirectory.branchesEmpty')} + ? t('workingDirectory.branchesNoMatch') + : t('workingDirectory.branchesEmpty')}
)} - {filtered.map((branch: GitBranchListItem) => { + {filtered.map((branch) => { const isCurrent = branch.name === currentBranch; const isBusy = busyBranch === branch.name; return ( @@ -337,7 +354,7 @@ const BranchSwitcher = memo(
{branch.name}
{isCurrent && workingStatus && !workingStatus.clean && (
- {t('localSystem.workingDirectory.uncommittedChanges', { + {t('workingDirectory.uncommittedChanges', { count: workingStatus.total, })}
@@ -351,53 +368,17 @@ const BranchSwitcher = memo( })}
- {isCreating ? ( -
- setNewBranch(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - onPressEnter={handleCreateSubmit} - /> - - -
- ) : ( -
- setIsCreating(true)} - > - -
- {t('localSystem.workingDirectory.createBranchAction')} -
-
-
- )} +
+ + +
+ {t('workingDirectory.createBranchAction')} +
+
+
diff --git a/src/features/ChatInput/RuntimeConfig/CreateBranchModal.tsx b/src/features/ChatInput/RuntimeConfig/CreateBranchModal.tsx new file mode 100644 index 0000000000..849ee195ac --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/CreateBranchModal.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { Button, Flexbox, Input, Text } from '@lobehub/ui'; +import { createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui'; +import { type InputRef } from 'antd'; +import { cssVar } from 'antd-style'; +import { t } from 'i18next'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface CreateBranchContentProps { + /** + * Create + checkout the branch. Return an error message to show inline and + * keep the modal open; return undefined on success (the modal closes). + */ + onSubmit: (name: string) => Promise; +} + +const CreateBranchContent = memo(({ onSubmit }) => { + const { t: tDevice } = useTranslation('device'); + const { t: tCommon } = useTranslation('common'); + const { close } = useModalContext(); + const [value, setValue] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const inputRef = useRef(null); + + useEffect(() => { + queueMicrotask(() => inputRef.current?.focus()); + }, []); + + const handleSubmit = useCallback(async () => { + if (loading) return; + const name = value.trim(); + if (!name) return; + setLoading(true); + try { + const message = await onSubmit(name); + if (message) { + setError(message); + return; + } + close(); + } finally { + setLoading(false); + } + }, [close, loading, onSubmit, value]); + + return ( + + + { + setValue(e.target.value); + setError(undefined); + }} + /> + {error ? {error} : null} + + + + + + + ); +}); + +CreateBranchContent.displayName = 'CreateBranchContent'; + +/** + * Branch-name entry for "checkout new branch". Replaces the inline dropdown + * footer with a focused modal; submitting creates and checks out the branch. + */ +export const openCreateBranchModal = (options: { + onSubmit: (name: string) => Promise; +}): ModalInstance => + createModal({ + content: , + footer: null, + maskClosable: true, + styles: { header: { borderBottom: 'none' } }, + title: t('workingDirectory.createBranchTitle', { ns: 'device' }), + width: 'min(90vw, 480px)', + }); diff --git a/src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx b/src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx deleted file mode 100644 index 5bb2d92b9e..0000000000 --- a/src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx +++ /dev/null @@ -1,256 +0,0 @@ -'use client'; - -import { Flexbox, Icon, Input, Popover, Tooltip } from '@lobehub/ui'; -import { confirmModal } from '@lobehub/ui/base-ui'; -import { createStaticStyles, cssVar, cx } from 'antd-style'; -import { CheckIcon, ChevronDownIcon, FolderIcon } from 'lucide-react'; -import { memo, useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { lambdaQuery } from '@/libs/trpc/client'; -import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; - -import type { WorkingDirEntry } from './deviceCwd'; -import { renderDirIcon } from './dirIcon'; -import { useUpdateDeviceCwd } from './useUpdateDeviceCwd'; - -const styles = createStaticStyles(({ css }) => ({ - button: css` - cursor: pointer; - - display: flex; - gap: 6px; - align-items: center; - - padding-block: 2px; - padding-inline: 4px; - border-radius: 4px; - - font-size: 12px; - color: ${cssVar.colorTextSecondary}; - - transition: background 0.2s; - - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - dirItem: css` - cursor: pointer; - - padding-block: 6px; - padding-inline: 8px; - border-radius: ${cssVar.borderRadius}; - - transition: background-color 0.2s; - - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - dirItemActive: css` - background: ${cssVar.colorFillTertiary}; - `, - dirName: css` - font-size: 13px; - font-weight: 500; - color: ${cssVar.colorText}; - `, - dirPath: css` - overflow: hidden; - - font-size: 11px; - color: ${cssVar.colorTextDescription}; - text-overflow: ellipsis; - white-space: nowrap; - `, - scrollContainer: css` - overflow-y: auto; - max-height: 320px; - `, - sectionTitle: css` - padding-block: 6px 2px; - padding-inline: 8px; - - font-size: 11px; - font-weight: 500; - color: ${cssVar.colorTextQuaternary}; - text-transform: uppercase; - letter-spacing: 0.5px; - `, -})); - -const getDirName = (path: string) => path.split('/').findLast(Boolean) || path; - -interface DeviceWorkingDirectoryProps { - agentId: string; -} - -/** - * Working-directory picker for runs dispatched to a remote device - * (`executionTarget='device'`). Unlike the desktop picker, the device's - * filesystem isn't browsable from here, so the cwd comes from the device's - * `workingDirs` (persisted via the registry) plus a manual path field. A pick is - * pinned to the active topic (override) and persisted back to the device - * (`defaultCwd` + `workingDirs`) so it seeds future topics and the recent list. - */ -const DeviceWorkingDirectory = memo(({ agentId }) => { - const { t } = useTranslation(['plugin', 'chat']); - const [open, setOpen] = useState(false); - const [input, setInput] = useState(''); - - const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId)); - const boundDeviceId = agencyConfig?.boundDeviceId; - - const { data: devices } = lambdaQuery.device.listDevices.useQuery(undefined, { - staleTime: 30_000, - }); - const device = useMemo( - () => devices?.find((d) => d.deviceId === boundDeviceId), - [devices, boundDeviceId], - ); - const workingDirs = device?.workingDirs ?? []; - - const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); - // Mirror the server's resolution (topic override > device.defaultCwd). - const effectiveDir = topicWorkingDirectory || device?.defaultCwd || ''; - - const activeTopicId = useChatStore((s) => s.activeTopicId); - const activeTopic = useChatStore((s) => - s.activeTopicId ? topicSelectors.getTopicById(s.activeTopicId)(s) : undefined, - ); - const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata); - const updateDeviceCwd = useUpdateDeviceCwd(); - - const commitDir = useCallback( - async (entry: WorkingDirEntry) => { - const newPath = entry.path.trim(); - if (!newPath || !boundDeviceId) return; - - const commit = async () => { - // Pin this topic to the chosen cwd (override wins server-side), and - // persist to the device so defaultCwd + workingDirs stay in sync. - if (activeTopicId) await updateTopicMetadata(activeTopicId, { workingDirectory: newPath }); - await updateDeviceCwd(boundDeviceId, { ...entry, path: newPath }, workingDirs); - setInput(''); - setOpen(false); - }; - - // Changing a topic's cwd invalidates its pinned CC session (sessions are - // keyed per-cwd), so warn before the implicit reset — same as the local picker. - const priorSessionId = activeTopic?.metadata?.heteroSessionId; - const priorCwd = activeTopic?.metadata?.workingDirectory; - if (priorSessionId && priorCwd && priorCwd !== newPath) { - confirmModal({ - cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), - content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), - okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), - onOk: commit, - title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), - }); - return; - } - - await commit(); - }, - [ - activeTopicId, - activeTopic, - boundDeviceId, - workingDirs, - t, - updateDeviceCwd, - updateTopicMetadata, - ], - ); - - const content = ( - -
{t('localSystem.workingDirectory.recent')}
-
- {workingDirs.length === 0 ? ( - - {t('localSystem.workingDirectory.noRecent')} - - ) : ( - workingDirs.map((entry) => { - const isActive = entry.path === effectiveDir; - return ( - void commitDir(entry)} - > - {renderDirIcon(entry.repoType)} - -
{getDirName(entry.path)}
-
{entry.path}
-
- {isActive ? ( - - ) : null} -
- ); - }) - )} -
- setInput(e.target.value)} - onPressEnter={() => void commitDir({ path: input })} - /> -
- ); - - const displayName = effectiveDir - ? getDirName(effectiveDir) - : t('localSystem.workingDirectory.notSet'); - - const trigger = ( -
- - {displayName} - -
- ); - - return ( - -
- {open ? ( - trigger - ) : ( - - {trigger} - - )} -
-
- ); -}); - -DeviceWorkingDirectory.displayName = 'DeviceWorkingDirectory'; - -export default DeviceWorkingDirectory; diff --git a/src/features/ChatInput/RuntimeConfig/GitStatus.tsx b/src/features/ChatInput/RuntimeConfig/GitStatus.tsx index c5bc56b7ea..286354541e 100644 --- a/src/features/ChatInput/RuntimeConfig/GitStatus.tsx +++ b/src/features/ChatInput/RuntimeConfig/GitStatus.tsx @@ -6,15 +6,17 @@ import { useTranslation } from 'react-i18next'; import { message } from '@/components/AntdStaticMethods'; import RingLoadingIcon from '@/components/RingLoading'; -import { electronGitService } from '@/services/electron/git'; import { electronSystemService } from '@/services/electron/system'; +import { gitService } from '@/services/git'; +import { + useFetchGitAheadBehind, + useFetchGitInfo, + useFetchGitWorkingTreeStatus, +} from '@/store/device'; import { useGlobalStore } from '@/store/global'; import { systemStatusSelectors } from '@/store/global/selectors'; import BranchSwitcher from './BranchSwitcher'; -import { useGitAheadBehind } from './useGitAheadBehind'; -import { useGitInfo } from './useGitInfo'; -import { useWorkingTreeStatus } from './useWorkingTreeStatus'; const styles = createStaticStyles(({ css }) => { return { @@ -136,15 +138,24 @@ const styles = createStaticStyles(({ css }) => { }); interface GitStatusProps { + /** When set, git status / branch switch / pull / push all run against this + * remote device via RPC. Omit for the local machine (talks over IPC). */ + deviceId?: string; isGithub: boolean; path: string; } -const GitStatus = memo(({ path, isGithub }) => { - const { t } = useTranslation('plugin'); - const { data, mutate } = useGitInfo(path, isGithub); - const { data: workingStatus, mutate: mutateWorkingStatus } = useWorkingTreeStatus(path); - const { data: aheadBehind, mutate: mutateAheadBehind } = useGitAheadBehind(path); +const GitStatus = memo(({ path, isGithub, deviceId }) => { + const { t } = useTranslation('device'); + const local = !deviceId; + // Transport (Electron IPC vs device RPC) is decided inside the service; the + // component just reads, identically for local and remote. + const { data, mutate } = useFetchGitInfo(deviceId, path, isGithub); + const { data: workingStatus, mutate: mutateWorkingStatus } = useFetchGitWorkingTreeStatus( + deviceId, + path, + ); + const { data: aheadBehind, mutate: mutateAheadBehind } = useFetchGitAheadBehind(deviceId, path); const [switcherOpen, setSwitcherOpen] = useState(false); const [pulling, setPulling] = useState(false); const [pushing, setPushing] = useState(false); @@ -172,69 +183,89 @@ const GitStatus = memo(({ path, isGithub }) => { await Promise.all([mutate(), mutateWorkingStatus(), mutateAheadBehind()]); }, [mutate, mutateWorkingStatus, mutateAheadBehind]); + // Flip the displayed branch instantly on checkout; clear the old branch's PR + // (the new branch's is unknown until revalidate). No revalidate here — the + // switcher's onAfterCheckout reconciles once the checkout lands. + const handleOptimisticCheckout = useCallback( + (branch: string) => { + void mutate( + (prev) => ({ + ...prev, + branch, + detached: false, + extraCount: undefined, + ghMissing: undefined, + pullRequest: null, + }), + { revalidate: false }, + ); + }, + [mutate], + ); + const syncBusy = pulling || pushing; const handlePull = useCallback(async () => { if (pulling || pushing) return; setPulling(true); try { - const result = await electronGitService.pullGitBranch({ path }); + const result = await gitService.pullGitBranch({ deviceId, path }); if (result.success) { if (result.noop) { - message.info(t('localSystem.workingDirectory.pullNoop')); + message.info(t('workingDirectory.pullNoop')); } else { - message.success(t('localSystem.workingDirectory.pullSuccess')); + message.success(t('workingDirectory.pullSuccess')); } await refreshAfterSync(); } else { - message.error(result.error || t('localSystem.workingDirectory.pullFailed')); + message.error(result.error || t('workingDirectory.pullFailed')); } } finally { setPulling(false); } - }, [path, pulling, pushing, refreshAfterSync, t]); + }, [deviceId, path, pulling, pushing, refreshAfterSync, t]); const handlePush = useCallback(async () => { if (pulling || pushing) return; setPushing(true); try { - const result = await electronGitService.pushGitBranch({ path }); + const result = await gitService.pushGitBranch({ deviceId, path }); if (result.success) { if (result.noop) { - message.info(t('localSystem.workingDirectory.pushNoop')); + message.info(t('workingDirectory.pushNoop')); } else { - message.success(t('localSystem.workingDirectory.pushSuccess')); + message.success(t('workingDirectory.pushSuccess')); } await refreshAfterSync(); } else { - message.error(result.error || t('localSystem.workingDirectory.pushFailed')); + message.error(result.error || t('workingDirectory.pushFailed')); } } finally { setPushing(false); } - }, [path, pulling, pushing, refreshAfterSync, t]); + }, [deviceId, path, pulling, pushing, refreshAfterSync, t]); if (!data?.branch) return null; const branchTooltip = data.detached - ? t('localSystem.workingDirectory.detachedHead', { sha: data.branch }) + ? t('workingDirectory.detachedHead', { sha: data.branch }) : data.branch; const prTooltip = data.pullRequest ? data.extraCount - ? t('localSystem.workingDirectory.prTooltipWithExtra', { + ? t('workingDirectory.prTooltipWithExtra', { count: data.extraCount, title: data.pullRequest.title, }) : data.pullRequest.title : data.ghMissing - ? t('localSystem.workingDirectory.ghMissing') + ? t('workingDirectory.ghMissing') : undefined; const hasChanges = !!workingStatus && !workingStatus.clean; const diffStatTooltip = hasChanges - ? t('localSystem.workingDirectory.diffStatTooltip', { + ? t('workingDirectory.diffStatTooltip', { added: workingStatus!.added, deleted: workingStatus!.deleted, modified: workingStatus!.modified, @@ -255,14 +286,18 @@ const GitStatus = memo(({ path, isGithub }) => { ); const branchNode = data.detached ? ( + // Detached HEAD → plain branch label (nothing to switch to). {branchTrigger} ) : ( + // Local switches over IPC; a remote device switches over RPC (deviceId set). { void mutate(); void mutateWorkingStatus(); @@ -274,23 +309,18 @@ const GitStatus = memo(({ path, isGithub }) => { ); const pullTooltip = pulling - ? t('localSystem.workingDirectory.pullInProgress') - : t('localSystem.workingDirectory.pullAction', { + ? t('workingDirectory.pullInProgress') + : t('workingDirectory.pullAction', { count: aheadBehind?.behind ?? 0, upstream: upstreamName, }); const pushTooltip = pushing - ? t('localSystem.workingDirectory.pushInProgress') - : t( - pushTargetExists - ? 'localSystem.workingDirectory.pushAction' - : 'localSystem.workingDirectory.pushActionNew', - { - count: aheadBehind?.ahead ?? 0, - target: pushTargetName || upstreamName, - }, - ); + ? t('workingDirectory.pushInProgress') + : t(pushTargetExists ? 'workingDirectory.pushAction' : 'workingDirectory.pushActionNew', { + count: aheadBehind?.ahead ?? 0, + target: pushTargetName || upstreamName, + }); const pullNode = showBehind && ( @@ -329,7 +359,11 @@ const GitStatus = memo(({ path, isGithub }) => { const diffNode = (() => { if (!hasChanges || !workingStatus) return null; const diffButton = ( -
+
{workingStatus.added > 0 && ( +{workingStatus.added} diff --git a/src/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher.tsx b/src/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher.tsx index 2bee12960a..c2d7f56e42 100644 --- a/src/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher.tsx +++ b/src/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher.tsx @@ -3,18 +3,20 @@ import { SiApple, SiLinux } from '@icons-pack/react-simple-icons'; import { isDesktop } from '@lobechat/const'; import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents'; -import type { HeteroExecutionTarget, RuntimeEnvMode } from '@lobechat/types'; +import type { DeviceExecutionTarget } from '@lobechat/types'; import { Microsoft } from '@lobehub/icons'; import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui'; import { createStaticStyles, cssVar, cx } from 'antd-style'; import { + BoxIcon, CheckIcon, ChevronDownIcon, - CloudIcon, ExternalLinkIcon, InfoIcon, LaptopIcon, + MonitorDownIcon, MonitorIcon, + MonitorOffIcon, } from 'lucide-react'; import { memo, type ReactNode, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -22,6 +24,7 @@ import { useTranslation } from 'react-i18next'; import { lambdaQuery } from '@/libs/trpc/client'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; +import { useElectronStore } from '@/store/electron'; const styles = createStaticStyles(({ css }) => ({ button: css` @@ -83,6 +86,30 @@ const styles = createStaticStyles(({ css }) => ({ font-size: 12px; color: ${cssVar.colorTextQuaternary}; `, + downloadCard: css` + cursor: pointer; + + display: flex; + gap: 10px; + align-items: center; + + padding-block: 8px; + padding-inline: 8px; + border-radius: ${cssVar.borderRadius}; + + text-decoration: none; + + transition: background-color 0.2s; + + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + downloadCardArrow: css` + flex: none; + margin-inline-start: auto; + color: ${cssVar.colorTextQuaternary}; + `, option: css` cursor: pointer; @@ -143,6 +170,19 @@ const styles = createStaticStyles(({ css }) => ({ text-overflow: ellipsis; white-space: nowrap; `, + tag: css` + flex: none; + + padding-block: 0; + padding-inline: 5px; + border-radius: 4px; + + font-size: 10px; + line-height: 16px; + color: ${cssVar.colorTextSecondary}; + + background: ${cssVar.colorFillSecondary}; + `, header: css` display: flex; gap: 6px; @@ -190,9 +230,10 @@ interface OptionRowProps { icon: ReactNode; label: string; onClick: () => void; + tag?: ReactNode; } -const OptionRow = memo(({ active, desc, disabled, icon, label, onClick }) => { +const OptionRow = memo(({ active, desc, disabled, icon, label, onClick, tag }) => { return (
(({ active, desc, disabled, icon, label, o >
{icon}
-
{label}
+ + {label} + {tag ? {tag} : null} + {desc ?
{desc}
: null}
{active ? : null} @@ -248,32 +292,33 @@ const HeteroDeviceSwitcher = memo(({ agentId }) => { const storedTarget = agencyConfig?.executionTarget; const boundDeviceId = agencyConfig?.boundDeviceId; - // Effective target: falls back to local on desktop, sandbox on web - const executionTarget: HeteroExecutionTarget = storedTarget ?? (isDesktop ? 'local' : 'sandbox'); - const { data: devices, isLoading } = lambdaQuery.device.listDevices.useQuery(undefined, { staleTime: 30_000, }); + // The current machine's own gateway deviceId (desktop only), used only to + // badge the matching device row. The dedicated local "This device" option + // remains visible in desktop mode. + useElectronStore((s) => s.useFetchGatewayDeviceInfo)(); + const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo); + const currentDeviceId = isDesktop ? gatewayDeviceInfo?.deviceId : undefined; + + // Effective target: falls back to local on desktop, no device on web. + const executionTarget: DeviceExecutionTarget = storedTarget ?? (isDesktop ? 'local' : 'none'); + const handleSelect = useCallback( - async (target: HeteroExecutionTarget, deviceId?: string) => { + async (target: DeviceExecutionTarget, deviceId?: string) => { setOpen(false); - // Keep runtimeMode in sync so the server-side tool gate (runtimeMode === 'cloud' - // enables CloudSandbox) reflects the user's chosen execution target. - // Use a single updateAgentConfigById to persist both fields atomically — parallel - // calls share the same abort signal name and the second would cancel the first. - const platform = isDesktop ? 'desktop' : 'web'; - const runtimeMode: RuntimeEnvMode = - target === 'sandbox' ? 'cloud' : target === 'local' ? 'local' : 'none'; - + // `executionTarget` is the single source of truth now — the server tool + // gate + client `getRuntimeModeById` derive `runtimeMode` from it, so we no + // longer write the legacy per-platform `runtimeMode` record. await updateAgentConfigById(agentId, { agencyConfig: { ...agencyConfig, executionTarget: target, ...(target === 'device' && deviceId ? { boundDeviceId: deviceId } : {}), }, - chatConfig: { runtimeEnv: { runtimeMode: { [platform]: runtimeMode } } }, }); }, [agentId, agencyConfig, updateAgentConfigById], @@ -285,11 +330,17 @@ const HeteroDeviceSwitcher = memo(({ agentId }) => { const boundDevice = executionTarget === 'device' ? devices?.find((d) => d.deviceId === boundDeviceId) : undefined; const hasNoDevices = !devices || devices.length === 0; + // On web with no device, the prominent download card below replaces the small + // header link — avoid showing the same CTA twice. + const showWebDownloadCard = !isDesktop && hasNoDevices && !isLoading; // Compute chip - let chipIcon: ReactNode = ; + let chipIcon: ReactNode = ; let chipLabel = t('heteroAgent.executionTarget.sandbox'); - if (executionTarget === 'local') { + if (executionTarget === 'none') { + chipIcon = ; + chipLabel = t('heteroAgent.executionTarget.none'); + } else if (executionTarget === 'local') { chipIcon = ; chipLabel = t('heteroAgent.executionTarget.local'); } else if (executionTarget === 'device') { @@ -300,16 +351,45 @@ const HeteroDeviceSwitcher = memo(({ agentId }) => { t('heteroAgent.executionTarget.unknownDevice'); } - const isActive = (target: HeteroExecutionTarget, deviceId?: string) => { + const isActive = (target: DeviceExecutionTarget, deviceId?: string) => { if (target === 'device') return executionTarget === 'device' && boundDeviceId === deviceId; return executionTarget === target; }; + const renderDeviceRow = (d: NonNullable[number]) => ( + + + + {d.online + ? t('heteroAgent.executionTarget.online') + : t('heteroAgent.executionTarget.offline')} + + + } + onClick={() => void handleSelect('device', d.deviceId)} + /> + ); + const content = ( - +
- {t('heteroAgent.executionTarget.title')} - + + {t('heteroAgent.executionTarget.title')} + + + + + + + {isDesktop || showWebDownloadCard ? null : ( (({ agentId }) => { {t('heteroAgent.executionTarget.downloadDesktop')} - - - - - - + )}
+ } + label={t('heteroAgent.executionTarget.none')} + onClick={() => void handleSelect('none')} + /> {isDesktop ? ( (({ agentId }) => { } + icon={} label={t('heteroAgent.executionTarget.sandbox')} onClick={() => void handleSelect('sandbox')} /> - {(devices ?? []).map((d) => ( - - - - {d.online - ? t('heteroAgent.executionTarget.online') - : t('heteroAgent.executionTarget.offline')} - - - } - onClick={() => void handleSelect('device', d.deviceId)} - /> - ))} + {(devices ?? []).map((d) => renderDeviceRow(d))} {hasNoDevices && isLoading ? (
{t('heteroAgent.executionTarget.loading')}
) : null} - {hasNoDevices && !isLoading ? ( + {/* On web with no remote device, guide the user to the desktop app (which + unlocks local execution + `lh connect`) rather than a muted dead-end. */} + {showWebDownloadCard ? ( + +
+ +
+
+
+ {t('heteroAgent.executionTarget.downloadDesktopTitle')} +
+
+ {t('heteroAgent.executionTarget.downloadDesktopDesc')} +
+
+ +
+ ) : null} + {hasNoDevices && !isLoading && isDesktop ? (
{t('heteroAgent.executionTarget.noDevices')}
) : null}
diff --git a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx deleted file mode 100644 index 0c4d3c9a12..0000000000 --- a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import { isDesktop } from '@lobechat/const'; -import { Flexbox, Icon } from '@lobehub/ui'; -import { confirmModal } from '@lobehub/ui/base-ui'; -import { createStaticStyles, cssVar } from 'antd-style'; -import { CheckIcon, FolderOpenIcon, XIcon } from 'lucide-react'; -import { memo, useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { lambdaQuery } from '@/libs/trpc/client'; -import { electronSystemService } from '@/services/electron/system'; -import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; -import { useElectronStore } from '@/store/electron'; - -import { renderDirIcon } from './dirIcon'; -import { addRecentDir, getRecentDirs, type RecentDirEntry, removeRecentDir } from './recentDirs'; -import { useRepoType } from './useRepoType'; -import { useUpdateDeviceCwd } from './useUpdateDeviceCwd'; - -const styles = createStaticStyles(({ css }) => ({ - chooseFolderItem: css` - cursor: pointer; - - padding-block: 8px; - padding-inline: 8px; - border-radius: ${cssVar.borderRadius}; - - font-size: 13px; - color: ${cssVar.colorTextSecondary}; - - transition: background-color 0.2s; - - &:hover { - color: ${cssVar.colorText}; - background: ${cssVar.colorFillTertiary}; - } - `, - dirItem: css` - cursor: pointer; - - padding-block: 6px; - padding-inline: 8px; - border-radius: ${cssVar.borderRadius}; - - transition: background-color 0.2s; - - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - dirItemActive: css` - background: ${cssVar.colorFillTertiary}; - `, - dirName: css` - font-size: 13px; - font-weight: 500; - color: ${cssVar.colorText}; - `, - dirPath: css` - overflow: hidden; - - font-size: 11px; - color: ${cssVar.colorTextDescription}; - text-overflow: ellipsis; - white-space: nowrap; - `, - removeBtn: css` - cursor: pointer; - - display: flex; - flex: none; - align-items: center; - justify-content: center; - - width: 20px; - height: 20px; - border-radius: ${cssVar.borderRadius}; - - color: ${cssVar.colorTextQuaternary}; - - transition: all 0.2s; - - &:hover { - color: ${cssVar.colorTextSecondary}; - background: ${cssVar.colorFillSecondary}; - } - `, - scrollContainer: css` - overflow-y: auto; - max-height: 360px; - `, - clearText: css` - cursor: pointer; - - padding-block: 6px 2px; - padding-inline: 8px; - - font-size: 11px; - font-weight: 500; - color: ${cssVar.colorTextTertiary}; - - transition: color 0.2s; - - &:hover { - color: ${cssVar.colorText}; - } - `, - sectionTitle: css` - padding-block: 6px 2px; - padding-inline: 8px; - - font-size: 11px; - font-weight: 500; - color: ${cssVar.colorTextQuaternary}; - text-transform: uppercase; - letter-spacing: 0.5px; - `, -})); - -// Backfills `repoType` for entries cached before detection supported submodule / -// worktree layouts — `useRepoType` probes and updates the recents cache. -const RecentDirIcon = memo<{ entry: RecentDirEntry }>(({ entry }) => { - const probed = useRepoType(entry.path); - return <>{renderDirIcon(entry.repoType ?? probed)}; -}); - -RecentDirIcon.displayName = 'RecentDirIcon'; - -interface WorkingDirectoryContentProps { - agentId: string; - onClose?: () => void; -} - -const WorkingDirectoryContent = memo(({ agentId, onClose }) => { - const { t } = useTranslation(['plugin', 'chat']); - - const agentWorkingDirectory = useAgentStore((s) => - agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s), - ); - const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); - const effectiveDir = topicWorkingDirectory || agentWorkingDirectory; - - const activeTopicId = useChatStore((s) => s.activeTopicId); - const activeTopic = useChatStore((s) => - s.activeTopicId ? topicSelectors.getTopicById(s.activeTopicId)(s) : undefined, - ); - const updateAgentRuntimeEnvConfig = useAgentStore((s) => s.updateAgentRuntimeEnvConfigById); - const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata); - - // Local runs execute on this very machine, so also record the chosen dir in - // its device-registry `workingDirs` — keeps the settings detail view + future - // device-mode picker in sync. workingDirs only; the device default is untouched. - const useFetchDeviceInfo = useElectronStore((s) => s.useFetchGatewayDeviceInfo); - const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo); - useFetchDeviceInfo(); - const currentDeviceId = gatewayDeviceInfo?.deviceId; - const { data: allDevices } = lambdaQuery.device.listDevices.useQuery(undefined, { - staleTime: 30_000, - }); - const deviceWorkingDirs = - allDevices?.find((d) => d.deviceId === currentDeviceId)?.workingDirs ?? []; - const updateDeviceCwd = useUpdateDeviceCwd(); - - const [recentDirs, setRecentDirs] = useState(getRecentDirs); - - const displayDirs = useMemo(() => { - const dirs = [...recentDirs]; - if (effectiveDir && !dirs.some((d) => d.path === effectiveDir)) { - dirs.unshift({ path: effectiveDir }); - } - return dirs; - }, [recentDirs, effectiveDir]); - - const selectDir = useCallback( - async (entry: RecentDirEntry) => { - const newPath = entry.path; - // Scope of the write: once a topic is active, changing cwd updates the - // topic's own binding (each topic is a CC session pinned to a dir). - // Only when there's no topic yet (blank conversation) do we touch the - // agent-level default so the next new topic inherits it. - const commit = async () => { - if (activeTopicId) { - await updateTopicMetadata(activeTopicId, { workingDirectory: newPath }); - } else { - await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: newPath }); - } - setRecentDirs(addRecentDir(entry)); - // Record on this machine's device registry (workingDirs only) — the - // whole entry, so the detected repoType is preserved cross-device. - if (currentDeviceId) { - void updateDeviceCwd(currentDeviceId, entry, deviceWorkingDirs, { setDefault: false }); - } - onClose?.(); - }; - - // CC sessions are pinned per-cwd under `~/.claude/projects//`. - // Changing the topic's cwd makes `--resume` fail, so we warn before the - // implicit session reset. - const priorSessionId = activeTopic?.metadata?.heteroSessionId; - const priorCwd = activeTopic?.metadata?.workingDirectory; - const wouldResetSession = !!priorSessionId && !!priorCwd && priorCwd !== newPath; - - if (wouldResetSession) { - confirmModal({ - cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), - content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), - okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), - onOk: commit, - title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), - }); - return; - } - - await commit(); - }, - [ - activeTopicId, - activeTopic, - agentId, - currentDeviceId, - deviceWorkingDirs, - t, - updateAgentRuntimeEnvConfig, - updateDeviceCwd, - updateTopicMetadata, - onClose, - ], - ); - - const clearDir = useCallback(async () => { - // Mirror selectDir's scope: clear the topic binding once a topic is active, - // otherwise clear the agent-level default. Each falls back to the next - // level (topic → agent → desktop home) rather than to a hard-empty value. - const commit = async () => { - if (activeTopicId) { - await updateTopicMetadata(activeTopicId, { workingDirectory: undefined }); - } else { - await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: undefined }); - } - onClose?.(); - }; - - // Clearing changes the topic's cwd, which invalidates a pinned CC session - // the same way switching folders does — warn before the implicit reset. - const priorSessionId = activeTopic?.metadata?.heteroSessionId; - const priorCwd = activeTopic?.metadata?.workingDirectory; - const wouldResetSession = !!priorSessionId && !!priorCwd; - - if (wouldResetSession) { - confirmModal({ - cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), - content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), - okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), - onOk: commit, - title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), - }); - return; - } - - await commit(); - }, [ - activeTopicId, - activeTopic, - agentId, - t, - updateAgentRuntimeEnvConfig, - updateTopicMetadata, - onClose, - ]); - - const handleChooseFolder = useCallback(async () => { - if (!isDesktop) return; - const result = await electronSystemService.selectFolder({ - defaultPath: effectiveDir || undefined, - title: t('localSystem.workingDirectory.selectFolder'), - }); - if (result) { - await selectDir({ path: result.path, repoType: result.repoType }); - } - }, [effectiveDir, t, selectDir]); - - const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => { - e.stopPropagation(); - setRecentDirs(removeRecentDir(path)); - }, []); - - const getDirName = (path: string) => path.split('/').findLast(Boolean) || path; - - return ( - - -
{t('localSystem.workingDirectory.recent')}
- {effectiveDir && ( -
- {t('localSystem.workingDirectory.clear')} -
- )} -
-
- {displayDirs.length === 0 ? ( - - {t('localSystem.workingDirectory.noRecent')} - - ) : ( - displayDirs.map((entry) => { - const isActive = entry.path === effectiveDir; - return ( - selectDir(entry)} - > - - -
{getDirName(entry.path)}
-
{entry.path}
-
- {isActive ? ( - - ) : ( -
handleRemoveRecent(e, entry.path)} - > - -
- )} -
- ); - }) - )} -
- - {isDesktop && ( - - - {t('localSystem.workingDirectory.chooseDifferentFolder')} - - )} -
- ); -}); - -WorkingDirectoryContent.displayName = 'WorkingDirectoryContent'; - -export default WorkingDirectoryContent; diff --git a/src/features/ChatInput/RuntimeConfig/WorkingDirectoryPicker.tsx b/src/features/ChatInput/RuntimeConfig/WorkingDirectoryPicker.tsx new file mode 100644 index 0000000000..e7743eb07e --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/WorkingDirectoryPicker.tsx @@ -0,0 +1,388 @@ +'use client'; + +import { isDesktop } from '@lobechat/const'; +import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cssVar, cx } from 'antd-style'; +import { + CheckIcon, + ChevronDownIcon, + FolderIcon, + FolderOpenIcon, + FolderPlusIcon, + XIcon, +} from 'lucide-react'; +import { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + resolveAgentWorkingDirectory, + resolveTargetDeviceId, +} from '@/helpers/agentWorkingDirectory'; +import { deviceService } from '@/services/device'; +import { electronSystemService } from '@/services/electron/system'; +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; +import { deviceSelectors, useDeviceStore } from '@/store/device'; +import { useElectronStore } from '@/store/electron'; + +import { openAddWorkingDirModal } from './AddWorkingDirModal'; +import { renderDirIcon } from './dirIcon'; +import { useCommitWorkingDirectory } from './useCommitWorkingDirectory'; +import { useMigrateDeviceRecents } from './useMigrateDeviceRecents'; + +const styles = createStaticStyles(({ css }) => ({ + button: css` + cursor: pointer; + + display: flex; + gap: 6px; + align-items: center; + + padding-block: 2px; + padding-inline: 4px; + border-radius: 4px; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + transition: background 0.2s; + + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + chooseFolderItem: css` + cursor: pointer; + + padding-block: 8px; + padding-inline: 8px; + border-radius: ${cssVar.borderRadius}; + + font-size: 13px; + color: ${cssVar.colorTextSecondary}; + + transition: background-color 0.2s; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + } + `, + clearText: css` + cursor: pointer; + + padding-block: 6px 2px; + padding-inline: 8px; + + font-size: 11px; + font-weight: 500; + color: ${cssVar.colorTextTertiary}; + + transition: color 0.2s; + + &:hover { + color: ${cssVar.colorText}; + } + `, + dirItem: css` + cursor: pointer; + + padding-block: 6px; + padding-inline: 8px; + border-radius: ${cssVar.borderRadius}; + + transition: background-color 0.2s; + + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + dirItemActive: css` + background: ${cssVar.colorFillTertiary}; + `, + dirName: css` + font-size: 13px; + font-weight: 500; + color: ${cssVar.colorText}; + `, + dirPath: css` + overflow: hidden; + + font-size: 11px; + color: ${cssVar.colorTextDescription}; + text-overflow: ellipsis; + white-space: nowrap; + `, + removeBtn: css` + cursor: pointer; + + display: flex; + flex: none; + align-items: center; + justify-content: center; + + width: 20px; + height: 20px; + border-radius: ${cssVar.borderRadius}; + + color: ${cssVar.colorTextQuaternary}; + + transition: all 0.2s; + + &:hover { + color: ${cssVar.colorTextSecondary}; + background: ${cssVar.colorFillSecondary}; + } + `, + scrollContainer: css` + overflow-y: auto; + max-height: 360px; + `, + sectionTitle: css` + padding-block: 6px 2px; + padding-inline: 8px; + + font-size: 11px; + font-weight: 500; + color: ${cssVar.colorTextQuaternary}; + `, +})); + +const getDirName = (path: string) => path.split('/').findLast(Boolean) || path; + +type FolderEntry = { path: string; repoType?: 'git' | 'github' }; + +/** This machine: browse the filesystem via the native Electron folder dialog. */ +const ChooseLocalFolderRow = memo<{ defaultPath?: string; onPick: (entry: FolderEntry) => void }>( + ({ defaultPath, onPick }) => { + const { t } = useTranslation('device'); + const handleClick = async () => { + const result = await electronSystemService.selectFolder({ + defaultPath: defaultPath || undefined, + title: t('workingDirectory.selectFolder'), + }); + if (result) onPick({ path: result.path, repoType: result.repoType }); + }; + return ( + + + {t('workingDirectory.chooseDifferentFolder')} + + ); + }, +); +ChooseLocalFolderRow.displayName = 'ChooseLocalFolderRow'; + +/** Web / remote device: filesystem isn't browsable here — enter an absolute path. */ +const AddRemoteFolderRow = memo<{ + defaultCwd?: string; + deviceId?: string; + onBeforeOpen: () => void; + onPick: (entry: FolderEntry) => void; +}>(({ defaultCwd, deviceId, onBeforeOpen, onPick }) => { + const { t } = useTranslation('device'); + + // Stat the entered path on the target device (it can't be browsed here): block + // on a definitive negative, otherwise commit with the detected repoType so the + // recent entry shows the right (git / github) icon. An unreachable device + // (null) is treated as "can't verify" and allowed through without a repoType. + const handleSubmit = async (path: string): Promise => { + const result = deviceId ? await deviceService.statPath(deviceId, path) : undefined; + if (result) { + if (!result.exists) return t('workingDirectory.pathNotExist'); + if (!result.isDirectory) return t('workingDirectory.pathNotDirectory'); + } + onPick({ path, repoType: result?.repoType }); + return undefined; + }; + + const handleClick = () => { + onBeforeOpen(); + openAddWorkingDirModal({ onSubmit: handleSubmit, placeholder: defaultCwd || undefined }); + }; + return ( + + + {t('workingDirectory.addFolder')} + + ); +}); +AddRemoteFolderRow.displayName = 'AddRemoteFolderRow'; + +interface WorkingDirectoryPickerProps { + agentId: string; +} + +/** + * Unified working-directory picker for both local and remote runs. Recents come + * from the target device's `device.workingDirs`; picks write through the unified + * `useCommitWorkingDirectory` (topic override / agent per-device choice). When + * the target is this machine, the native folder dialog is offered; a true remote + * device falls back to manual path entry (its filesystem isn't browsable here). + */ +const WorkingDirectoryPicker = memo(({ agentId }) => { + const { t } = useTranslation('device'); + const [open, setOpen] = useState(false); + + // Populate the device store (SWR dedupes across callers). + useDeviceStore((s) => s.useFetchDevices)(); + // One-time fold of legacy localStorage recents into device.workingDirs. + useMigrateDeviceRecents(); + + const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId)); + const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId); + const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId); + // The local machine's filesystem is browsable; a remote device's is not. + const isLocalDevice = isDesktop && !!targetDeviceId && targetDeviceId === currentDeviceId; + + const recents = useDeviceStore(deviceSelectors.getDeviceWorkingDirs(targetDeviceId)); + const deviceDefaultCwd = useDeviceStore(deviceSelectors.getDeviceDefaultCwd(targetDeviceId)); + const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); + const legacyAgentWorkingDirectory = useAgentStore( + (s) => s.localAgentWorkingDirectoryMap[agentId], + ); + + // The explicitly-selected cwd (no home fallback) — drives the active check and + // the Clear affordance. + const selectedDir = resolveAgentWorkingDirectory({ + agencyConfig, + currentDeviceId, + deviceDefaultCwd, + legacyAgentWorkingDirectory, + topicWorkingDirectory, + }); + + const { clear, commit } = useCommitWorkingDirectory(agentId); + const removeDeviceWorkingDir = useDeviceStore((s) => s.removeDeviceWorkingDir); + + const pick = async (entry: { path: string; repoType?: 'git' | 'github' }) => { + await commit(entry); + setOpen(false); + }; + + const handleRemoveRecent = (e: React.MouseEvent, path: string) => { + e.stopPropagation(); + if (targetDeviceId) void removeDeviceWorkingDir(targetDeviceId, path); + }; + + const content = ( + + +
{t('workingDirectory.recent')}
+ {selectedDir && ( +
void clear().then(() => setOpen(false))}> + {t('workingDirectory.clear')} +
+ )} +
+
+ {recents.length === 0 ? ( + + {t('workingDirectory.noRecent')} + + ) : ( + recents.map((entry) => { + const isActive = entry.path === selectedDir; + return ( + void pick(entry)} + > + {renderDirIcon(entry.repoType)} + +
{getDirName(entry.path)}
+
{entry.path}
+
+ {isActive ? ( + + ) : ( +
handleRemoveRecent(e, entry.path)} + > + +
+ )} +
+ ); + }) + )} +
+ + {isLocalDevice ? ( + + ) : ( + setOpen(false)} + onPick={pick} + /> + )} +
+ ); + + const displayName = selectedDir ? getDirName(selectedDir) : t('workingDirectory.notSet'); + + const trigger = ( +
+ {selectedDir ? ( + renderDirIcon(recents.find((r) => r.path === selectedDir)?.repoType) + ) : ( + + )} + {displayName} + +
+ ); + + return ( + +
+ {open ? ( + trigger + ) : ( + {trigger} + )} +
+
+ ); +}); + +WorkingDirectoryPicker.displayName = 'WorkingDirectoryPicker'; + +export default WorkingDirectoryPicker; diff --git a/src/features/ChatInput/RuntimeConfig/WorkingDirectorySection.tsx b/src/features/ChatInput/RuntimeConfig/WorkingDirectorySection.tsx new file mode 100644 index 0000000000..72142a326a --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/WorkingDirectorySection.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { isDesktop } from '@lobechat/const'; +import { memo } from 'react'; + +import { resolveTargetDeviceId } from '@/helpers/agentWorkingDirectory'; +import { useEffectiveWorkingDirectory } from '@/hooks/useEffectiveWorkingDirectory'; +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors } from '@/store/agent/selectors'; +import { deviceSelectors, useDeviceStore } from '@/store/device'; +import { useElectronStore } from '@/store/electron'; + +import GitStatus from './GitStatus'; +import { useRepoType } from './useRepoType'; +import WorkingDirectoryPicker from './WorkingDirectoryPicker'; + +interface WorkingDirectorySectionProps { + agentId: string; +} + +/** + * Working directory + git status, shared by the agent runtime bars. The unified + * picker handles local and remote targets alike; git status shows for both — the + * local machine probes its own filesystem, a remote device answers over RPC + * (read-only) via GitStatus's `deviceId`. + */ +const WorkingDirectorySection = memo(({ agentId }) => { + const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId)); + const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId); + const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId); + const isLocalDevice = isDesktop && !!targetDeviceId && targetDeviceId === currentDeviceId; + + const effectiveWorkingDirectory = useEffectiveWorkingDirectory(agentId); + + // Local machine probes the filesystem for repoType; a remote device's repoType + // comes from the cached `workingDirs` entry (we can't probe a remote fs here). + const localRepoType = useRepoType(isLocalDevice ? effectiveWorkingDirectory : undefined); + const remoteDirs = useDeviceStore(deviceSelectors.getDeviceWorkingDirs(targetDeviceId)); + const remoteRepoType = remoteDirs.find((d) => d.path === effectiveWorkingDirectory)?.repoType; + const repoType = isLocalDevice ? localRepoType : remoteRepoType; + + return ( + <> + + {effectiveWorkingDirectory && repoType && ( + + )} + + ); +}); + +WorkingDirectorySection.displayName = 'WorkingDirectorySection'; + +export default WorkingDirectorySection; diff --git a/src/features/ChatInput/RuntimeConfig/WorkspaceControls.tsx b/src/features/ChatInput/RuntimeConfig/WorkspaceControls.tsx new file mode 100644 index 0000000000..a2a8a6bd81 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/WorkspaceControls.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { isDesktop } from '@lobechat/const'; +import { memo } from 'react'; + +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors'; + +import CloudRepoSwitcher from './CloudRepoSwitcher'; +import HeteroDeviceSwitcher from './HeteroDeviceSwitcher'; +import WorkingDirectorySection from './WorkingDirectorySection'; + +interface WorkspaceControlsProps { + agentId: string; + /** + * Force the workspace (directory + branch + file changes + PR) to show even + * when the runtime isn't in local mode. Heterogeneous agents always run inside + * a working directory, so they pass `true`; normal agents only surface it in + * local mode. + */ + alwaysShowWorkspace?: boolean; +} + +/** + * Workspace/Project control strip shared by the chat-input runtime bars: + * device selector + working directory + git branch / file changes / PR info. + * + * Both RuntimeConfig (normal agents) and the heterogeneous WorkingDirectoryBar + * compose this, so the Device / Branch / diff / PR cluster can't drift between + * them. The bar-specific bits (ModeSelector, ApprovalMode, ContextWindow, the + * full-access badge) stay in their respective bars. + */ +const WorkspaceControls = memo( + ({ agentId, alwaysShowWorkspace = false }) => { + const runtimeMode = useAgentStore(chatConfigByIdSelectors.getRuntimeModeById(agentId)); + const isHeterogeneous = useAgentStore(agentByIdSelectors.isAgentHeterogeneousById(agentId)); + const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId)); + const isDeviceMode = + agencyConfig?.executionTarget === 'device' && !!agencyConfig?.boundDeviceId; + + const renderWorkspace = () => { + // Remote device runs get the device-scoped picker, regardless of runtimeMode + // (HeteroDeviceSwitcher sets runtimeMode to 'none' when a device is selected). + if (isDeviceMode) return ; + + // Web has no local filesystem — cloud / heterogeneous agents browse the repo + // through the cloud repo switcher instead. + if (!isDesktop) { + return isHeterogeneous || alwaysShowWorkspace ? ( + + ) : null; + } + + // Desktop: local working directory + git branch / diff / PR. Shown when the + // run is local, or always for heterogeneous agents (they always have a cwd). + if (alwaysShowWorkspace || runtimeMode === 'local') { + return ; + } + + return null; + }; + + return ( + <> + + {renderWorkspace()} + + ); + }, +); + +WorkspaceControls.displayName = 'WorkspaceControls'; + +export default WorkspaceControls; diff --git a/src/features/ChatInput/RuntimeConfig/index.tsx b/src/features/ChatInput/RuntimeConfig/index.tsx index 21167370b0..57266c10ea 100644 --- a/src/features/ChatInput/RuntimeConfig/index.tsx +++ b/src/features/ChatInput/RuntimeConfig/index.tsx @@ -1,158 +1,35 @@ -import { isDesktop } from '@lobechat/const'; -import { type RuntimeEnvMode } from '@lobechat/types'; -import { Github } from '@lobehub/icons'; -import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui'; -import { createStaticStyles, cssVar, cx } from 'antd-style'; -import { - ChevronDownIcon, - CloudIcon, - FolderIcon, - GitBranchIcon, - LaptopIcon, - MonitorOffIcon, - SquircleDashed, -} from 'lucide-react'; -import { memo, type ReactNode, useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Flexbox, Skeleton } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo } from 'react'; import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; -import { useUserStore } from '@/store/user'; -import { labPreferSelectors } from '@/store/user/selectors'; +import { agentByIdSelectors } from '@/store/agent/selectors'; import ContextWindow from '../ActionBar/Token'; import { useAgentId } from '../hooks/useAgentId'; -import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig'; import { useChatInputStore } from '../store'; import ApprovalMode from './ApprovalMode'; -import CloudRepoSwitcher from './CloudRepoSwitcher'; -import GitStatus from './GitStatus'; -import HeteroDeviceSwitcher from './HeteroDeviceSwitcher'; import ModeSelector from './ModeSelector'; -import { useRepoType } from './useRepoType'; -import WorkingDirectory from './WorkingDirectory'; - -const MODE_ICONS: Record = { - cloud: CloudIcon, - local: LaptopIcon, - none: MonitorOffIcon, -}; +import WorkspaceControls from './WorkspaceControls'; const styles = createStaticStyles(({ css }) => ({ bar: css` padding-block: 0; padding-inline: 4px; `, - button: css` - cursor: pointer; - - display: flex; - gap: 6px; - align-items: center; - - height: 28px; - padding-inline: 8px; - border-radius: 6px; - - font-size: 12px; - color: ${cssVar.colorTextSecondary}; - - transition: all 0.2s; - - &:hover { - color: ${cssVar.colorText}; - background: ${cssVar.colorFillSecondary}; - } - `, - modeDesc: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - `, - modeOption: css` - cursor: pointer; - - width: 100%; - padding-block: 8px; - padding-inline: 8px; - border-radius: ${cssVar.borderRadius}; - - transition: background-color 0.2s; - - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - modeOptionActive: css` - background: ${cssVar.colorFillTertiary}; - `, - modeOptionDesc: css` - font-size: 12px; - color: ${cssVar.colorTextDescription}; - `, - modeOptionIcon: css` - border: 1px solid ${cssVar.colorFillTertiary}; - border-radius: ${cssVar.borderRadius}; - background: ${cssVar.colorBgElevated}; - `, - modeOptionTitle: css` - font-size: 14px; - font-weight: 500; - color: ${cssVar.colorText}; - `, })); const RuntimeConfig = memo(() => { - const { t } = useTranslation('chat'); - const { t: tPlugin } = useTranslation('plugin'); const agentId = useAgentId(); - const { updateAgentChatConfig } = useUpdateAgentConfig(); - const [dirPopoverOpen, setDirPopoverOpen] = useState(false); - const [modePopoverOpen, setModePopoverOpen] = useState(false); const showContextWindow = useChatInputStore((s) => s.rightActions.flat().includes('contextWindow'), ); - const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode] = useAgentStore((s) => [ + const [isLoading, enableAgentMode] = useAgentStore((s) => [ agentByIdSelectors.isAgentConfigLoadingById(agentId)(s), - chatConfigByIdSelectors.getRuntimeModeById(agentId)(s), - agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false, agentByIdSelectors.getAgentEnableModeById(agentId)(s), ]); - const enableExecutionDeviceSwitcher = useUserStore( - labPreferSelectors.enableExecutionDeviceSwitcher, - ); - - const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); - const agentWorkingDirectory = useAgentStore((s) => - agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined, - ); - const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory; - - const repoType = useRepoType(effectiveWorkingDirectory); - - const dirIconNode = useMemo((): ReactNode => { - if (!effectiveWorkingDirectory) return ; - if (repoType === 'github') return ; - if (repoType === 'git') return ; - return ; - }, [effectiveWorkingDirectory, repoType]); - - const switchMode = useCallback( - async (mode: RuntimeEnvMode) => { - if (mode === runtimeMode) return; - - const platform = isDesktop ? 'desktop' : 'web'; - - await updateAgentChatConfig({ - runtimeEnv: { runtimeMode: { [platform]: mode } }, - }); - }, - [runtimeMode, updateAgentChatConfig], - ); - // Skeleton placeholder to prevent layout jump during loading if (!agentId || isLoading) { return ( @@ -163,161 +40,12 @@ const RuntimeConfig = memo(() => { ); } - const ModeIcon = MODE_ICONS[runtimeMode]; - const modeLabel = t(`runtimeEnv.mode.${runtimeMode}`); - - const displayName = effectiveWorkingDirectory - ? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory - : tPlugin('localSystem.workingDirectory.notSet'); - - const modes: { desc: string; icon: typeof LaptopIcon; label: string; mode: RuntimeEnvMode }[] = [ - // Local mode is desktop-only - ...(isDesktop - ? [ - { - desc: t('runtimeEnv.mode.localDesc'), - icon: LaptopIcon, - label: t('runtimeEnv.mode.local'), - mode: 'local' as RuntimeEnvMode, - }, - ] - : []), - { - desc: t('runtimeEnv.mode.cloudDesc'), - icon: CloudIcon, - label: t('runtimeEnv.mode.cloud'), - mode: 'cloud', - }, - { - desc: t('runtimeEnv.mode.noneDesc'), - icon: MonitorOffIcon, - label: t('runtimeEnv.mode.none'), - mode: 'none', - }, - ]; - - const modeContent = ( - - {modes.map(({ mode, icon, label, desc }) => ( - switchMode(mode)} - > - - - - -
{label}
-
{desc}
-
-
- ))} -
- ); - - const modeButton = ( -
- - {modeLabel} - -
- ); - - const dirButton = ( -
- {dirIconNode} - {displayName} - -
- ); - - const rightContent = () => { - // Web + heterogeneous agent always shows the cloud repo switcher, - // regardless of the stored runtimeMode (which may be 'local' from desktop). - if (!isDesktop && isHeterogeneous && agentId) { - return ; - } - - // Desktop local mode: show working directory picker - if (runtimeMode === 'local') { - return ( - <> - setDirPopoverOpen(false)} /> - } - onOpenChange={setDirPopoverOpen} - > -
- {dirPopoverOpen ? ( - dirButton - ) : ( - - {dirButton} - - )} -
-
- {effectiveWorkingDirectory && repoType && ( - - )} - - ); - } - - return null; - }; - return ( - {/* Left: Chat mode switcher + (agent-only) runtime env + working directory */} + {/* Left: chat-mode switcher + (agent-only) execution device + working directory */} - {enableAgentMode && enableExecutionDeviceSwitcher && agentId && ( - - )} - {enableAgentMode && ( - <> - {!enableExecutionDeviceSwitcher && ( - -
- {modePopoverOpen ? ( - modeButton - ) : ( - {modeButton} - )} -
-
- )} - {rightContent()} - - )} + {enableAgentMode && }
diff --git a/src/features/ChatInput/RuntimeConfig/useCommitWorkingDirectory.ts b/src/features/ChatInput/RuntimeConfig/useCommitWorkingDirectory.ts new file mode 100644 index 0000000000..09d89a75e9 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/useCommitWorkingDirectory.ts @@ -0,0 +1,118 @@ +import type { WorkingDirEntry } from '@lobechat/types'; +import { confirmModal } from '@lobehub/ui/base-ui'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { resolveTargetDeviceId } from '@/helpers/agentWorkingDirectory'; +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; +import { useDeviceStore } from '@/store/device'; +import { useElectronStore } from '@/store/electron'; + +/** + * Unified working-directory writes, shared by the directory picker for both + * local and remote runs. Write rules: + * + * - **active topic** → `topic.metadata.workingDirectory` (per-topic override) + * - **no topic yet** → `agencyConfig.workingDirByDevice[targetDeviceId]` + * - **always** → upsert the target device's `workingDirs` recent list + * + * Changing a topic's cwd invalidates its pinned CC session (sessions are keyed + * per-cwd), so warn before the implicit reset — same as the legacy pickers. + */ +export const useCommitWorkingDirectory = (agentId: string) => { + const { t } = useTranslation(['plugin', 'chat']); + + const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId)); + const updateAgentConfigById = useAgentStore((s) => s.updateAgentConfigById); + + const activeTopicId = useChatStore((s) => s.activeTopicId); + const activeTopic = useChatStore((s) => + s.activeTopicId ? topicSelectors.getTopicById(s.activeTopicId)(s) : undefined, + ); + const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata); + + const updateDeviceCwd = useDeviceStore((s) => s.updateDeviceCwd); + const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId); + const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId); + + const writeCwd = useCallback( + async (newPath: string | undefined, entry?: WorkingDirEntry) => { + // Topic override wins once a conversation exists; otherwise persist the + // agent's per-device choice so a new topic inherits it. + if (activeTopicId) { + await updateTopicMetadata(activeTopicId, { workingDirectory: newPath }); + } else if (targetDeviceId) { + const prev = agencyConfig?.workingDirByDevice ?? {}; + const nextMap = { ...prev }; + if (newPath) nextMap[targetDeviceId] = newPath; + else delete nextMap[targetDeviceId]; + await updateAgentConfigById(agentId, { + agencyConfig: { ...agencyConfig, workingDirByDevice: nextMap }, + }); + } + // Record on the target device's recent list (not the device-wide default — + // a per-agent pick shouldn't repoint other agents on the same device). + if (newPath && entry && targetDeviceId) { + await updateDeviceCwd(targetDeviceId, { ...entry, path: newPath }, { setDefault: false }); + } + }, + [ + agentId, + agencyConfig, + activeTopicId, + targetDeviceId, + updateAgentConfigById, + updateTopicMetadata, + updateDeviceCwd, + ], + ); + + /** Pick a directory (with the CC-session-reset guard). */ + const commit = useCallback( + async (entry: WorkingDirEntry) => { + const newPath = entry.path.trim(); + if (!newPath) return; + + const run = () => writeCwd(newPath, entry); + + const priorSessionId = activeTopic?.metadata?.heteroSessionId; + const priorCwd = activeTopic?.metadata?.workingDirectory; + if (priorSessionId && priorCwd && priorCwd !== newPath) { + confirmModal({ + cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), + content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), + okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), + onOk: run, + title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), + }); + return; + } + await run(); + }, + [activeTopic, t, writeCwd], + ); + + /** Clear the current selection (falls back to the next precedence level). */ + const clear = useCallback(async () => { + const run = () => writeCwd(undefined); + + const priorSessionId = activeTopic?.metadata?.heteroSessionId; + const priorCwd = activeTopic?.metadata?.workingDirectory; + if (priorSessionId && priorCwd) { + confirmModal({ + cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), + content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), + okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), + onOk: run, + title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), + }); + return; + } + await run(); + }, [activeTopic, t, writeCwd]); + + return { clear, commit }; +}; diff --git a/src/features/ChatInput/RuntimeConfig/useGitAheadBehind.ts b/src/features/ChatInput/RuntimeConfig/useGitAheadBehind.ts deleted file mode 100644 index ef54752c7f..0000000000 --- a/src/features/ChatInput/RuntimeConfig/useGitAheadBehind.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isDesktop } from '@lobechat/const'; - -import { useClientDataSWR } from '@/libs/swr'; -import { electronGitService } from '@/services/electron/git'; - -/** - * Ahead/behind commit counts for the current branch vs its upstream tracking ref. - * Shown as push (↑) / pull (↓) badges in the status bar. Piggybacks a - * best-effort `git fetch` on every SWR load (including focus revalidation) - * inside the IPC, so remote updates surface when the user switches back to - * the window without needing a manual fetch. - */ -export const useGitAheadBehind = (dirPath?: string) => { - const key = isDesktop && dirPath ? ['git-ahead-behind', dirPath] : null; - - return useClientDataSWR(key, () => electronGitService.getGitAheadBehind(dirPath!), { - focusThrottleInterval: 5 * 1000, - revalidateOnFocus: true, - shouldRetryOnError: false, - }); -}; diff --git a/src/features/ChatInput/RuntimeConfig/useGitInfo.ts b/src/features/ChatInput/RuntimeConfig/useGitInfo.ts deleted file mode 100644 index 8dc3269882..0000000000 --- a/src/features/ChatInput/RuntimeConfig/useGitInfo.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { isDesktop } from '@lobechat/const'; -import type { GitLinkedPullRequest } from '@lobechat/electron-client-ipc'; - -import { useClientDataSWR } from '@/libs/swr'; -import { electronGitService } from '@/services/electron/git'; - -export interface GitInfo { - branch?: string; - detached?: boolean; - extraCount?: number; - ghMissing?: boolean; - pullRequest?: GitLinkedPullRequest | null; -} - -const fetchGitInfo = async (dirPath: string, isGithub: boolean): Promise => { - const { branch, detached } = await electronGitService.getGitBranch(dirPath); - if (!branch) return {}; - - // Skip PR lookup for detached HEAD or non-github repos - if (detached || !isGithub) return { branch, detached }; - - const prResult = await electronGitService.getLinkedPullRequest({ branch, path: dirPath }); - return { - branch, - detached, - extraCount: prResult.extraCount, - ghMissing: prResult.status === 'gh-missing', - pullRequest: prResult.pullRequest, - }; -}; - -export const useGitInfo = (dirPath?: string, isGithub: boolean = false) => { - const key = isDesktop && dirPath ? ['git-info', dirPath, isGithub] : null; - - return useClientDataSWR(key, () => fetchGitInfo(dirPath!, isGithub), { - // Prevent gh spam: dedupe within 60s + throttle focus revalidation to 60s - dedupingInterval: 60 * 1000, - focusThrottleInterval: 60 * 1000, - // gh may not be installed — don't retry aggressively - shouldRetryOnError: false, - }); -}; diff --git a/src/features/ChatInput/RuntimeConfig/useMigrateDeviceRecents.ts b/src/features/ChatInput/RuntimeConfig/useMigrateDeviceRecents.ts new file mode 100644 index 0000000000..8c43e0b575 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/useMigrateDeviceRecents.ts @@ -0,0 +1,40 @@ +import { isDesktop } from '@lobechat/const'; +import { useEffect } from 'react'; + +import { useDeviceStore } from '@/store/device'; +import { useElectronStore } from '@/store/electron'; + +import { getRecentDirs, RECENT_DIRS_KEY } from './recentDirs'; + +// Module-level guard: the migration is global, not per-component, so only the +// first mounted caller runs it per session (clearing localStorage makes it a +// no-op across reloads anyway). +let migrationStarted = false; + +/** + * One-time fold of the legacy localStorage recent dirs into this machine's + * `device.workingDirs` (the unified recent source). Lives in the feature layer + * because it reads/clears feature-owned localStorage; it passes the entries + * *into* the device store action (store never imports feature storage). Runs + * once the device store is populated and this machine's deviceId is known + * (desktop only); keeps localStorage on a failed persist for a later retry. + */ +export const useMigrateDeviceRecents = (): void => { + const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId); + const isDevicesInit = useDeviceStore((s) => s.isDevicesInit); + const migrate = useDeviceStore((s) => s.migrateLocalRecentsToDevice); + + useEffect(() => { + if (migrationStarted || !isDesktop || !currentDeviceId || !isDevicesInit) return; + + const legacy = getRecentDirs(); + migrationStarted = true; + if (legacy.length === 0) return; + + migrate(currentDeviceId, legacy) + .then(() => localStorage.removeItem(RECENT_DIRS_KEY)) + .catch(() => { + // Persist failed — keep localStorage so the next reload retries. + }); + }, [currentDeviceId, isDevicesInit, migrate]); +}; diff --git a/src/features/ChatInput/RuntimeConfig/useRepoType.ts b/src/features/ChatInput/RuntimeConfig/useRepoType.ts index b3c15f7364..ae4e9c9802 100644 --- a/src/features/ChatInput/RuntimeConfig/useRepoType.ts +++ b/src/features/ChatInput/RuntimeConfig/useRepoType.ts @@ -3,26 +3,46 @@ import { useEffect, useMemo } from 'react'; import useSWR from 'swr'; import { electronGitService } from '@/services/electron/git'; +import { deviceSelectors, useDeviceStore } from '@/store/device'; +import { useElectronStore } from '@/store/electron'; import { getRecentDirs, setRecentDirRepoType } from './recentDirs'; export type RepoType = 'git' | 'github' | undefined; /** - * Resolve the repo type for a working directory. + * Resolve the repo type for a working directory on `deviceId` (the local machine + * when omitted). * - * Cached entries from `recentDirs` (populated when the user picks a folder) - * are used as a fast path. Legacy/string entries and agent-config-driven - * paths that never went through the picker have no cached `repoType`, so - * we probe the filesystem via IPC and backfill the cache. + * Primary source is the device's persisted `workingDirs[].repoType` (committed + * by the picker, backfilled by `statPath`) — hydrated from `listDevices`, so it + * works for remote devices too. Falls back, only when the target is the local + * machine, to the legacy localStorage recents + an IPC probe (backfilling the + * cache) for paths that never went through the picker. A remote device's + * filesystem isn't probeable here, so it relies on the persisted value. */ -export const useRepoType = (path?: string): RepoType => { - const cached = useMemo(() => { - if (!path) return undefined; - return getRecentDirs().find((d) => d.path === path)?.repoType; - }, [path]); +export const useRepoType = (path?: string, deviceId?: string): RepoType => { + const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId); + const isLocalTarget = !deviceId || deviceId === currentDeviceId; - const shouldProbe = isDesktop && !!path && !cached; + // Persisted repoType from the (local or remote) device's working dirs. + const fromDevice = useDeviceStore((s) => + path + ? deviceSelectors + .getDeviceWorkingDirs(deviceId)(s) + .find((d) => d.path === path)?.repoType + : undefined, + ); + + // Legacy localStorage fast path — local machine only. + const cachedLocal = useMemo(() => { + if (!isLocalTarget || !path) return undefined; + return getRecentDirs().find((d) => d.path === path)?.repoType; + }, [isLocalTarget, path]); + + const cached = fromDevice ?? cachedLocal; + + const shouldProbe = isDesktop && isLocalTarget && !!path && !cached; const { data: probed } = useSWR( shouldProbe ? ['detect-repo-type', path] : null, @@ -35,8 +55,8 @@ export const useRepoType = (path?: string): RepoType => { ); useEffect(() => { - if (path && probed !== undefined) setRecentDirRepoType(path, probed); - }, [path, probed]); + if (isLocalTarget && path && probed !== undefined) setRecentDirRepoType(path, probed); + }, [isLocalTarget, path, probed]); return cached ?? probed; }; diff --git a/src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts b/src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts deleted file mode 100644 index 4180bb4b9c..0000000000 --- a/src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback } from 'react'; - -import { lambdaQuery } from '@/libs/trpc/client'; - -import { nextWorkingDirs, type WorkingDirEntry } from './deviceCwd'; - -/** - * Persist a working-directory choice to a device's registry record - * (`defaultCwd` + `workingDirs`) with an **optimistic** update of the - * `listDevices` cache, so the picker reflects the pick instantly and the - * server's `device.defaultCwd` (read by the hetero device-dispatch branch) - * stays in sync. Rolls back on error. - */ -export const useUpdateDeviceCwd = () => { - const utils = lambdaQuery.useUtils(); - - const mutation = lambdaQuery.device.updateDevice.useMutation({ - onMutate: async ({ defaultCwd, deviceId, workingDirs }) => { - // Optimistic write: cancel in-flight refetches so they don't clobber it, - // then patch the touched device in place. onSettled re-fetches the truth - // afterwards (on both success and error), so a failed write self-corrects - // without a manual rollback. - await utils.device.listDevices.cancel(); - utils.device.listDevices.setData(undefined, (old) => { - if (!old) return old; - // `listDevices` returns a union (registered device | online-only ghost); - // spreading widens the touched item out of its branch, so re-assert the - // query's own element type rather than fight the literal union. - return old.map((device) => - device.deviceId === deviceId - ? { - ...device, - defaultCwd: defaultCwd ?? device.defaultCwd, - workingDirs: workingDirs ?? device.workingDirs, - } - : device, - ) as typeof old; - }); - }, - onSettled: () => utils.device.listDevices.invalidate(), - }); - - return useCallback( - ( - deviceId: string, - entry: WorkingDirEntry, - currentWorkingDirs: readonly WorkingDirEntry[] = [], - // Local-mode runs only want to record the dir in the working-dirs list, - // not repoint the device's default working directory. - options: { setDefault?: boolean } = {}, - ) => { - const trimmed = entry.path.trim(); - if (!trimmed) return; - const setDefault = options.setDefault ?? true; - return mutation.mutateAsync({ - ...(setDefault ? { defaultCwd: trimmed } : {}), - deviceId, - workingDirs: nextWorkingDirs(entry, currentWorkingDirs), - }); - }, - [mutation], - ); -}; diff --git a/src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts b/src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts deleted file mode 100644 index 7e807edbc7..0000000000 --- a/src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isDesktop } from '@lobechat/const'; - -import { useClientDataSWR } from '@/libs/swr'; -import { electronGitService } from '@/services/electron/git'; - -/** - * Working-tree dirty-file breakdown for the current cwd. - * Always-on (not gated by dropdown open state) so the status bar can show a - * +N ~M -K badge. Revalidates on window focus, throttled to 5s — git status - * is local & cheap, but we still don't need sub-second freshness. - */ -export const useWorkingTreeStatus = (dirPath?: string) => { - const key = isDesktop && dirPath ? ['git-working-tree-status', dirPath] : null; - - return useClientDataSWR(key, () => electronGitService.getGitWorkingTreeStatus(dirPath!), { - focusThrottleInterval: 5 * 1000, - revalidateOnFocus: true, - shouldRetryOnError: false, - }); -}; diff --git a/src/features/CreatePlatformAgent/index.tsx b/src/features/CreatePlatformAgent/index.tsx index deba085395..8956985cd1 100644 --- a/src/features/CreatePlatformAgent/index.tsx +++ b/src/features/CreatePlatformAgent/index.tsx @@ -20,7 +20,8 @@ import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { lambdaClient, lambdaQuery } from '@/libs/trpc/client'; +import { lambdaQuery } from '@/libs/trpc/client'; +import { deviceService } from '@/services/device'; import { useAgentStore } from '@/store/agent'; import { useHomeStore } from '@/store/home'; @@ -187,7 +188,7 @@ const CreatePlatformAgentModal = memo( setCheckingCapability(true); setCapabilityResult(undefined); try { - const result = await lambdaClient.device.checkCapability.query({ + const result = await deviceService.checkCapability({ deviceId: dId, platform, }); @@ -206,7 +207,7 @@ const CreatePlatformAgentModal = memo( setFetchingProfile(true); setAgentProfile(null); try { - const profile = await lambdaClient.device.getAgentProfile.query({ + const profile = await deviceService.getAgentProfile({ deviceId: dId, platform, }); diff --git a/src/helpers/agentWorkingDirectory.test.ts b/src/helpers/agentWorkingDirectory.test.ts new file mode 100644 index 0000000000..4e628a89a0 --- /dev/null +++ b/src/helpers/agentWorkingDirectory.test.ts @@ -0,0 +1,92 @@ +import type { LobeAgentAgencyConfig } from '@lobechat/types'; +import { describe, expect, it } from 'vitest'; + +import { resolveAgentWorkingDirectory, resolveTargetDeviceId } from './agentWorkingDirectory'; + +const cfg = (over: Partial = {}): LobeAgentAgencyConfig => ({ ...over }); + +describe('resolveTargetDeviceId', () => { + it('uses boundDeviceId when executionTarget is `device`', () => { + expect( + resolveTargetDeviceId(cfg({ boundDeviceId: 'dev-1', executionTarget: 'device' }), 'cur'), + ).toBe('dev-1'); + }); + + it('uses the current machine for non-device targets', () => { + expect(resolveTargetDeviceId(cfg({ executionTarget: 'local' }), 'cur')).toBe('cur'); + expect(resolveTargetDeviceId(cfg({ executionTarget: 'sandbox' }), 'cur')).toBe('cur'); + expect(resolveTargetDeviceId(undefined, 'cur')).toBe('cur'); + }); + + it('returns undefined when device target has no boundDeviceId', () => { + expect(resolveTargetDeviceId(cfg({ executionTarget: 'device' }), 'cur')).toBeUndefined(); + }); +}); + +describe('resolveAgentWorkingDirectory', () => { + it('follows precedence: topic > agentChoice > legacy > deviceDefault > fallback', () => { + const base = { + agencyConfig: cfg({ executionTarget: 'local', workingDirByDevice: { cur: '/agent' } }), + currentDeviceId: 'cur', + deviceDefaultCwd: '/device-default', + fallback: '/home', + legacyAgentWorkingDirectory: '/legacy', + topicWorkingDirectory: '/topic', + }; + expect(resolveAgentWorkingDirectory(base)).toBe('/topic'); + expect(resolveAgentWorkingDirectory({ ...base, topicWorkingDirectory: undefined })).toBe( + '/agent', + ); + expect( + resolveAgentWorkingDirectory({ + ...base, + agencyConfig: cfg({ executionTarget: 'local' }), + topicWorkingDirectory: undefined, + }), + ).toBe('/legacy'); + expect( + resolveAgentWorkingDirectory({ + ...base, + agencyConfig: cfg({ executionTarget: 'local' }), + legacyAgentWorkingDirectory: undefined, + topicWorkingDirectory: undefined, + }), + ).toBe('/device-default'); + expect( + resolveAgentWorkingDirectory({ + currentDeviceId: 'cur', + fallback: '/home', + }), + ).toBe('/home'); + }); + + it('keys the per-device choice by the bound device when target is `device`', () => { + const agencyConfig = cfg({ + boundDeviceId: 'dev-1', + executionTarget: 'device', + workingDirByDevice: { 'cur': '/local-choice', 'dev-1': '/remote-choice' }, + }); + // resolves the bound device's path, not the current machine's + expect(resolveAgentWorkingDirectory({ agencyConfig, currentDeviceId: 'cur' })).toBe( + '/remote-choice', + ); + }); + + it('ignores the per-device choice when the target device has no entry', () => { + const agencyConfig = cfg({ + executionTarget: 'local', + workingDirByDevice: { other: '/other-choice' }, + }); + expect( + resolveAgentWorkingDirectory({ + agencyConfig, + currentDeviceId: 'cur', + deviceDefaultCwd: '/device-default', + }), + ).toBe('/device-default'); + }); + + it('returns undefined when nothing is configured', () => { + expect(resolveAgentWorkingDirectory({})).toBeUndefined(); + }); +}); diff --git a/src/helpers/agentWorkingDirectory.ts b/src/helpers/agentWorkingDirectory.ts new file mode 100644 index 0000000000..6c7ad544df --- /dev/null +++ b/src/helpers/agentWorkingDirectory.ts @@ -0,0 +1,54 @@ +import type { LobeAgentAgencyConfig } from '@lobechat/types'; + +/** + * The device a run targets: an explicitly bound device, else this machine. + * Local execution treats the current machine as its own device, so local and + * remote share one resolution model. + */ +export const resolveTargetDeviceId = ( + agencyConfig: LobeAgentAgencyConfig | undefined, + currentDeviceId: string | undefined, +): string | undefined => + agencyConfig?.executionTarget === 'device' ? agencyConfig?.boundDeviceId : currentDeviceId; + +/** + * Unified working-directory precedence (mirrors the server's resolution): + * + * topic override + * > agent's per-device choice (`agencyConfig.workingDirByDevice[targetDeviceId]`) + * > legacy per-agent localStorage value (pre-migration fallback) + * > device default (`device.defaultCwd`) + * > caller fallback (e.g. home dir for in-process runs) + * + * The legacy slot keeps existing desktop users' selections working until they + * next pick a directory (which writes the new per-device map). + */ +export const resolveAgentWorkingDirectory = (params: { + agencyConfig?: LobeAgentAgencyConfig; + currentDeviceId?: string; + deviceDefaultCwd?: string; + fallback?: string; + legacyAgentWorkingDirectory?: string; + topicWorkingDirectory?: string; +}): string | undefined => { + const { + agencyConfig, + currentDeviceId, + deviceDefaultCwd, + fallback, + legacyAgentWorkingDirectory, + topicWorkingDirectory, + } = params; + const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId); + const agentChoice = targetDeviceId + ? agencyConfig?.workingDirByDevice?.[targetDeviceId] + : undefined; + return ( + topicWorkingDirectory || + agentChoice || + legacyAgentWorkingDirectory || + deviceDefaultCwd || + fallback || + undefined + ); +}; diff --git a/src/helpers/executionTarget.test.ts b/src/helpers/executionTarget.test.ts new file mode 100644 index 0000000000..f84f4d9080 --- /dev/null +++ b/src/helpers/executionTarget.test.ts @@ -0,0 +1,75 @@ +import type { LobeAgentAgencyConfig } from '@lobechat/types'; +import { describe, expect, it } from 'vitest'; + +import { + executionTargetToRuntimeMode, + resolveExecutionTarget, + resolveRuntimeMode, +} from './executionTarget'; + +const cfg = (over: Partial = {}): LobeAgentAgencyConfig => ({ ...over }); + +describe('resolveExecutionTarget', () => { + it('returns the stored target verbatim when set', () => { + expect(resolveExecutionTarget(cfg({ executionTarget: 'device' }), true)).toBe('device'); + expect(resolveExecutionTarget(cfg({ executionTarget: 'sandbox' }), true)).toBe('sandbox'); + }); + + it('defaults to local on desktop, none on web when unset', () => { + expect(resolveExecutionTarget(undefined, true)).toBe('local'); + expect(resolveExecutionTarget(undefined, false)).toBe('none'); + expect(resolveExecutionTarget(cfg(), true)).toBe('local'); + expect(resolveExecutionTarget(cfg(), false)).toBe('none'); + }); + + it('coerces a stored `local` to `sandbox` on web (no local filesystem)', () => { + expect(resolveExecutionTarget(cfg({ executionTarget: 'local' }), false)).toBe('sandbox'); + // …but keeps it on desktop + expect(resolveExecutionTarget(cfg({ executionTarget: 'local' }), true)).toBe('local'); + }); + + it('keeps `device` on web (a bound device is reachable from anywhere)', () => { + expect(resolveExecutionTarget(cfg({ executionTarget: 'device' }), false)).toBe('device'); + }); + + it('keeps an explicit `none` on both platforms', () => { + expect(resolveExecutionTarget(cfg({ executionTarget: 'none' }), true)).toBe('none'); + expect(resolveExecutionTarget(cfg({ executionTarget: 'none' }), false)).toBe('none'); + }); +}); + +describe('executionTargetToRuntimeMode', () => { + it('maps target → tool gate', () => { + expect(executionTargetToRuntimeMode('local')).toBe('local'); + expect(executionTargetToRuntimeMode('sandbox')).toBe('cloud'); + expect(executionTargetToRuntimeMode('device')).toBe('none'); + expect(executionTargetToRuntimeMode('none')).toBe('none'); + }); +}); + +describe('resolveRuntimeMode', () => { + it('honours the legacy runtimeMode when no executionTarget is set (no-regression)', () => { + expect(resolveRuntimeMode(undefined, 'cloud', false)).toBe('cloud'); + expect(resolveRuntimeMode(cfg(), 'none', false)).toBe('none'); + expect(resolveRuntimeMode(cfg(), 'local', true)).toBe('local'); + }); + + it('derives from the default target when neither executionTarget nor legacy is set', () => { + // desktop default → local + expect(resolveRuntimeMode(undefined, undefined, true)).toBe('local'); + // web default → none (an unconfigured web agent is plain chat, no run tools) + expect(resolveRuntimeMode(undefined, undefined, false)).toBe('none'); + }); + + it('lets an explicit executionTarget override the legacy runtimeMode', () => { + expect(resolveRuntimeMode(cfg({ executionTarget: 'sandbox' }), 'local', true)).toBe('cloud'); + expect(resolveRuntimeMode(cfg({ executionTarget: 'device' }), 'cloud', true)).toBe('none'); + expect(resolveRuntimeMode(cfg({ executionTarget: 'local' }), 'none', true)).toBe('local'); + expect(resolveRuntimeMode(cfg({ executionTarget: 'none' }), 'local', true)).toBe('none'); + }); + + it('applies the web `local`→`sandbox` coercion before mapping to runtime mode', () => { + // executionTarget=local synced from desktop, resolved on web → sandbox → cloud + expect(resolveRuntimeMode(cfg({ executionTarget: 'local' }), undefined, false)).toBe('cloud'); + }); +}); diff --git a/src/helpers/executionTarget.ts b/src/helpers/executionTarget.ts new file mode 100644 index 0000000000..20e6073b1d --- /dev/null +++ b/src/helpers/executionTarget.ts @@ -0,0 +1,61 @@ +import type { DeviceExecutionTarget, LobeAgentAgencyConfig, RuntimeEnvMode } from '@lobechat/types'; + +/** + * Single source of truth for where an agent executes. Replaces the old + * per-platform `chatConfig.runtimeEnv.runtimeMode` record — one global + * `agencyConfig.executionTarget` drives both desktop and web. + * + * - `none` → 无设备 (no execution environment; plain chat) + * - `local` → 本机 (this machine, in-process; desktop only) + * - `sandbox` → 云端沙箱 (server cloud sandbox) + * - `device` → 远程设备 (dispatched to `boundDeviceId`) + * + * Defaults: desktop → `local`, web → `none`. On web `local` isn't available + * (no local filesystem), so a stored `local` (synced from desktop) resolves to + * `sandbox`. + */ +export const resolveExecutionTarget = ( + agencyConfig: LobeAgentAgencyConfig | undefined, + isDesktop: boolean, +): DeviceExecutionTarget => { + const stored = agencyConfig?.executionTarget; + const effective = stored ?? (isDesktop ? 'local' : 'none'); + if (!isDesktop && effective === 'local') return 'sandbox'; + return effective; +}; + +/** + * Derive the legacy `runtimeMode` (still used by the server tool gate) from the + * unified execution target: `local` → local-system tools, `sandbox` → cloud + * sandbox, `device` → gateway-dispatched tools, `none` → no run tools (plain + * chat). `device`/`none` both gate to `'none'` — device tools are routed + * separately via `executionTarget === 'device'` + `boundDeviceId`. + */ +export const executionTargetToRuntimeMode = (target: DeviceExecutionTarget): RuntimeEnvMode => { + switch (target) { + case 'local': { + return 'local'; + } + case 'sandbox': { + return 'cloud'; + } + default: { + return 'none'; + } + } +}; + +/** + * The effective `runtimeMode` (server tool gate) from the unified execution + * target, with a no-regression fallback: agents that predate `executionTarget` + * still honour their legacy per-platform `runtimeMode` until migrated. New + * writes set `executionTarget`, so this fallback fades out over time. + */ +export const resolveRuntimeMode = ( + agencyConfig: LobeAgentAgencyConfig | undefined, + legacyRuntimeMode: RuntimeEnvMode | undefined, + isDesktop: boolean, +): RuntimeEnvMode => { + if (!agencyConfig?.executionTarget && legacyRuntimeMode) return legacyRuntimeMode; + return executionTargetToRuntimeMode(resolveExecutionTarget(agencyConfig, isDesktop)); +}; diff --git a/src/hooks/useEffectiveWorkingDirectory.ts b/src/hooks/useEffectiveWorkingDirectory.ts new file mode 100644 index 0000000000..74d80f6b8b --- /dev/null +++ b/src/hooks/useEffectiveWorkingDirectory.ts @@ -0,0 +1,53 @@ +import { isDesktop } from '@lobechat/const'; + +import { + resolveAgentWorkingDirectory, + resolveTargetDeviceId, +} from '@/helpers/agentWorkingDirectory'; +import { globalAgentContextManager } from '@/helpers/GlobalAgentContextManager'; +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; +import { deviceSelectors, useDeviceStore } from '@/store/device'; +import { useElectronStore } from '@/store/electron'; + +/** + * The agent's effective working directory under the unified precedence: + * + * topic override > agent's per-device choice > legacy localStorage > device + * default > home (desktop only). + * + * Combines the agent store (agencyConfig + legacy map), chat store (topic cwd), + * device store (defaultCwd) and the current machine's deviceId. Use this instead + * of the old `topicCwd || agentCwd` pattern so local and remote resolve the same + * way. Returns `undefined` only on web with nothing configured. + */ +export const useEffectiveWorkingDirectory = (agentId?: string): string | undefined => { + // Self-populate the device store (SWR dedupes by key across all callers). + useDeviceStore((s) => s.useFetchDevices)(); + + const agencyConfig = useAgentStore((s) => + agentId ? agentByIdSelectors.getAgencyConfigById(agentId)(s) : undefined, + ); + const legacyAgentWorkingDirectory = useAgentStore((s) => + agentId ? s.localAgentWorkingDirectoryMap[agentId] : undefined, + ); + const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); + const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId); + const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId); + const deviceDefaultCwd = useDeviceStore(deviceSelectors.getDeviceDefaultCwd(targetDeviceId)); + + // Home is the last-resort default, desktop-only (matches the legacy selector). + const ctx = isDesktop ? globalAgentContextManager.getContext() : undefined; + const fallback = ctx?.desktopPath ?? ctx?.homePath; + + return resolveAgentWorkingDirectory({ + agencyConfig, + currentDeviceId, + deviceDefaultCwd, + fallback, + legacyAgentWorkingDirectory, + topicWorkingDirectory, + }); +}; diff --git a/src/hooks/useRemoteAgentDeviceGuard.ts b/src/hooks/useRemoteAgentDeviceGuard.ts index 7c4ec11c81..8c28e0afd0 100644 --- a/src/hooks/useRemoteAgentDeviceGuard.ts +++ b/src/hooks/useRemoteAgentDeviceGuard.ts @@ -1,7 +1,7 @@ import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents'; import { useCallback, useEffect, useState } from 'react'; -import { lambdaClient } from '@/libs/trpc/client'; +import { deviceService } from '@/services/device'; import { useAgentStore } from '@/store/agent'; export type RemoteAgentDeviceStatus = @@ -48,7 +48,7 @@ export const useRemoteAgentDeviceGuard = ({ setStatus('checking'); try { - const devices = await lambdaClient.device.listDevices.query(); + const devices = await deviceService.listDevices(); const device = devices.find((d) => d.deviceId === boundDeviceId); if (!device || !device.online) { @@ -57,7 +57,7 @@ export const useRemoteAgentDeviceGuard = ({ } if (providerType && isRemoteHeterogeneousType(providerType)) { - const capability = await lambdaClient.device.checkCapability.query({ + const capability = await deviceService.checkCapability({ deviceId: boundDeviceId, platform: providerType, }); diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index cb1b145b8c..483e23bc56 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -176,17 +176,21 @@ export default { 'heteroAgent.cloudRepo.noRepos': 'No repositories configured. Add them in agent settings.', 'heteroAgent.cloudRepo.multiSelected': '{{count}} repos selected', 'heteroAgent.executionTarget.infoTooltip': - 'Pick a remote device to drive that machine from the web. "This device" runs the agent locally and is only available inside the desktop app.', + 'Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.', 'heteroAgent.executionTarget.loading': 'Loading devices…', 'heteroAgent.executionTarget.local': 'This device', 'heteroAgent.executionTarget.localDesc': 'Run as a local process on this desktop app', 'heteroAgent.executionTarget.noDevices': - 'No remote devices yet. Install the desktop app or run `lh connect` on another machine.', + 'No remote devices yet. Run `lh connect` on another machine to add one.', + 'heteroAgent.executionTarget.none': 'No device', + 'heteroAgent.executionTarget.noneDesc': 'No device enabled', 'heteroAgent.executionTarget.offline': 'Offline', 'heteroAgent.executionTarget.online': 'Online', - 'heteroAgent.executionTarget.sandbox': 'Cloud sandbox', + 'heteroAgent.executionTarget.sandbox': 'Cloud Sandbox', 'heteroAgent.executionTarget.sandboxDesc': 'Run in an ephemeral cloud sandbox', 'heteroAgent.executionTarget.downloadDesktop': 'Get Desktop App', + 'heteroAgent.executionTarget.downloadDesktopDesc': 'Run agents with access to your computer', + 'heteroAgent.executionTarget.downloadDesktopTitle': 'Get the desktop app', 'heteroAgent.executionTarget.title': 'Execution Device', 'heteroAgent.executionTarget.unknownDevice': 'Unknown device', 'hideForYou': diff --git a/src/locales/default/device.ts b/src/locales/default/device.ts new file mode 100644 index 0000000000..6eaa3d5b89 --- /dev/null +++ b/src/locales/default/device.ts @@ -0,0 +1,60 @@ +export default { + 'workingDirectory.addFolder': 'Add folder…', + 'workingDirectory.addFolderDesc': + 'Enter an absolute path on the target device, e.g. /Users/name/projects', + 'workingDirectory.addFolderTitle': 'Add working directory', + 'workingDirectory.agentDescription': + 'Default working directory for all conversations with this Agent', + 'workingDirectory.agentLevel': 'Agent Working Directory', + 'workingDirectory.branchSearchPlaceholder': 'Search branches', + 'workingDirectory.branchesEmpty': 'No local branches', + 'workingDirectory.branchesHeading': 'Branches', + 'workingDirectory.branchesLoading': 'Loading branches…', + 'workingDirectory.branchesNoMatch': 'No matching branches', + 'workingDirectory.cancel': 'Cancel', + 'workingDirectory.checkoutAction': 'Checkout', + 'workingDirectory.checkoutFailed': 'Checkout failed', + 'workingDirectory.chooseDifferentFolder': 'Choose a folder...', + 'workingDirectory.clear': 'Clear', + 'workingDirectory.createBranchAction': 'Checkout new branch…', + 'workingDirectory.createBranchTitle': 'Create new branch', + 'workingDirectory.current': 'Current working directory', + 'workingDirectory.detachedHead': 'Detached HEAD at {{sha}}', + 'workingDirectory.diffStatTooltip': + 'Added {{added}} · Modified {{modified}} · Deleted {{deleted}}', + 'workingDirectory.filesAdded': 'Added', + 'workingDirectory.filesDeleted': 'Deleted', + 'workingDirectory.filesEmpty': 'No uncommitted changes', + 'workingDirectory.filesLoading': 'Loading changes…', + 'workingDirectory.filesModified': 'Modified', + 'workingDirectory.ghMissing': + 'Install and log in to the GitHub CLI (`gh`) to see linked pull requests', + 'workingDirectory.newBranchPlaceholder': 'feature/new-branch-name', + 'workingDirectory.noRecent': 'No recent directories', + 'workingDirectory.notSet': 'Click to set working directory', + 'workingDirectory.pathNotDirectory': 'This path is not a directory', + 'workingDirectory.pathNotExist': "This path doesn't exist on the device", + 'workingDirectory.placeholder': 'Enter directory path, e.g. /Users/name/projects', + 'workingDirectory.prTooltipWithExtra': '{{title}} (+{{count}} more open PR on this branch)', + 'workingDirectory.pullAction': 'Click to pull {{count}} commit(s) from {{upstream}}', + 'workingDirectory.pullFailed': 'Pull failed', + 'workingDirectory.pullInProgress': 'Pulling…', + 'workingDirectory.pullNoop': 'Already up to date', + 'workingDirectory.pullSuccess': 'Pulled successfully', + 'workingDirectory.pushAction': 'Click to push {{count}} commit(s) to {{target}}', + 'workingDirectory.pushActionNew': 'Click to create branch {{target}}', + 'workingDirectory.pushFailed': 'Push failed', + 'workingDirectory.pushInProgress': 'Pushing…', + 'workingDirectory.pushNoop': 'Everything up-to-date', + 'workingDirectory.pushSuccess': 'Pushed successfully', + 'workingDirectory.recent': 'Recent', + 'workingDirectory.refreshGitStatus': 'Refresh branch & PR status', + 'workingDirectory.removeRecent': 'Remove from recent', + 'workingDirectory.selectFolder': 'Select folder', + 'workingDirectory.title': 'Working Directory', + 'workingDirectory.topicDescription': 'Override Agent default for this conversation only', + 'workingDirectory.topicLevel': 'Conversation override', + 'workingDirectory.topicOverride': 'Override for this conversation', + 'workingDirectory.uncommittedChanges_one': 'Uncommitted changes: {{count}} file', + 'workingDirectory.uncommittedChanges_other': 'Uncommitted changes: {{count}} files', +}; diff --git a/src/locales/default/index.ts b/src/locales/default/index.ts index d8368f093e..98e785faa7 100644 --- a/src/locales/default/index.ts +++ b/src/locales/default/index.ts @@ -8,6 +8,7 @@ import color from './color'; import common from './common'; import components from './components'; import desktopOnboarding from './desktop-onboarding'; +import device from './device'; import discover from './discover'; import editor from './editor'; import electron from './electron'; @@ -60,6 +61,7 @@ const resources = { common, components, 'desktop-onboarding': desktopOnboarding, + device, discover, editor, electron, diff --git a/src/locales/default/labs.ts b/src/locales/default/labs.ts index 1ede3f7a81..010d09912b 100644 --- a/src/locales/default/labs.ts +++ b/src/locales/default/labs.ts @@ -8,9 +8,6 @@ export default { 'features.assistantMessageGroup.desc': 'Group agent messages and their tool call results together for display', 'features.assistantMessageGroup.title': 'Agent Message Grouping', - 'features.executionDeviceSwitcher.desc': - 'Surface the execution-device switcher in the heterogeneous agent toolbar so you can route runs to this device, a cloud sandbox, or a bound remote device.', - 'features.executionDeviceSwitcher.title': 'Execution Device Switcher', 'features.gatewayMode.desc': 'Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.', 'features.gatewayMode.title': 'Server-Side Agent Execution (Gateway)', diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index fc80618733..fc27dcf864 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -572,59 +572,6 @@ export default { 'list.item.local.title': 'Custom', 'loading.content': 'Calling Skill…', 'loading.plugin': 'Skill running…', - 'localSystem.workingDirectory.agentDescription': - 'Default working directory for all conversations with this Agent', - 'localSystem.workingDirectory.agentLevel': 'Agent Working Directory', - 'localSystem.workingDirectory.branchesEmpty': 'No local branches', - 'localSystem.workingDirectory.branchesHeading': 'Branches', - 'localSystem.workingDirectory.branchesLoading': 'Loading branches…', - 'localSystem.workingDirectory.branchesNoMatch': 'No matching branches', - 'localSystem.workingDirectory.branchSearchPlaceholder': 'Search branches', - 'localSystem.workingDirectory.cancel': 'Cancel', - 'localSystem.workingDirectory.clear': 'Clear', - 'localSystem.workingDirectory.checkoutAction': 'Checkout', - 'localSystem.workingDirectory.checkoutFailed': 'Checkout failed', - 'localSystem.workingDirectory.createBranchAction': 'Checkout new branch…', - 'localSystem.workingDirectory.current': 'Current working directory', - 'localSystem.workingDirectory.chooseDifferentFolder': 'Choose a folder...', - 'localSystem.workingDirectory.pullAction': 'Click to pull {{count}} commit(s) from {{upstream}}', - 'localSystem.workingDirectory.pullInProgress': 'Pulling…', - 'localSystem.workingDirectory.pullSuccess': 'Pulled successfully', - 'localSystem.workingDirectory.pullNoop': 'Already up to date', - 'localSystem.workingDirectory.pullFailed': 'Pull failed', - 'localSystem.workingDirectory.pushAction': 'Click to push {{count}} commit(s) to {{target}}', - 'localSystem.workingDirectory.pushActionNew': 'Click to create branch {{target}}', - 'localSystem.workingDirectory.pushInProgress': 'Pushing…', - 'localSystem.workingDirectory.pushSuccess': 'Pushed successfully', - 'localSystem.workingDirectory.pushNoop': 'Everything up-to-date', - 'localSystem.workingDirectory.pushFailed': 'Push failed', - 'localSystem.workingDirectory.detachedHead': 'Detached HEAD at {{sha}}', - 'localSystem.workingDirectory.diffStatTooltip': - 'Added {{added}} · Modified {{modified}} · Deleted {{deleted}}', - 'localSystem.workingDirectory.filesAdded': 'Added', - 'localSystem.workingDirectory.filesDeleted': 'Deleted', - 'localSystem.workingDirectory.filesEmpty': 'No uncommitted changes', - 'localSystem.workingDirectory.filesLoading': 'Loading changes…', - 'localSystem.workingDirectory.filesModified': 'Modified', - 'localSystem.workingDirectory.ghMissing': - 'Install and log in to the GitHub CLI (`gh`) to see linked pull requests', - 'localSystem.workingDirectory.newBranchPlaceholder': 'feature/new-branch-name', - 'localSystem.workingDirectory.noRecent': 'No recent directories', - 'localSystem.workingDirectory.notSet': 'Click to set working directory', - 'localSystem.workingDirectory.placeholder': 'Enter directory path, e.g. /Users/name/projects', - 'localSystem.workingDirectory.prTooltipWithExtra': - '{{title}} (+{{count}} more open PR on this branch)', - 'localSystem.workingDirectory.recent': 'Recent', - 'localSystem.workingDirectory.refreshGitStatus': 'Refresh branch & PR status', - 'localSystem.workingDirectory.removeRecent': 'Remove from recent', - 'localSystem.workingDirectory.selectFolder': 'Select folder', - 'localSystem.workingDirectory.title': 'Working Directory', - 'localSystem.workingDirectory.topicDescription': - 'Override Agent default for this conversation only', - 'localSystem.workingDirectory.topicLevel': 'Conversation override', - 'localSystem.workingDirectory.topicOverride': 'Override for this conversation', - 'localSystem.workingDirectory.uncommittedChanges_one': 'Uncommitted changes: {{count}} file', - 'localSystem.workingDirectory.uncommittedChanges_other': 'Uncommitted changes: {{count}} files', 'mcpEmpty.deployment': 'No deployment options', 'mcpEmpty.prompts': 'No prompts', 'mcpEmpty.resources': 'No resources', diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx index 8f4ca3a819..8f3d4633e1 100644 --- a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx @@ -1,58 +1,22 @@ 'use client'; import { isDesktop } from '@lobechat/const'; -import { Github } from '@lobehub/icons'; -import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui'; +import { Flexbox, Icon, Skeleton, Tooltip } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; -import { - ChevronDownIcon, - CircleAlertIcon, - FolderIcon, - GitBranchIcon, - SquircleDashed, -} from 'lucide-react'; -import { memo, type ReactNode, useMemo, useState } from 'react'; +import { CircleAlertIcon } from 'lucide-react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useAgentId } from '@/features/ChatInput/hooks/useAgentId'; -import CloudRepoSwitcher from '@/features/ChatInput/RuntimeConfig/CloudRepoSwitcher'; -import DeviceWorkingDirectory from '@/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory'; -import GitStatus from '@/features/ChatInput/RuntimeConfig/GitStatus'; -import HeteroDeviceSwitcher from '@/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher'; -import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType'; -import WorkingDirectoryContent from '@/features/ChatInput/RuntimeConfig/WorkingDirectory'; +import WorkspaceControls from '@/features/ChatInput/RuntimeConfig/WorkspaceControls'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; -import { useUserStore } from '@/store/user'; -import { labPreferSelectors } from '@/store/user/selectors'; const styles = createStaticStyles(({ css }) => ({ bar: css` padding-block: 0; padding-inline: 4px; `, - button: css` - cursor: pointer; - - display: flex; - gap: 6px; - align-items: center; - - padding-block: 2px; - padding-inline: 4px; - border-radius: 4px; - - font-size: 12px; - color: ${cssVar.colorTextSecondary}; - - transition: background 0.2s; - - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, fullAccess: css` cursor: default; @@ -70,49 +34,20 @@ const styles = createStaticStyles(({ css }) => ({ })); const WorkingDirectoryBar = memo(() => { - const { t } = useTranslation('plugin'); const { t: tChat } = useTranslation('chat'); const agentId = useAgentId(); - const [open, setOpen] = useState(false); // All hooks must be called unconditionally (Rules of Hooks) const isLoading = useAgentStore(agentByIdSelectors.isAgentConfigLoadingById(agentId)); - const agentWorkingDirectory = useAgentStore((s) => - agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined, - ); - const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); - const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory; - const enableExecutionDeviceSwitcher = useUserStore( - labPreferSelectors.enableExecutionDeviceSwitcher, - ); - const agencyConfig = useAgentStore((s) => - agentId ? agentByIdSelectors.getAgencyConfigById(agentId)(s) : undefined, - ); - // Runs dispatched to a remote device can't browse the local filesystem — use - // the device-scoped picker (recent dirs + manual input) instead. - const isDeviceMode = agencyConfig?.executionTarget === 'device' && !!agencyConfig?.boundDeviceId; - const repoType = useRepoType(effectiveWorkingDirectory); - - const dirIconNode = useMemo((): ReactNode => { - if (!effectiveWorkingDirectory) return ; - if (repoType === 'github') return ; - if (repoType === 'git') return ; - return ; - }, [effectiveWorkingDirectory, repoType]); - - // On web, show the cloud repo switcher instead of the local directory picker + // On web there's no full-access badge / skeleton — just the workspace controls + // (the cloud repo switcher is rendered inside WorkspaceControls). if (!isDesktop) { if (!agentId) return null; return ( - {enableExecutionDeviceSwitcher && } - {isDeviceMode ? ( - - ) : ( - - )} + ); @@ -127,18 +62,6 @@ const WorkingDirectoryBar = memo(() => { ); } - const displayName = effectiveWorkingDirectory - ? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory - : t('localSystem.workingDirectory.notSet'); - - const dirButton = ( -
- {dirIconNode} - {displayName} - -
- ); - const fullAccessBadge = (
@@ -149,39 +72,7 @@ const WorkingDirectoryBar = memo(() => { return ( - {enableExecutionDeviceSwitcher && } - {isDeviceMode ? ( - // A remote device's filesystem isn't browsable from here — use the - // device-scoped picker (recent dirs + manual input) instead of the - // local folder picker + git status. - - ) : ( - <> - setOpen(false)} />} - open={open} - placement="bottomLeft" - styles={{ content: { padding: 4 } }} - trigger="click" - onOpenChange={setOpen} - > -
- {open ? ( - dirButton - ) : ( - - {dirButton} - - )} -
-
- {effectiveWorkingDirectory && repoType && ( - - )} - - )} +
{fullAccessBadge}
diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/index.tsx index 4f0d42798d..e3435bf7ed 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/index.tsx @@ -21,6 +21,13 @@ import { buildGitStatusEntries, useGitWorkingTreeFiles } from './useGitWorkingTr import { useProjectFiles } from './useProjectFiles'; interface FilesProps { + /** + * Target device the working directory lives on. Undefined for local desktop; + * set for a remote / web-bound device so the tree + git status route through + * the device RPCs. OS-level actions (open in app / reveal in Finder) are + * hidden for remote — there's no local filesystem to act on. + */ + deviceId?: string; workingDirectory: string; } @@ -101,10 +108,15 @@ const getAncestorIds = (filePath: string): string[] => { return ancestors; }; -const Files = memo(({ workingDirectory }) => { +const Files = memo(({ deviceId, workingDirectory }) => { const { t } = useTranslation('chat'); - const { data, isLoading, isValidating, mutate } = useProjectFiles(workingDirectory); - const { data: gitFiles } = useGitWorkingTreeFiles(workingDirectory, data?.source === 'git'); + const isRemote = !!deviceId; + const { data, isLoading, isValidating, mutate } = useProjectFiles(deviceId, workingDirectory); + const { data: gitFiles } = useGitWorkingTreeFiles( + deviceId, + workingDirectory, + data?.source === 'git', + ); const projectRoot = data?.root ?? workingDirectory; const entries = useMemo(() => data?.entries ?? [], [data]); @@ -170,10 +182,12 @@ const Files = memo(({ workingDirectory }) => { const handleNodeClick = useCallback( (node: ExplorerTreeNode) => { - if (node.isFolder) return; + // Folders expand via the tree; files open the local viewer (local only — + // a remote device has no filesystem to open here). + if (node.isFolder || isRemote) return; openNode(node); }, - [openNode], + [isRemote, openNode], ); const getContextMenuItems = useCallback( @@ -183,28 +197,39 @@ const Files = memo(({ workingDirectory }) => { const { path, relativePath } = node.data; const isDirty = dirtyFilePaths.has(relativePath); + // OS-level actions (open in app / reveal in Finder) only work on the local + // machine — omit them for a remote device. + const localActions: MenuProps['items'] = isRemote + ? [] + : [ + { + key: 'open', + label: t('workingPanel.files.open'), + onClick: () => openNode(node), + }, + { key: 'divider-reveal', type: 'divider' as const }, + { + key: 'show-in-system', + label: t('workingPanel.files.showInSystem'), + onClick: () => void localFileService.openFileFolder(path), + }, + ]; + + const reviewActions: MenuProps['items'] = isDirty + ? [ + { + key: 'show-in-review', + label: t('workingPanel.files.showInReview'), + onClick: () => setWorkingSidebarTab('review'), + }, + ] + : []; + + const before = [...localActions, ...reviewActions]; + return [ - { - key: 'open', - label: t('workingPanel.files.open'), - onClick: () => openNode(node), - }, - { key: 'divider-reveal', type: 'divider' as const }, - { - key: 'show-in-system', - label: t('workingPanel.files.showInSystem'), - onClick: () => void localFileService.openFileFolder(path), - }, - ...(isDirty - ? [ - { - key: 'show-in-review', - label: t('workingPanel.files.showInReview'), - onClick: () => setWorkingSidebarTab('review'), - }, - ] - : []), - { key: 'divider-copy', type: 'divider' as const }, + ...before, + ...(before.length > 0 ? [{ key: 'divider-copy', type: 'divider' as const }] : []), { key: 'copy-absolute-path', label: t('workingPanel.files.copyAbsolutePath'), @@ -223,7 +248,7 @@ const Files = memo(({ workingDirectory }) => { }, ]; }, - [dirtyFilePaths, openNode, setWorkingSidebarTab, t], + [dirtyFilePaths, isRemote, openNode, setWorkingSidebarTab, t], ); const fileCount = data?.totalCount ?? entries.filter((e) => !e.isDirectory).length; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useGitWorkingTreeFiles.ts b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useGitWorkingTreeFiles.ts index b0747339b9..720236dfa0 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useGitWorkingTreeFiles.ts +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useGitWorkingTreeFiles.ts @@ -3,7 +3,7 @@ import type { GitWorkingTreeFiles } from '@lobechat/electron-client-ipc'; import type { GitStatusEntry } from '@pierre/trees'; import { useClientDataSWR } from '@/libs/swr'; -import { electronGitService } from '@/services/electron/git'; +import { gitService } from '@/services/git'; export const buildGitStatusEntries = (files: GitWorkingTreeFiles | undefined): GitStatusEntry[] => { if (!files) return []; @@ -15,12 +15,22 @@ export const buildGitStatusEntries = (files: GitWorkingTreeFiles | undefined): G ]; }; -export const useGitWorkingTreeFiles = (dirPath: string | undefined, enabled: boolean) => { - const key = isDesktop && dirPath && enabled ? ['git-working-tree-files', dirPath] : null; +/** + * Dirty working-tree files for the git-status overlay. Transport-agnostic via + * `gitService` (Electron IPC local / `device.*` RPC remote). Disabled until a + * `dirPath` + `enabled`, and on web until a `deviceId` is present too. + */ +export const useGitWorkingTreeFiles = ( + deviceId: string | undefined, + dirPath: string | undefined, + enabled: boolean, +) => { + const active = (!!deviceId || isDesktop) && !!dirPath && enabled; + const key = active ? ['git-working-tree-files', deviceId ?? 'local', dirPath] : null; - return useClientDataSWR( + return useClientDataSWR( key, - () => electronGitService.getGitWorkingTreeFiles(dirPath!), + () => gitService.getGitWorkingTreeFiles({ deviceId, path: dirPath! }), { focusThrottleInterval: 5 * 1000, revalidateOnFocus: true, diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useProjectFiles.ts b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useProjectFiles.ts index 719dd720c3..1ead6c3c45 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useProjectFiles.ts +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/useProjectFiles.ts @@ -2,15 +2,21 @@ import { isDesktop } from '@lobechat/const'; import type { ProjectFileIndexResult } from '@lobechat/electron-client-ipc'; import { useClientDataSWR } from '@/libs/swr'; -import { localFileService } from '@/services/electron/localFileService'; +import { projectFileService } from '@/services/projectFile'; -export const useProjectFiles = (dirPath: string | undefined) => { - const enabled = isDesktop && Boolean(dirPath); - const key = enabled ? ['project-file-index', dirPath] : null; +/** + * Project file tree for a working directory. Transport-agnostic: `fileService` + * dispatches Electron IPC (local) vs `device.getProjectFileIndex` RPC (remote, + * `deviceId` set). Disabled until a `dirPath` is available, and on web (no + * `isDesktop`) until a `deviceId` is too. + */ +export const useProjectFiles = (deviceId: string | undefined, dirPath: string | undefined) => { + const enabled = Boolean(dirPath) && (!!deviceId || isDesktop); + const key = enabled ? ['project-file-index', deviceId ?? 'local', dirPath] : null; - return useClientDataSWR( + return useClientDataSWR( key, - () => localFileService.getProjectFileIndex({ scope: dirPath! }), + () => projectFileService.getProjectFileIndex({ deviceId, scope: dirPath! }), { focusThrottleInterval: 30 * 1000, revalidateOnFocus: true, diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileItem.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileItem.tsx index d7bc24d54f..bbe9cd039e 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileItem.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileItem.tsx @@ -10,7 +10,7 @@ import { memo, type MouseEvent, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { message } from '@/components/AntdStaticMethods'; -import { electronGitService } from '@/services/electron/git'; +import { gitService } from '@/services/git'; import { useGlobalStore } from '@/store/global'; const styles = createStaticStyles(({ css, cssVar }) => ({ @@ -122,7 +122,7 @@ interface FileItemHeaderProps { /** Called after a successful revert so the parent can refresh the patch list. */ onReverted?: () => void; /** When provided, enables the per-file revert button (unstaged mode only). */ - revertContext?: { workingDirectory: string }; + revertContext?: { deviceId?: string; workingDirectory: string }; // Status reserved for future use (e.g. dim deleted entries) — keep on the // shape so the parent doesn't need to re-derive it later. status: GitFileDiffStatus; @@ -162,7 +162,8 @@ export const FileItemHeader = memo( if (!revertContext) return; setReverting(true); try { - const result = await electronGitService.revertGitFile({ + const result = await gitService.revertGitFile({ + deviceId: revertContext.deviceId, filePath, path: revertContext.workingDirectory, }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileRow.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileRow.tsx index 07f80cc719..7e012e2f2d 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileRow.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/FileRow.tsx @@ -55,6 +55,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ })); interface FileRowProps { + /** Target device the repo lives on — undefined for local desktop. */ + deviceId?: string; entry: GitWorkingTreePatch; expanded: boolean; mode: ReviewMode; @@ -71,6 +73,7 @@ interface FileRowProps { const FileRow = memo( ({ + deviceId, entry, expanded, mode, @@ -110,8 +113,10 @@ const FileRow = memo( additions={entry.additions} deletions={entry.deletions} filePath={entry.filePath} - revertContext={mode === 'unstaged' ? { workingDirectory: repoAbsolutePath } : undefined} status={entry.status} + revertContext={ + mode === 'unstaged' ? { deviceId, workingDirectory: repoAbsolutePath } : undefined + } onReverted={onReverted} />
diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/__tests__/FileItem.test.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/__tests__/FileItem.test.tsx index d3ad264542..2d675c03a1 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/__tests__/FileItem.test.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/__tests__/FileItem.test.tsx @@ -20,8 +20,8 @@ vi.mock('@/components/AntdStaticMethods', () => ({ message: { error: vi.fn(), success: vi.fn() }, })); -vi.mock('@/services/electron/git', () => ({ - electronGitService: { revertGitFile: vi.fn() }, +vi.mock('@/services/git', () => ({ + gitService: { revertGitFile: vi.fn() }, })); describe('FileItemHeader — reveal in tree', () => { diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/index.tsx index 3f4db8417e..ce0e07c13b 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Review/index.tsx @@ -22,8 +22,8 @@ import { Fragment, memo, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import NeuralNetworkLoading from '@/components/NeuralNetworkLoading'; -import { useGitInfo } from '@/features/ChatInput/RuntimeConfig/useGitInfo'; import { useLocalStorageState } from '@/hooks/useLocalStorageState'; +import { useFetchGitInfo } from '@/store/device'; import FileRow from './FileRow'; import GroupHeader from './GroupHeader'; @@ -45,6 +45,11 @@ const REVIEW_MODE_STORAGE_KEY = 'lobechat-review-mode'; const BASE_REF_OVERRIDES_STORAGE_KEY = 'lobechat-review-base-overrides'; interface ReviewProps { + /** + * Target device the working directory lives on. Undefined for local desktop; + * set for a remote / web-bound device so git ops route through the device RPCs. + */ + deviceId?: string; workingDirectory: string; } @@ -193,7 +198,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ `, })); -const Review = memo(({ workingDirectory }) => { +const Review = memo(({ deviceId, workingDirectory }) => { const { t } = useTranslation('chat'); const [mode, setMode] = useLocalStorageState(REVIEW_MODE_STORAGE_KEY, 'unstaged'); // Per-repo base-ref override — when set, the branch diff compares against @@ -217,12 +222,14 @@ const Review = memo(({ workingDirectory }) => { workingDirectory, mode, baseOverride, + deviceId, ); // Lazy: only fetch remote branches list once the user opens the picker. const [basePickerOpen, setBasePickerOpen] = useState(false); const { data: remoteBranches } = useGitRemoteBranches( workingDirectory, mode === 'branch' && basePickerOpen, + deviceId, ); // Memo-stabilise the fallback so downstream useMemo deps don't flap on // every render while the SWR result is undefined. @@ -232,8 +239,10 @@ const Review = memo(({ workingDirectory }) => { const headRef = data?.mode === 'branch' ? data.headRef : undefined; // Parent branch — only needed for the group header label, so we only fetch // it when there's at least one submodule group to render alongside it. - // SWR-deduped under the hood by `useGitInfo`'s own cache key. - const { data: parentGitInfo } = useGitInfo( + // SWR-deduped under the hood by `useFetchGitInfo`'s own cache key. Routes + // through the target device so remote repos resolve the same way. + const { data: parentGitInfo } = useFetchGitInfo( + deviceId, submoduleGroups.length > 0 ? workingDirectory : undefined, ); const [viewMode, setViewMode] = useLocalStorageState<'unified' | 'split'>( @@ -572,6 +581,7 @@ const Review = memo(({ workingDirectory }) => { const expanded = activeKeys.includes(key); return ( => { - const result = await electronGitService.getGitWorkingTreePatches(dirPath); - return { mode: 'unstaged', patches: result.patches, submodules: result.submodules }; +const fetchUnstaged = async ( + dirPath: string, + deviceId: string | undefined, +): Promise => { + const result = await gitService.getGitWorkingTreePatches({ deviceId, path: dirPath }); + return { mode: 'unstaged', patches: result?.patches ?? [], submodules: result?.submodules }; }; const fetchBranch = async ( dirPath: string, baseRef: string | undefined, + deviceId: string | undefined, ): Promise => { - const result = await electronGitService.getGitBranchDiff({ baseRef, path: dirPath }); + const result = await gitService.getGitBranchDiff({ baseRef, deviceId, path: dirPath }); return { - baseRef: result.baseRef, - headRef: result.headRef, + baseRef: result?.baseRef, + headRef: result?.headRef, mode: 'branch', - patches: result.patches, - submodules: result.submodules, + patches: result?.patches ?? [], + submodules: result?.submodules, }; }; @@ -55,13 +58,19 @@ export const useReviewPatches = ( dirPath: string | undefined, mode: ReviewMode, baseRef?: string, + deviceId?: string, ) => { - const enabled = isDesktop && Boolean(dirPath); - const key = enabled ? ['git-review-patches', dirPath, mode, baseRef ?? ''] : null; + const enabled = Boolean(dirPath); + const key = enabled + ? ['git-review-patches', deviceId ?? 'local', dirPath, mode, baseRef ?? ''] + : null; return useClientDataSWR( key, - () => (mode === 'branch' ? fetchBranch(dirPath!, baseRef) : fetchUnstaged(dirPath!)), + () => + mode === 'branch' + ? fetchBranch(dirPath!, baseRef, deviceId) + : fetchUnstaged(dirPath!, deviceId), { focusThrottleInterval: mode === 'branch' ? 30 * 1000 : 5 * 1000, revalidateOnFocus: true, @@ -76,10 +85,18 @@ export const useReviewPatches = ( * Review panel. Stays disabled until `enabled` flips true so we don't fork * a `git for-each-ref` until the user actually opens the dropdown. */ -export const useGitRemoteBranches = (dirPath: string | undefined, enabled: boolean) => { - const key = isDesktop && dirPath && enabled ? ['git-remote-branches', dirPath] : null; - return useClientDataSWR(key, () => electronGitService.listGitRemoteBranches(dirPath!), { - revalidateOnFocus: false, - shouldRetryOnError: false, - }); +export const useGitRemoteBranches = ( + dirPath: string | undefined, + enabled: boolean, + deviceId?: string, +) => { + const key = dirPath && enabled ? ['git-remote-branches', deviceId ?? 'local', dirPath] : null; + return useClientDataSWR( + key, + () => gitService.listGitRemoteBranches({ deviceId, path: dirPath! }), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ); }; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx deleted file mode 100644 index 942da8588a..0000000000 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import type { ReactNode } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import * as swr from '@/libs/swr'; -import { useGlobalStore } from '@/store/global'; -import { initialState } from '@/store/global/initialState'; -import { useUserStore } from '@/store/user'; -import { initialState as initialUserState } from '@/store/user/initialState'; - -import AgentWorkingSidebar from './index'; - -const mocks = vi.hoisted(() => ({ - agentStoreState: { - activeAgentId: 'agent-1', - agentWorkingDirectoryById: {} as Record, - }, - repoType: undefined as 'git' | 'github' | undefined, - topicWorkingDirectory: undefined as string | undefined, -})); - -vi.mock('@/libs/swr', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useClientDataSWR: vi.fn() }; -}); - -vi.mock('./Review', () => ({ - default: ({ workingDirectory }: { workingDirectory: string }) => ( -
{workingDirectory}
- ), -})); - -vi.mock('./Files', () => ({ - default: ({ workingDirectory }: { workingDirectory: string }) => ( -
{workingDirectory}
- ), -})); - -vi.mock('@/features/ChatInput/RuntimeConfig/useRepoType', () => ({ - useRepoType: (path?: string) => (path ? mocks.repoType : undefined), -})); - -vi.mock('@lobehub/ui', () => ({ - Accordion: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
- ), - ActionIcon: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( - - ), - AccordionItem: ({ - children, - title, - ...props - }: { - children?: ReactNode; - title?: ReactNode; - [key: string]: unknown; - }) => ( -
- {title} - {children} -
- ), - Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( - - ), - Center: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
- ), - Checkbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
- ), - DraggablePanel: ({ - children, - expand, - stableLayout, - }: { - children?: ReactNode; - expand?: boolean; - stableLayout?: boolean; - }) => ( -
- {children} -
- ), - Empty: ({ description }: { description?: ReactNode }) =>
{description}
, - Avatar: ({ avatar }: { avatar?: ReactNode | string }) =>
{avatar}
, - Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
- ), - Icon: () =>
, - Markdown: ({ children }: { children?: ReactNode }) =>
{children}
, - Progress: () =>
, - ShikiLobeTheme: {}, - Skeleton: { Button: () =>
}, - Tag: ({ children }: { children?: ReactNode }) =>
{children}
, - Text: ({ children }: { children?: ReactNode }) =>
{children}
, - TextArea: () =>