mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(agent): unified per-device working directory + execution-device UI (#15543)
* ✨ 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 <noreply@anthropic.com> * ✨ 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 <noreply@anthropic.com> * ✅ 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 style(agent): reword download-card desc to "access to your computer" Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ✨ 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 style(agent): swap switcher icons — MonitorOff for "no device", Box for sandbox Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 style(agent): zh tooltip wording — "提供服务" Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 style(agent): reorder tooltip — device runtime first, marketplace last Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 style(agent): trim tooltip — drop "设备"/devices and trailing period Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * 💄 style(agent): widen gap between execution-device rows Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 style(agent): hide "Get Desktop App" link on desktop Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 style(agent): capitalize "Cloud Sandbox" label Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * ♻️ 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 <noreply@anthropic.com> * 💄 style(agent): use the device default cwd as the add-folder placeholder Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ✨ 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 <noreply@anthropic.com> * ♻️ 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 <noreply@anthropic.com> * ♻️ 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 <noreply@anthropic.com> * ♻️ 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 <noreply@anthropic.com> * ✨ 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 <noreply@anthropic.com> * ♻️ 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 <noreply@anthropic.com> * 🐛 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * ♻️ 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 <noreply@anthropic.com> * ✨ 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 <noreply@anthropic.com> * 🐛 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 <noreply@anthropic.com> * 💄 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 <noreply@anthropic.com> * ✨ 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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": "تمكين تنسيق الدردشة الجماعية متعددة الوكلاء.",
|
||||
|
||||
@@ -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": "Активиране на координация в групов чат с множество агенти.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "فعالسازی هماهنگی گفتوگوی گروهی چندعاملی.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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アシスタントによるグループチャット機能を有効にします。",
|
||||
|
||||
@@ -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": "다중 도우미 그룹 채팅 조정 기능을 활성화합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Включите координацию группового чата с несколькими агентами.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "云端沙箱",
|
||||
|
||||
@@ -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}} 个文件"
|
||||
}
|
||||
@@ -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": "启用多代理协同群聊功能。",
|
||||
|
||||
@@ -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": "该技能暂无资源",
|
||||
|
||||
@@ -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": "啟用多智能體群組聊天編排功能。",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<ListProjectSkillsResult>(
|
||||
|
||||
@@ -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<string | undefined>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const AddWorkingDirContent = memo<AddWorkingDirContentProps>(({ 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<string>();
|
||||
const inputRef = useRef<InputRef>(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 (
|
||||
<Flexbox gap={16}>
|
||||
<Text style={{ marginTop: -8 }} type={'secondary'}>
|
||||
{tPlugin('workingDirectory.addFolderDesc')}
|
||||
</Text>
|
||||
<Flexbox gap={6}>
|
||||
<Input
|
||||
placeholder={placeholder || tPlugin('workingDirectory.placeholder')}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onPressEnter={handleSubmit}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
{error ? <Text style={{ color: cssVar.colorError, fontSize: 12 }}>{error}</Text> : null}
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={8} justify={'flex-end'}>
|
||||
<Button disabled={loading} onClick={close}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button loading={loading} type={'primary'} onClick={handleSubmit}>
|
||||
{tCommon('confirm')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
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<string | undefined>;
|
||||
placeholder?: string;
|
||||
}): ModalInstance =>
|
||||
createModal({
|
||||
content: <AddWorkingDirContent placeholder={options.placeholder} onSubmit={options.onSubmit} />,
|
||||
footer: null,
|
||||
maskClosable: true,
|
||||
styles: { header: { borderBottom: 'none' } },
|
||||
title: t('workingDirectory.addFolderTitle', { ns: 'device' }),
|
||||
width: 'min(90vw, 480px)',
|
||||
});
|
||||
@@ -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<void>;
|
||||
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<BranchSwitcherProps>(
|
||||
({ 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<string | null>(null);
|
||||
const createInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
data: branches = [],
|
||||
@@ -189,11 +189,14 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
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<BranchSwitcherProps>(
|
||||
}, [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<BranchSwitcherProps>(
|
||||
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<string | undefined> => {
|
||||
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 (
|
||||
<DropdownMenuRoot open={open} onOpenChange={onOpenChange}>
|
||||
@@ -273,7 +295,7 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
<div className={styles.searchBar}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('localSystem.workingDirectory.branchSearchPlaceholder')}
|
||||
placeholder={t('workingDirectory.branchSearchPlaceholder')}
|
||||
prefix={<Icon icon={SearchIcon} size={14} />}
|
||||
size="small"
|
||||
value={search}
|
||||
@@ -285,9 +307,7 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
|
||||
<div className={styles.list}>
|
||||
<div className={styles.sectionRow}>
|
||||
<div className={styles.section}>
|
||||
{t('localSystem.workingDirectory.branchesHeading')}
|
||||
</div>
|
||||
<div className={styles.section}>{t('workingDirectory.branchesHeading')}</div>
|
||||
<div className={styles.refreshButton} role="button" onClick={handleRefresh}>
|
||||
<Icon
|
||||
className={cx(isRefreshing && styles.spinning)}
|
||||
@@ -298,27 +318,24 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
</div>
|
||||
|
||||
{isLoading && branches.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
{t('localSystem.workingDirectory.branchesLoading')}
|
||||
</div>
|
||||
<div className={styles.emptyState}>{t('workingDirectory.branchesLoading')}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && branchesError && (
|
||||
<div className={styles.emptyState}>
|
||||
{(branchesError as Error)?.message ||
|
||||
t('localSystem.workingDirectory.branchesEmpty')}
|
||||
{(branchesError as Error)?.message || t('workingDirectory.branchesEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !branchesError && filtered.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
{search.trim()
|
||||
? t('localSystem.workingDirectory.branchesNoMatch')
|
||||
: t('localSystem.workingDirectory.branchesEmpty')}
|
||||
? t('workingDirectory.branchesNoMatch')
|
||||
: t('workingDirectory.branchesEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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<BranchSwitcherProps>(
|
||||
<div className={styles.branchLabel}>{branch.name}</div>
|
||||
{isCurrent && workingStatus && !workingStatus.clean && (
|
||||
<div className={styles.itemMeta}>
|
||||
{t('localSystem.workingDirectory.uncommittedChanges', {
|
||||
{t('workingDirectory.uncommittedChanges', {
|
||||
count: workingStatus.total,
|
||||
})}
|
||||
</div>
|
||||
@@ -351,53 +368,17 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isCreating ? (
|
||||
<div className={styles.createFooter}>
|
||||
<Input
|
||||
className={styles.createInput}
|
||||
placeholder={t('localSystem.workingDirectory.newBranchPlaceholder')}
|
||||
ref={createInputRef as any}
|
||||
size="small"
|
||||
value={newBranch}
|
||||
variant="filled"
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onPressEnter={handleCreateSubmit}
|
||||
/>
|
||||
<Button
|
||||
disabled={!newBranch.trim() || !!busyBranch}
|
||||
loading={!!busyBranch}
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={handleCreateSubmit}
|
||||
>
|
||||
{t('localSystem.workingDirectory.checkoutAction')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewBranch('');
|
||||
}}
|
||||
>
|
||||
{t('localSystem.workingDirectory.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.createItemWrapper}>
|
||||
<DropdownMenuItem
|
||||
className={cx(styles.item, styles.createItem)}
|
||||
closeOnClick={false}
|
||||
onClick={() => setIsCreating(true)}
|
||||
>
|
||||
<Icon className={styles.itemIcon} icon={GitBranchPlusIcon} size={14} />
|
||||
<div className={styles.itemMain}>
|
||||
{t('localSystem.workingDirectory.createBranchAction')}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.createItemWrapper}>
|
||||
<DropdownMenuItem
|
||||
className={cx(styles.item, styles.createItem)}
|
||||
onClick={openCreateBranch}
|
||||
>
|
||||
<Icon className={styles.itemIcon} icon={GitBranchPlusIcon} size={14} />
|
||||
<div className={styles.itemMain}>
|
||||
{t('workingDirectory.createBranchAction')}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuPopup>
|
||||
</DropdownMenuPositioner>
|
||||
|
||||
@@ -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<string | undefined>;
|
||||
}
|
||||
|
||||
const CreateBranchContent = memo<CreateBranchContentProps>(({ 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<string>();
|
||||
const inputRef = useRef<InputRef>(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 (
|
||||
<Flexbox gap={16}>
|
||||
<Flexbox gap={6}>
|
||||
<Input
|
||||
placeholder={tDevice('workingDirectory.newBranchPlaceholder')}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onPressEnter={handleSubmit}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
{error ? <Text style={{ color: cssVar.colorError, fontSize: 12 }}>{error}</Text> : null}
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={8} justify={'flex-end'}>
|
||||
<Button disabled={loading} onClick={close}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button disabled={!value.trim()} loading={loading} type={'primary'} onClick={handleSubmit}>
|
||||
{tDevice('workingDirectory.checkoutAction')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
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<string | undefined>;
|
||||
}): ModalInstance =>
|
||||
createModal({
|
||||
content: <CreateBranchContent onSubmit={options.onSubmit} />,
|
||||
footer: null,
|
||||
maskClosable: true,
|
||||
styles: { header: { borderBottom: 'none' } },
|
||||
title: t('workingDirectory.createBranchTitle', { ns: 'device' }),
|
||||
width: 'min(90vw, 480px)',
|
||||
});
|
||||
@@ -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<DeviceWorkingDirectoryProps>(({ 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 = (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
<div className={styles.sectionTitle}>{t('localSystem.workingDirectory.recent')}</div>
|
||||
<div className={styles.scrollContainer}>
|
||||
{workingDirs.length === 0 ? (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ color: cssVar.colorTextQuaternary, fontSize: 12, padding: '12px 8px' }}
|
||||
>
|
||||
{t('localSystem.workingDirectory.noRecent')}
|
||||
</Flexbox>
|
||||
) : (
|
||||
workingDirs.map((entry) => {
|
||||
const isActive = entry.path === effectiveDir;
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={cx(styles.dirItem, isActive && styles.dirItemActive)}
|
||||
gap={8}
|
||||
key={entry.path}
|
||||
onClick={() => void commitDir(entry)}
|
||||
>
|
||||
{renderDirIcon(entry.repoType)}
|
||||
<Flexbox flex={1} style={{ minWidth: 0 }}>
|
||||
<div className={styles.dirName}>{getDirName(entry.path)}</div>
|
||||
<div className={styles.dirPath}>{entry.path}</div>
|
||||
</Flexbox>
|
||||
{isActive ? (
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size={16}
|
||||
style={{ color: cssVar.colorSuccess, flex: 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
</Flexbox>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('localSystem.workingDirectory.placeholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPressEnter={() => void commitDir({ path: input })}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
const displayName = effectiveDir
|
||||
? getDirName(effectiveDir)
|
||||
: t('localSystem.workingDirectory.notSet');
|
||||
|
||||
const trigger = (
|
||||
<div className={styles.button}>
|
||||
<Icon icon={FolderIcon} size={14} />
|
||||
<span>{displayName}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip title={effectiveDir || t('localSystem.workingDirectory.notSet')}>
|
||||
{trigger}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
DeviceWorkingDirectory.displayName = 'DeviceWorkingDirectory';
|
||||
|
||||
export default DeviceWorkingDirectory;
|
||||
@@ -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<GitStatusProps>(({ 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<GitStatusProps>(({ 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<GitStatusProps>(({ 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<GitStatusProps>(({ path, isGithub }) => {
|
||||
);
|
||||
|
||||
const branchNode = data.detached ? (
|
||||
// Detached HEAD → plain branch label (nothing to switch to).
|
||||
<Tooltip title={branchTooltip}>{branchTrigger}</Tooltip>
|
||||
) : (
|
||||
// Local switches over IPC; a remote device switches over RPC (deviceId set).
|
||||
<BranchSwitcher
|
||||
currentBranch={data.branch}
|
||||
deviceId={deviceId}
|
||||
open={switcherOpen}
|
||||
path={path}
|
||||
onExternalRefresh={refreshAfterSync}
|
||||
onOpenChange={setSwitcherOpen}
|
||||
onOptimisticCheckout={handleOptimisticCheckout}
|
||||
onAfterCheckout={() => {
|
||||
void mutate();
|
||||
void mutateWorkingStatus();
|
||||
@@ -274,23 +309,18 @@ const GitStatus = memo<GitStatusProps>(({ 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 && (
|
||||
<Tooltip title={pullTooltip}>
|
||||
@@ -329,7 +359,11 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub }) => {
|
||||
const diffNode = (() => {
|
||||
if (!hasChanges || !workingStatus) return null;
|
||||
const diffButton = (
|
||||
<div className={styles.trigger} role="button" onClick={handleToggleReview}>
|
||||
<div
|
||||
className={styles.trigger}
|
||||
role={local ? 'button' : undefined}
|
||||
onClick={local ? handleToggleReview : undefined}
|
||||
>
|
||||
<span className={styles.diffStat}>
|
||||
{workingStatus.added > 0 && (
|
||||
<span className={styles.diffStatAdded}>+{workingStatus.added}</span>
|
||||
|
||||
@@ -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<OptionRowProps>(({ active, desc, disabled, icon, label, onClick }) => {
|
||||
const OptionRow = memo<OptionRowProps>(({ active, desc, disabled, icon, label, onClick, tag }) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
@@ -206,7 +247,10 @@ const OptionRow = memo<OptionRowProps>(({ active, desc, disabled, icon, label, o
|
||||
>
|
||||
<div className={styles.optionIcon}>{icon}</div>
|
||||
<div className={styles.optionMeta}>
|
||||
<div className={styles.optionTitle}>{label}</div>
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
<span className={styles.optionTitle}>{label}</span>
|
||||
{tag ? <span className={styles.tag}>{tag}</span> : null}
|
||||
</Flexbox>
|
||||
{desc ? <div className={styles.desc}>{desc}</div> : null}
|
||||
</div>
|
||||
{active ? <Icon className={styles.check} icon={CheckIcon} size={14} /> : null}
|
||||
@@ -248,32 +292,33 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ 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<HeteroDeviceSwitcherProps>(({ 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 = <Icon icon={CloudIcon} size={14} />;
|
||||
let chipIcon: ReactNode = <Icon icon={BoxIcon} size={14} />;
|
||||
let chipLabel = t('heteroAgent.executionTarget.sandbox');
|
||||
if (executionTarget === 'local') {
|
||||
if (executionTarget === 'none') {
|
||||
chipIcon = <Icon icon={MonitorOffIcon} size={14} />;
|
||||
chipLabel = t('heteroAgent.executionTarget.none');
|
||||
} else if (executionTarget === 'local') {
|
||||
chipIcon = <Icon icon={LaptopIcon} size={14} />;
|
||||
chipLabel = t('heteroAgent.executionTarget.local');
|
||||
} else if (executionTarget === 'device') {
|
||||
@@ -300,16 +351,45 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ 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<typeof devices>[number]) => (
|
||||
<OptionRow
|
||||
active={isActive('device', d.deviceId)}
|
||||
disabled={!d.online}
|
||||
icon={getDeviceIcon(d.platform)}
|
||||
key={d.deviceId}
|
||||
label={d.friendlyName || d.hostname || d.deviceId}
|
||||
tag={d.deviceId === currentDeviceId ? t('heteroAgent.executionTarget.local') : undefined}
|
||||
desc={
|
||||
<>
|
||||
<span className={d.online ? styles.dotOnline : styles.dotOffline} />
|
||||
<span>
|
||||
{d.online
|
||||
? t('heteroAgent.executionTarget.online')
|
||||
: t('heteroAgent.executionTarget.offline')}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
onClick={() => void handleSelect('device', d.deviceId)}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Flexbox gap={2} style={{ maxWidth: 320, minWidth: 280 }}>
|
||||
<Flexbox gap={6} style={{ maxWidth: 320, minWidth: 280 }}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerTitle}>{t('heteroAgent.executionTarget.title')}</span>
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<span className={styles.headerTitle}>{t('heteroAgent.executionTarget.title')}</span>
|
||||
<Tooltip title={t('heteroAgent.executionTarget.infoTooltip')}>
|
||||
<span className={styles.headerInfo}>
|
||||
<Icon icon={InfoIcon} size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Flexbox>
|
||||
{isDesktop || showWebDownloadCard ? null : (
|
||||
<a
|
||||
className={styles.headerLink}
|
||||
href="https://lobehub.com/downloads"
|
||||
@@ -319,13 +399,15 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
<Icon icon={ExternalLinkIcon} size={11} />
|
||||
<span>{t('heteroAgent.executionTarget.downloadDesktop')}</span>
|
||||
</a>
|
||||
<Tooltip title={t('heteroAgent.executionTarget.infoTooltip')}>
|
||||
<span className={styles.headerInfo}>
|
||||
<Icon icon={InfoIcon} size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Flexbox>
|
||||
)}
|
||||
</div>
|
||||
<OptionRow
|
||||
active={isActive('none')}
|
||||
desc={t('heteroAgent.executionTarget.noneDesc')}
|
||||
icon={<Icon icon={MonitorOffIcon} size={14} />}
|
||||
label={t('heteroAgent.executionTarget.none')}
|
||||
onClick={() => void handleSelect('none')}
|
||||
/>
|
||||
{isDesktop ? (
|
||||
<OptionRow
|
||||
active={isActive('local')}
|
||||
@@ -338,34 +420,38 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
<OptionRow
|
||||
active={isActive('sandbox')}
|
||||
desc={t('heteroAgent.executionTarget.sandboxDesc')}
|
||||
icon={<Icon icon={CloudIcon} size={14} />}
|
||||
icon={<Icon icon={BoxIcon} size={14} />}
|
||||
label={t('heteroAgent.executionTarget.sandbox')}
|
||||
onClick={() => void handleSelect('sandbox')}
|
||||
/>
|
||||
{(devices ?? []).map((d) => (
|
||||
<OptionRow
|
||||
active={isActive('device', d.deviceId)}
|
||||
disabled={!d.online}
|
||||
icon={getDeviceIcon(d.platform)}
|
||||
key={d.deviceId}
|
||||
label={d.friendlyName || d.hostname || d.deviceId}
|
||||
desc={
|
||||
<>
|
||||
<span className={d.online ? styles.dotOnline : styles.dotOffline} />
|
||||
<span>
|
||||
{d.online
|
||||
? t('heteroAgent.executionTarget.online')
|
||||
: t('heteroAgent.executionTarget.offline')}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
onClick={() => void handleSelect('device', d.deviceId)}
|
||||
/>
|
||||
))}
|
||||
{(devices ?? []).map((d) => renderDeviceRow(d))}
|
||||
{hasNoDevices && isLoading ? (
|
||||
<div className={styles.empty}>{t('heteroAgent.executionTarget.loading')}</div>
|
||||
) : 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 ? (
|
||||
<a
|
||||
className={styles.downloadCard}
|
||||
href="https://lobehub.com/downloads"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div className={styles.optionIcon}>
|
||||
<Icon icon={MonitorDownIcon} size={14} />
|
||||
</div>
|
||||
<div className={styles.optionMeta}>
|
||||
<div className={styles.optionTitle}>
|
||||
{t('heteroAgent.executionTarget.downloadDesktopTitle')}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
{t('heteroAgent.executionTarget.downloadDesktopDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Icon className={styles.downloadCardArrow} icon={ExternalLinkIcon} size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
{hasNoDevices && !isLoading && isDesktop ? (
|
||||
<div className={styles.empty}>{t('heteroAgent.executionTarget.noDevices')}</div>
|
||||
) : null}
|
||||
</Flexbox>
|
||||
|
||||
@@ -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<WorkingDirectoryContentProps>(({ 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/<encoded-cwd>/`.
|
||||
// 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 (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
<Flexbox horizontal align={'center'} distribution={'space-between'}>
|
||||
<div className={styles.sectionTitle}>{t('localSystem.workingDirectory.recent')}</div>
|
||||
{effectiveDir && (
|
||||
<div className={styles.clearText} onClick={clearDir}>
|
||||
{t('localSystem.workingDirectory.clear')}
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
<div className={styles.scrollContainer}>
|
||||
{displayDirs.length === 0 ? (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ color: cssVar.colorTextQuaternary, fontSize: 12, padding: '12px 8px' }}
|
||||
>
|
||||
{t('localSystem.workingDirectory.noRecent')}
|
||||
</Flexbox>
|
||||
) : (
|
||||
displayDirs.map((entry) => {
|
||||
const isActive = entry.path === effectiveDir;
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={`${styles.dirItem} ${isActive ? styles.dirItemActive : ''}`}
|
||||
gap={8}
|
||||
key={entry.path}
|
||||
onClick={() => selectDir(entry)}
|
||||
>
|
||||
<RecentDirIcon entry={entry} />
|
||||
<Flexbox flex={1} style={{ minWidth: 0 }}>
|
||||
<div className={styles.dirName}>{getDirName(entry.path)}</div>
|
||||
<div className={styles.dirPath}>{entry.path}</div>
|
||||
</Flexbox>
|
||||
{isActive ? (
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size={16}
|
||||
style={{ color: cssVar.colorSuccess, flex: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles.removeBtn}
|
||||
title={t('localSystem.workingDirectory.removeRecent')}
|
||||
onClick={(e) => handleRemoveRecent(e, entry.path)}
|
||||
>
|
||||
<Icon icon={XIcon} size={12} />
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDesktop && (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.chooseFolderItem}
|
||||
gap={8}
|
||||
onClick={handleChooseFolder}
|
||||
>
|
||||
<Icon icon={FolderOpenIcon} size={14} />
|
||||
<span>{t('localSystem.workingDirectory.chooseDifferentFolder')}</span>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
WorkingDirectoryContent.displayName = 'WorkingDirectoryContent';
|
||||
|
||||
export default WorkingDirectoryContent;
|
||||
@@ -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 (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.chooseFolderItem}
|
||||
gap={8}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon icon={FolderOpenIcon} size={14} />
|
||||
<span>{t('workingDirectory.chooseDifferentFolder')}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
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<string | undefined> => {
|
||||
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 (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.chooseFolderItem}
|
||||
gap={8}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon icon={FolderPlusIcon} size={14} />
|
||||
<span>{t('workingDirectory.addFolder')}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
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<WorkingDirectoryPickerProps>(({ 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 = (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
<Flexbox horizontal align={'center'} distribution={'space-between'}>
|
||||
<div className={styles.sectionTitle}>{t('workingDirectory.recent')}</div>
|
||||
{selectedDir && (
|
||||
<div className={styles.clearText} onClick={() => void clear().then(() => setOpen(false))}>
|
||||
{t('workingDirectory.clear')}
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
<div className={styles.scrollContainer}>
|
||||
{recents.length === 0 ? (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ color: cssVar.colorTextQuaternary, fontSize: 12, padding: '12px 8px' }}
|
||||
>
|
||||
{t('workingDirectory.noRecent')}
|
||||
</Flexbox>
|
||||
) : (
|
||||
recents.map((entry) => {
|
||||
const isActive = entry.path === selectedDir;
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={cx(styles.dirItem, isActive && styles.dirItemActive)}
|
||||
gap={8}
|
||||
key={entry.path}
|
||||
onClick={() => void pick(entry)}
|
||||
>
|
||||
{renderDirIcon(entry.repoType)}
|
||||
<Flexbox flex={1} style={{ minWidth: 0 }}>
|
||||
<div className={styles.dirName}>{getDirName(entry.path)}</div>
|
||||
<div className={styles.dirPath}>{entry.path}</div>
|
||||
</Flexbox>
|
||||
{isActive ? (
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size={16}
|
||||
style={{ color: cssVar.colorSuccess, flex: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles.removeBtn}
|
||||
title={t('workingDirectory.removeRecent')}
|
||||
onClick={(e) => handleRemoveRecent(e, entry.path)}
|
||||
>
|
||||
<Icon icon={XIcon} size={12} />
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocalDevice ? (
|
||||
<ChooseLocalFolderRow defaultPath={selectedDir} onPick={pick} />
|
||||
) : (
|
||||
<AddRemoteFolderRow
|
||||
defaultCwd={deviceDefaultCwd}
|
||||
deviceId={targetDeviceId}
|
||||
onBeforeOpen={() => setOpen(false)}
|
||||
onPick={pick}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
const displayName = selectedDir ? getDirName(selectedDir) : t('workingDirectory.notSet');
|
||||
|
||||
const trigger = (
|
||||
<div className={styles.button}>
|
||||
{selectedDir ? (
|
||||
renderDirIcon(recents.find((r) => r.path === selectedDir)?.repoType)
|
||||
) : (
|
||||
<Icon icon={FolderIcon} size={14} />
|
||||
)}
|
||||
<span>{displayName}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip title={selectedDir || t('workingDirectory.notSet')}>{trigger}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
WorkingDirectoryPicker.displayName = 'WorkingDirectoryPicker';
|
||||
|
||||
export default WorkingDirectoryPicker;
|
||||
@@ -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<WorkingDirectorySectionProps>(({ 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 (
|
||||
<>
|
||||
<WorkingDirectoryPicker agentId={agentId} />
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus
|
||||
deviceId={isLocalDevice ? undefined : targetDeviceId}
|
||||
isGithub={repoType === 'github'}
|
||||
path={effectiveWorkingDirectory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
WorkingDirectorySection.displayName = 'WorkingDirectorySection';
|
||||
|
||||
export default WorkingDirectorySection;
|
||||
@@ -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<WorkspaceControlsProps>(
|
||||
({ 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 <WorkingDirectorySection agentId={agentId} />;
|
||||
|
||||
// Web has no local filesystem — cloud / heterogeneous agents browse the repo
|
||||
// through the cloud repo switcher instead.
|
||||
if (!isDesktop) {
|
||||
return isHeterogeneous || alwaysShowWorkspace ? (
|
||||
<CloudRepoSwitcher agentId={agentId} />
|
||||
) : 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 <WorkingDirectorySection agentId={agentId} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeteroDeviceSwitcher agentId={agentId} />
|
||||
{renderWorkspace()}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WorkspaceControls.displayName = 'WorkspaceControls';
|
||||
|
||||
export default WorkspaceControls;
|
||||
@@ -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<RuntimeEnvMode, typeof LaptopIcon> = {
|
||||
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 <Icon icon={SquircleDashed} size={14} />;
|
||||
if (repoType === 'github') return <Github size={14} />;
|
||||
if (repoType === 'git') return <Icon icon={GitBranchIcon} size={14} />;
|
||||
return <Icon icon={FolderIcon} size={14} />;
|
||||
}, [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 = (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
{modes.map(({ mode, icon, label, desc }) => (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
className={cx(styles.modeOption, runtimeMode === mode && styles.modeOptionActive)}
|
||||
gap={12}
|
||||
key={mode}
|
||||
onClick={() => switchMode(mode)}
|
||||
>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.modeOptionIcon}
|
||||
flex={'none'}
|
||||
height={32}
|
||||
justify={'center'}
|
||||
width={32}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
</Flexbox>
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.modeOptionTitle}>{label}</div>
|
||||
<div className={styles.modeOptionDesc}>{desc}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
const modeButton = (
|
||||
<div className={styles.button}>
|
||||
<Icon icon={ModeIcon} size={14} />
|
||||
<span>{modeLabel}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const dirButton = (
|
||||
<div className={styles.button}>
|
||||
{dirIconNode}
|
||||
<span>{displayName}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
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 <CloudRepoSwitcher agentId={agentId} />;
|
||||
}
|
||||
|
||||
// Desktop local mode: show working directory picker
|
||||
if (runtimeMode === 'local') {
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={dirPopoverOpen}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
content={
|
||||
<WorkingDirectory agentId={agentId} onClose={() => setDirPopoverOpen(false)} />
|
||||
}
|
||||
onOpenChange={setDirPopoverOpen}
|
||||
>
|
||||
<div>
|
||||
{dirPopoverOpen ? (
|
||||
dirButton
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
effectiveWorkingDirectory || tPlugin('localSystem.workingDirectory.notSet')
|
||||
}
|
||||
>
|
||||
{dirButton}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
{/* Left: Chat mode switcher + (agent-only) runtime env + working directory */}
|
||||
{/* Left: chat-mode switcher + (agent-only) execution device + working directory */}
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<ModeSelector />
|
||||
{enableAgentMode && enableExecutionDeviceSwitcher && agentId && (
|
||||
<HeteroDeviceSwitcher agentId={agentId} />
|
||||
)}
|
||||
{enableAgentMode && (
|
||||
<>
|
||||
{!enableExecutionDeviceSwitcher && (
|
||||
<Popover
|
||||
content={modeContent}
|
||||
open={modePopoverOpen}
|
||||
placement="top"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setModePopoverOpen}
|
||||
>
|
||||
<div>
|
||||
{modePopoverOpen ? (
|
||||
modeButton
|
||||
) : (
|
||||
<Tooltip title={t('runtimeEnv.selectMode')}>{modeButton}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
{rightContent()}
|
||||
</>
|
||||
)}
|
||||
{enableAgentMode && <WorkspaceControls agentId={agentId} />}
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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<GitInfo> => {
|
||||
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<GitInfo>(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,
|
||||
});
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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<RepoType>(() => {
|
||||
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<RepoType>(() => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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<CreatePlatformAgentModalProps>(
|
||||
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<CreatePlatformAgentModalProps>(
|
||||
setFetchingProfile(true);
|
||||
setAgentProfile(null);
|
||||
try {
|
||||
const profile = await lambdaClient.device.getAgentProfile.query({
|
||||
const profile = await deviceService.getAgentProfile({
|
||||
deviceId: dId,
|
||||
platform,
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+8
-117
@@ -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 <Icon icon={SquircleDashed} size={14} />;
|
||||
if (repoType === 'github') return <Github size={14} />;
|
||||
if (repoType === 'git') return <Icon icon={GitBranchIcon} size={14} />;
|
||||
return <Icon icon={FolderIcon} size={14} />;
|
||||
}, [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 (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{enableExecutionDeviceSwitcher && <HeteroDeviceSwitcher agentId={agentId} />}
|
||||
{isDeviceMode ? (
|
||||
<DeviceWorkingDirectory agentId={agentId} />
|
||||
) : (
|
||||
<CloudRepoSwitcher agentId={agentId} />
|
||||
)}
|
||||
<WorkspaceControls alwaysShowWorkspace agentId={agentId} />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
@@ -127,18 +62,6 @@ const WorkingDirectoryBar = memo(() => {
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = effectiveWorkingDirectory
|
||||
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
|
||||
: t('localSystem.workingDirectory.notSet');
|
||||
|
||||
const dirButton = (
|
||||
<div className={styles.button}>
|
||||
{dirIconNode}
|
||||
<span>{displayName}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const fullAccessBadge = (
|
||||
<div className={styles.fullAccess}>
|
||||
<Icon icon={CircleAlertIcon} size={14} />
|
||||
@@ -149,39 +72,7 @@ const WorkingDirectoryBar = memo(() => {
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{enableExecutionDeviceSwitcher && <HeteroDeviceSwitcher agentId={agentId} />}
|
||||
{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.
|
||||
<DeviceWorkingDirectory agentId={agentId} />
|
||||
) : (
|
||||
<>
|
||||
<Popover
|
||||
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
dirButton
|
||||
) : (
|
||||
<Tooltip
|
||||
title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}
|
||||
>
|
||||
{dirButton}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<WorkspaceControls alwaysShowWorkspace agentId={agentId} />
|
||||
</Flexbox>
|
||||
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
|
||||
</Flexbox>
|
||||
|
||||
@@ -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<FilesProps>(({ workingDirectory }) => {
|
||||
const Files = memo<FilesProps>(({ 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<FilesProps>(({ workingDirectory }) => {
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(node: ExplorerTreeNode<ProjectFileIndexEntry>) => {
|
||||
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<FilesProps>(({ 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<FilesProps>(({ workingDirectory }) => {
|
||||
},
|
||||
];
|
||||
},
|
||||
[dirtyFilePaths, openNode, setWorkingSidebarTab, t],
|
||||
[dirtyFilePaths, isRemote, openNode, setWorkingSidebarTab, t],
|
||||
);
|
||||
|
||||
const fileCount = data?.totalCount ?? entries.filter((e) => !e.isDirectory).length;
|
||||
|
||||
+15
-5
@@ -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<GitWorkingTreeFiles>(
|
||||
return useClientDataSWR<GitWorkingTreeFiles | undefined>(
|
||||
key,
|
||||
() => electronGitService.getGitWorkingTreeFiles(dirPath!),
|
||||
() => gitService.getGitWorkingTreeFiles({ deviceId, path: dirPath! }),
|
||||
{
|
||||
focusThrottleInterval: 5 * 1000,
|
||||
revalidateOnFocus: true,
|
||||
|
||||
+12
-6
@@ -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<ProjectFileIndexResult>(
|
||||
return useClientDataSWR<ProjectFileIndexResult | undefined>(
|
||||
key,
|
||||
() => localFileService.getProjectFileIndex({ scope: dirPath! }),
|
||||
() => projectFileService.getProjectFileIndex({ deviceId, scope: dirPath! }),
|
||||
{
|
||||
focusThrottleInterval: 30 * 1000,
|
||||
revalidateOnFocus: true,
|
||||
|
||||
@@ -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<FileItemHeaderProps>(
|
||||
if (!revertContext) return;
|
||||
setReverting(true);
|
||||
try {
|
||||
const result = await electronGitService.revertGitFile({
|
||||
const result = await gitService.revertGitFile({
|
||||
deviceId: revertContext.deviceId,
|
||||
filePath,
|
||||
path: revertContext.workingDirectory,
|
||||
});
|
||||
|
||||
@@ -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<FileRowProps>(
|
||||
({
|
||||
deviceId,
|
||||
entry,
|
||||
expanded,
|
||||
mode,
|
||||
@@ -110,8 +113,10 @@ const FileRow = memo<FileRowProps>(
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -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', () => {
|
||||
|
||||
@@ -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<ReviewProps>(({ workingDirectory }) => {
|
||||
const Review = memo<ReviewProps>(({ deviceId, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [mode, setMode] = useLocalStorageState<ReviewMode>(REVIEW_MODE_STORAGE_KEY, 'unstaged');
|
||||
// Per-repo base-ref override — when set, the branch diff compares against
|
||||
@@ -217,12 +222,14 @@ const Review = memo<ReviewProps>(({ 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<ReviewProps>(({ 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<ReviewProps>(({ workingDirectory }) => {
|
||||
const expanded = activeKeys.includes(key);
|
||||
return (
|
||||
<FileRow
|
||||
deviceId={deviceId}
|
||||
entry={entry}
|
||||
expanded={expanded}
|
||||
key={key}
|
||||
|
||||
+36
-19
@@ -1,11 +1,10 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type {
|
||||
GitWorkingTreePatch,
|
||||
SubmoduleWorkingTreePatches,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { electronGitService } from '@/services/electron/git';
|
||||
import { gitService } from '@/services/git';
|
||||
|
||||
export type ReviewMode = 'unstaged' | 'branch';
|
||||
|
||||
@@ -23,22 +22,26 @@ export interface ReviewPatchesData {
|
||||
submodules?: SubmoduleWorkingTreePatches[];
|
||||
}
|
||||
|
||||
const fetchUnstaged = async (dirPath: string): Promise<ReviewPatchesData> => {
|
||||
const result = await electronGitService.getGitWorkingTreePatches(dirPath);
|
||||
return { mode: 'unstaged', patches: result.patches, submodules: result.submodules };
|
||||
const fetchUnstaged = async (
|
||||
dirPath: string,
|
||||
deviceId: string | undefined,
|
||||
): Promise<ReviewPatchesData> => {
|
||||
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<ReviewPatchesData> => {
|
||||
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<ReviewPatchesData>(
|
||||
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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, string | undefined>,
|
||||
},
|
||||
repoType: undefined as 'git' | 'github' | undefined,
|
||||
topicWorkingDirectory: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/swr', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof swr>();
|
||||
return { ...actual, useClientDataSWR: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('./Review', () => ({
|
||||
default: ({ workingDirectory }: { workingDirectory: string }) => (
|
||||
<div data-testid="review-panel">{workingDirectory}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./Files', () => ({
|
||||
default: ({ workingDirectory }: { workingDirectory: string }) => (
|
||||
<div data-testid="files-panel">{workingDirectory}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
ActionIcon: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
AccordionItem: ({
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div {...props}>
|
||||
{title}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Center: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
Checkbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
DraggablePanel: ({
|
||||
children,
|
||||
expand,
|
||||
stableLayout,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
expand?: boolean;
|
||||
stableLayout?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-expand={String(expand)}
|
||||
data-stable-layout={String(Boolean(stableLayout))}
|
||||
data-testid="right-panel"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
|
||||
Avatar: ({ avatar }: { avatar?: ReactNode | string }) => <div>{avatar}</div>,
|
||||
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
Icon: () => <div />,
|
||||
Markdown: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Progress: () => <div data-testid="workspace-progress-bar" />,
|
||||
ShikiLobeTheme: {},
|
||||
Skeleton: { Button: () => <div /> },
|
||||
Tag: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Text: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
TextArea: () => <textarea />,
|
||||
TooltipGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
modal: { confirm: vi.fn() },
|
||||
}),
|
||||
},
|
||||
Progress: () => <div data-testid="workspace-progress-bar" />,
|
||||
Segmented: ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (value: string) => void;
|
||||
options?: Array<{ label?: ReactNode; value: string }>;
|
||||
value?: string;
|
||||
}) => (
|
||||
<div data-testid="working-sidebar-tabs">
|
||||
{options?.map((opt) => (
|
||||
<button
|
||||
data-active={String(opt.value === value)}
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange?.(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Spin: () => <div data-testid="spin" />,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
(
|
||||
({
|
||||
'workingPanel.resources.empty': 'No agent documents yet',
|
||||
'workingPanel.review.title': 'Review',
|
||||
'workingPanel.skills.empty': 'No skills found',
|
||||
'workingPanel.space': 'Space',
|
||||
}) as Record<string, string>
|
||||
)[key] || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useMatch: () => null,
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector?.(mocks.agentStoreState),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent/selectors', () => ({
|
||||
agentByIdSelectors: {
|
||||
getAgentWorkingDirectoryById:
|
||||
(agentId: string) =>
|
||||
(state: { agentWorkingDirectoryById?: Record<string, string | undefined> }) =>
|
||||
state.agentWorkingDirectoryById?.[agentId],
|
||||
},
|
||||
agentSelectors: {
|
||||
isCurrentAgentHeterogeneous: (_state: Record<string, unknown>) => false,
|
||||
},
|
||||
chatConfigByIdSelectors: {
|
||||
isLocalSystemEnabledById: (_agentId: string) => (_state: Record<string, unknown>) => true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/chat', () => ({
|
||||
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
activeTopicId: undefined,
|
||||
closeDocument: vi.fn(),
|
||||
dbMessagesMap: {},
|
||||
openDocument: vi.fn(),
|
||||
portalStack: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/chat/selectors', () => ({
|
||||
chatPortalSelectors: {
|
||||
portalDocumentId: () => null,
|
||||
},
|
||||
topicSelectors: {
|
||||
currentTopicWorkingDirectory: () => mocks.topicWorkingDirectory,
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.agentStoreState.activeAgentId = 'agent-1';
|
||||
mocks.agentStoreState.agentWorkingDirectoryById = {};
|
||||
mocks.repoType = undefined;
|
||||
mocks.topicWorkingDirectory = undefined;
|
||||
vi.mocked(swr.useClientDataSWR).mockImplementation((() => ({
|
||||
data: [],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
})) as unknown as typeof swr.useClientDataSWR);
|
||||
useGlobalStore.setState({
|
||||
...initialState,
|
||||
isStatusInit: true,
|
||||
status: { ...initialState.status },
|
||||
});
|
||||
useUserStore.setState({
|
||||
...initialUserState,
|
||||
preference: {
|
||||
...initialUserState.preference,
|
||||
lab: { ...initialUserState.preference.lab },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('AgentWorkingSidebar', () => {
|
||||
it('renders panel header title and resources empty state', () => {
|
||||
render(<AgentWorkingSidebar />);
|
||||
|
||||
// Panel-level title (Space tab when no working directory)
|
||||
expect(screen.getAllByText('Space').length).toBeGreaterThan(0);
|
||||
|
||||
const resources = screen.getByTestId('workspace-resources');
|
||||
// Default tab is Skills; empty data shows the skills empty state.
|
||||
expect(resources).toHaveTextContent('No skills found');
|
||||
});
|
||||
|
||||
it('mounts a right panel wrapper', () => {
|
||||
render(<AgentWorkingSidebar />);
|
||||
|
||||
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-stable-layout', 'true');
|
||||
});
|
||||
|
||||
it('shows review when the agent has a git working directory but the topic does not', () => {
|
||||
mocks.agentStoreState.agentWorkingDirectoryById['agent-1'] = '/Users/hai/LobeHub/lobehub';
|
||||
mocks.repoType = 'git';
|
||||
useGlobalStore.setState({
|
||||
status: {
|
||||
...useGlobalStore.getState().status,
|
||||
workingSidebarTab: 'review',
|
||||
},
|
||||
});
|
||||
|
||||
render(<AgentWorkingSidebar />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Review' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('review-panel')).toHaveTextContent('/Users/hai/LobeHub/lobehub');
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
|
||||
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
|
||||
import RightPanel from '@/features/RightPanel';
|
||||
import { resolveTargetDeviceId } from '@/helpers/agentWorkingDirectory';
|
||||
import { useEffectiveWorkingDirectory } from '@/hooks/useEffectiveWorkingDirectory';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import {
|
||||
agentByIdSelectors,
|
||||
agentSelectors,
|
||||
chatConfigByIdSelectors,
|
||||
} from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import Files from './Files';
|
||||
@@ -80,20 +81,37 @@ const AgentWorkingSidebar = memo(() => {
|
||||
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
|
||||
const setWorkingSidebarTab = useGlobalStore((s) => s.setWorkingSidebarTab);
|
||||
const storedTab = useGlobalStore((s) => s.status.workingSidebarTab);
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
activeAgentId ? agentByIdSelectors.getAgentWorkingDirectoryById(activeAgentId)(s) : undefined,
|
||||
);
|
||||
const isLocalSystemEnabled = useAgentStore((s) =>
|
||||
activeAgentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(activeAgentId)(s) : false,
|
||||
);
|
||||
const isHetero = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
|
||||
const repoType = useRepoType(workingDirectory);
|
||||
// Unified precedence (topic > per-device choice > legacy > device default), so
|
||||
// the sidebar resolves the same directory the runtime bar / git status do.
|
||||
// The old `topicCwd || legacy agentCwd` pattern missed `workingDirByDevice`,
|
||||
// landing on the home fallback for device-bound agents and hiding Review.
|
||||
const workingDirectory = useEffectiveWorkingDirectory(activeAgentId);
|
||||
// Effective target device for git ops — bound device for remote agents, this
|
||||
// machine otherwise. Resolved the same way WorkingDirectoryPicker / GitStatus do.
|
||||
const agencyConfig = useAgentStore((s) =>
|
||||
activeAgentId ? agentByIdSelectors.getAgencyConfigById(activeAgentId)(s) : undefined,
|
||||
);
|
||||
const currentDeviceId = useElectronStore((s) => s.gatewayDeviceInfo?.deviceId);
|
||||
const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId);
|
||||
const repoType = useRepoType(workingDirectory, targetDeviceId);
|
||||
|
||||
const filesAvailable = isLocalSystemEnabled && !!workingDirectory;
|
||||
const reviewAvailable = isLocalSystemEnabled && !!workingDirectory && !!repoType;
|
||||
// Running against a bound device (remote, or this machine as a device): file
|
||||
// tree + git reads go over RPC, so both Review and Files are reachable even
|
||||
// when runtimeMode isn't `local`.
|
||||
const isDeviceMode = agencyConfig?.executionTarget === 'device' && !!agencyConfig?.boundDeviceId;
|
||||
// `targetDeviceId` also identifies the local desktop for per-device working
|
||||
// directory state. Files/Review only need a deviceId when routing through a
|
||||
// remote device RPC; local "This device" must keep Electron IPC + file-open
|
||||
// actions enabled.
|
||||
const remoteDeviceId = isDeviceMode ? agencyConfig.boundDeviceId : undefined;
|
||||
const filesAvailable = (isLocalSystemEnabled || isDeviceMode) && !!workingDirectory;
|
||||
const reviewAvailable =
|
||||
(isLocalSystemEnabled || isDeviceMode) && !!workingDirectory && !!repoType;
|
||||
const paramsAvailable = !isHetero;
|
||||
const resolveActiveTab = (): Tab => {
|
||||
if (storedTab === 'params' && paramsAvailable) return 'params';
|
||||
@@ -168,12 +186,12 @@ const AgentWorkingSidebar = memo(() => {
|
||||
)}
|
||||
{reviewAvailable && (
|
||||
<Flexbox className={activeTab === 'review' ? styles.pane : styles.paneHidden}>
|
||||
<Review workingDirectory={workingDirectory} />
|
||||
<Review deviceId={remoteDeviceId} workingDirectory={workingDirectory} />
|
||||
</Flexbox>
|
||||
)}
|
||||
{filesAvailable && (
|
||||
<Flexbox className={activeTab === 'files' ? styles.pane : styles.paneHidden}>
|
||||
<Files workingDirectory={workingDirectory} />
|
||||
<Files deviceId={remoteDeviceId} workingDirectory={workingDirectory} />
|
||||
</Flexbox>
|
||||
)}
|
||||
<Flexbox
|
||||
|
||||
@@ -13,7 +13,8 @@ import { BotIcon, CheckCircle2, MonitorSmartphone, RefreshCw, XCircle } from 'lu
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { deviceService } from '@/services/device';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
@@ -114,7 +115,7 @@ const RemoteAgentConfigCard = memo<RemoteAgentConfigCardProps>(
|
||||
setCheckingCapability(true);
|
||||
setCapabilityResult(undefined);
|
||||
try {
|
||||
const result = await lambdaClient.device.checkCapability.query({
|
||||
const result = await deviceService.checkCapability({
|
||||
deviceId,
|
||||
platform: provider.type as RemoteHeterogeneousAgentType,
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ const Page = memo(() => {
|
||||
enableInputMarkdown,
|
||||
enableGatewayMode,
|
||||
enablePlatformAgent,
|
||||
enableExecutionDeviceSwitcher,
|
||||
enableImessage,
|
||||
updateLab,
|
||||
] = useUserStore((s) => [
|
||||
@@ -50,7 +49,6 @@ const Page = memo(() => {
|
||||
labPreferSelectors.enableInputMarkdown(s),
|
||||
labPreferSelectors.enableGatewayMode(s),
|
||||
labPreferSelectors.enablePlatformAgent(s),
|
||||
labPreferSelectors.enableExecutionDeviceSwitcher(s),
|
||||
labPreferSelectors.enableImessage(s),
|
||||
s.updateLab,
|
||||
]);
|
||||
@@ -134,19 +132,6 @@ const Page = memo(() => {
|
||||
label: tLabs('features.inputMarkdown.title'),
|
||||
minWidth: undefined,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Switch
|
||||
checked={enableExecutionDeviceSwitcher}
|
||||
loading={!isPreferenceInit}
|
||||
onChange={(checked) => updateLab({ enableExecutionDeviceSwitcher: checked })}
|
||||
/>
|
||||
),
|
||||
className: styles.labItem,
|
||||
desc: tLabs('features.executionDeviceSwitcher.desc'),
|
||||
label: tLabs('features.executionDeviceSwitcher.title'),
|
||||
minWidth: undefined,
|
||||
},
|
||||
...(isDesktop
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type { DeviceListItem } from '@lobechat/types';
|
||||
import { ActionIcon, Button, Flexbox, Icon, Input, SortableList, Tag, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -8,12 +9,11 @@ import { FolderOpenIcon, FolderPlusIcon, XIcon } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { nextWorkingDirs } from '@/features/ChatInput/RuntimeConfig/deviceCwd';
|
||||
import { renderDirIcon } from '@/features/ChatInput/RuntimeConfig/dirIcon';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { nextWorkingDirs } from '@/store/device';
|
||||
|
||||
import type { DeviceListItem } from './DeviceItem';
|
||||
import { getDeviceIcon } from './getDeviceIcon';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { DeviceListItem } from '@lobechat/types';
|
||||
import { ActionIcon, DropdownMenu, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
@@ -8,32 +9,10 @@ import { FolderIcon, MoreVerticalIcon, Trash2Icon, TriangleAlertIcon } from 'luc
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { WorkingDirEntry } from '@/features/ChatInput/RuntimeConfig/deviceCwd';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
|
||||
import { getDeviceIcon } from './getDeviceIcon';
|
||||
|
||||
export interface DeviceChannel {
|
||||
channel: string | null;
|
||||
connectedAt: string;
|
||||
hostname: string | null;
|
||||
platform: string | null;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
cwd: css`
|
||||
overflow: hidden;
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
useServerConfigStore,
|
||||
} from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { labPreferSelectors } from '@/store/user/selectors';
|
||||
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
|
||||
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
|
||||
|
||||
@@ -70,9 +69,6 @@ export const useCategory = () => {
|
||||
]);
|
||||
const remoteServerUrl = useElectronStore(electronSyncSelectors.remoteServerUrl);
|
||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||
const enableExecutionDeviceSwitcher = useUserStore(
|
||||
labPreferSelectors.enableExecutionDeviceSwitcher,
|
||||
);
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (!avatar) return undefined;
|
||||
@@ -102,7 +98,7 @@ export const useCategory = () => {
|
||||
key: SettingsTabs.Appearance,
|
||||
label: t('tab.appearance'),
|
||||
},
|
||||
enableExecutionDeviceSwitcher && {
|
||||
{
|
||||
icon: MonitorSmartphoneIcon,
|
||||
key: SettingsTabs.Devices,
|
||||
label: t('tab.devices'),
|
||||
@@ -235,7 +231,6 @@ export const useCategory = () => {
|
||||
tAuth,
|
||||
tSubscription,
|
||||
enableBusinessFeatures,
|
||||
enableExecutionDeviceSwitcher,
|
||||
hideDocs,
|
||||
mobile,
|
||||
showApiKeyManage,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ToolsEngine } from '@lobechat/context-engine';
|
||||
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { resolveRuntimeMode } from '@/helpers/executionTarget';
|
||||
import {
|
||||
buildAllowedBuiltinTools,
|
||||
DEVICE_TOOL_IDENTIFIERS,
|
||||
@@ -148,11 +149,15 @@ export const createServerAgentToolsEngine = (
|
||||
// serving desktop-class users; otherwise the caller is treated as web.
|
||||
const platform: RuntimePlatform = hasDeviceProxy ? 'desktop' : 'web';
|
||||
|
||||
// User-configured runtime mode for the current platform, with a
|
||||
// platform-appropriate default when unset.
|
||||
const runtimeMode: RuntimeEnvMode =
|
||||
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform] ??
|
||||
(platform === 'desktop' ? 'local' : 'none');
|
||||
// Tool gate derived from the single `agencyConfig.executionTarget` param
|
||||
// (sandbox → cloud tools, local → local-system tools, device → gateway), with
|
||||
// a no-regression fallback to the legacy per-platform `runtimeMode` for agents
|
||||
// that predate `executionTarget`.
|
||||
const runtimeMode: RuntimeEnvMode = resolveRuntimeMode(
|
||||
agentConfig.agencyConfig,
|
||||
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform],
|
||||
platform === 'desktop',
|
||||
);
|
||||
|
||||
const searchMode = agentConfig.chatConfig?.searchMode ?? 'auto';
|
||||
const isSearchEnabled = searchMode !== 'off';
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { type LobeToolManifest, type PluginEnableChecker } from '@lobechat/context-engine';
|
||||
import { type LobeBuiltinTool, type LobeTool, type RuntimeEnvConfig } from '@lobechat/types';
|
||||
import {
|
||||
type LobeAgentAgencyConfig,
|
||||
type LobeBuiltinTool,
|
||||
type LobeTool,
|
||||
type RuntimeEnvConfig,
|
||||
} from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Installed plugin with manifest
|
||||
@@ -52,6 +57,8 @@ export interface ServerCreateAgentToolsEngineParams {
|
||||
additionalManifests?: LobeToolManifest[];
|
||||
/** Agent configuration containing plugins array */
|
||||
agentConfig: {
|
||||
/** Agency config — execution target drives the runtime tool gate. */
|
||||
agencyConfig?: LobeAgentAgencyConfig;
|
||||
/** Optional agent chat config */
|
||||
chatConfig?: {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/database/schemas';
|
||||
import type { WorkspaceInitResult } from '@lobechat/types';
|
||||
import type { WorkingDirEntry, WorkspaceInitResult } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { preserveWorkspaceCache } from '../deviceWorkingDirs';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/database/schemas';
|
||||
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
|
||||
import type { DeviceChannel, DeviceListItem, WorkingDirEntry } from '@lobechat/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DeviceModel } from '@/database/models/device';
|
||||
@@ -21,14 +21,6 @@ const remotePlatformEnum = z.enum(
|
||||
const CAPABILITY_TIMEOUT_MS = 5_000;
|
||||
const PROFILE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** A single live gateway WebSocket connection belonging to a device. */
|
||||
interface DeviceChannel {
|
||||
channel: string | null;
|
||||
connectedAt: string;
|
||||
hostname: string | null;
|
||||
platform: string | null;
|
||||
}
|
||||
|
||||
const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
|
||||
@@ -77,26 +69,236 @@ export const deviceRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Git status (branch / file changes / linked PR) for a directory on a remote
|
||||
* device, fetched via the device's `gitInfo` RPC. Lets the UI render a remote
|
||||
* device's git the same as the local desktop. Returns `null` when offline /
|
||||
* the directory isn't a git repo.
|
||||
* Granular git reads for a directory on a remote device, each via its own
|
||||
* device RPC so the web/remote git status bar mirrors the local desktop's
|
||||
* separate, differently-cadenced SWR hooks. Return `null` when offline / the
|
||||
* directory isn't a git repo.
|
||||
*/
|
||||
gitInfo: deviceProcedure
|
||||
gitBranch: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.gitBranch({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
gitLinkedPullRequest: deviceProcedure
|
||||
.input(z.object({ branch: z.string(), deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.gitLinkedPullRequest({
|
||||
branch: input.branch,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
gitWorkingTreeStatus: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.gitWorkingTreeStatus({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
gitAheadBehind: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.gitAheadBehind({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* List the local branches of a directory on a remote device, via the device's
|
||||
* `listGitBranches` RPC. Lets the web/remote branch switcher populate the same
|
||||
* dropdown the local desktop renders over IPC.
|
||||
*/
|
||||
listGitBranches: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
isGithub: z.boolean().optional(),
|
||||
scope: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.gitInfo(
|
||||
ctx.userId,
|
||||
input.deviceId,
|
||||
input.scope,
|
||||
input.isGithub,
|
||||
);
|
||||
const result = await deviceGateway.listGitBranches({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? [];
|
||||
}),
|
||||
|
||||
/**
|
||||
* Checkout (or create) a branch in a directory on a remote device, via the
|
||||
* device's `checkoutGitBranch` RPC.
|
||||
*/
|
||||
checkoutGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
branch: z.string(),
|
||||
create: z.boolean().optional(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.checkoutGitBranch({
|
||||
branch: input.branch,
|
||||
create: input.create,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device, via
|
||||
* the device's `pullGitBranch` RPC.
|
||||
*/
|
||||
pullGitBranch: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.pullGitBranch({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Push the current branch of a directory on a remote device, via the device's
|
||||
* `pushGitBranch` RPC.
|
||||
*/
|
||||
pushGitBranch: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.pushGitBranch({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Working-tree (unstaged) per-file patches for a directory on a remote device,
|
||||
* via the device's `getGitWorkingTreePatches` RPC. Powers the web/remote Review
|
||||
* panel's unstaged diff. Returns `null` when offline / not a git repo.
|
||||
*/
|
||||
getGitWorkingTreePatches: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.getGitWorkingTreePatches({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Branch diff (current branch vs base ref) per-file patches for a directory on
|
||||
* a remote device, via the device's `getGitBranchDiff` RPC.
|
||||
*/
|
||||
getGitBranchDiff: deviceProcedure
|
||||
.input(z.object({ baseRef: z.string().optional(), deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.getGitBranchDiff({
|
||||
baseRef: input.baseRef,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* List the remote branches of a directory on a remote device, via the device's
|
||||
* `listGitRemoteBranches` RPC. Populates the Review base-ref picker.
|
||||
*/
|
||||
listGitRemoteBranches: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.listGitRemoteBranches({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? [];
|
||||
}),
|
||||
|
||||
/**
|
||||
* Repo-relative paths of dirty working-tree files for a directory on a remote
|
||||
* device, via the device's `getGitWorkingTreeFiles` RPC. Powers the Files tab's
|
||||
* git-status overlay. Returns `null` when offline / not a git repo.
|
||||
*/
|
||||
getGitWorkingTreeFiles: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.getGitWorkingTreeFiles({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Project file index (tree) for a directory on a remote device, via the
|
||||
* device's `getProjectFileIndex` RPC. Powers the Files tab's tree. Returns
|
||||
* `null` when offline.
|
||||
*/
|
||||
getProjectFileIndex: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), scope: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.getProjectFileIndex({
|
||||
deviceId: input.deviceId,
|
||||
scope: input.scope,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revert a single file in a directory on a remote device, via the device's
|
||||
* `revertGitFile` RPC.
|
||||
*/
|
||||
revertGitFile: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), filePath: z.string(), path: z.string() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.revertGitFile({
|
||||
deviceId: input.deviceId,
|
||||
filePath: input.filePath,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Check whether a path exists on a remote device and is a directory, via the
|
||||
* device's `statPath` RPC. Lets a web client validate a manually-entered
|
||||
* working directory before binding it. Returns `null` when the device is
|
||||
* unreachable (caller treats "can't verify" as non-blocking).
|
||||
*/
|
||||
statPath: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.statPath({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
@@ -154,7 +356,7 @@ export const deviceRouter = router({
|
||||
* flight). Those are surfaced as transient entries so the picker never loses
|
||||
* a currently-reachable device during rollout.
|
||||
*/
|
||||
listDevices: deviceProcedure.query(async ({ ctx }) => {
|
||||
listDevices: deviceProcedure.query(async ({ ctx }): Promise<DeviceListItem[]> => {
|
||||
const [registered, onlineList] = await Promise.all([
|
||||
ctx.deviceModel.query(),
|
||||
deviceGateway.queryDeviceList(ctx.userId),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/database/schemas';
|
||||
import type { WorkingDirEntry } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Re-attach the server-owned workspace-init cache (`workspace` /
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/database/schemas';
|
||||
import type { WorkspaceInitResult } from '@lobechat/types';
|
||||
import type { WorkingDirEntry, WorkspaceInitResult } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
ExecGroupAgentResult,
|
||||
ExecSubAgentTaskParams,
|
||||
ExecSubAgentTaskResult,
|
||||
LobeAgentAgencyConfig,
|
||||
MessagePluginItem,
|
||||
UserInterventionConfig,
|
||||
WorkspaceInitResult,
|
||||
@@ -320,10 +321,11 @@ export class AiAgentService {
|
||||
*/
|
||||
private async resolveWorkspaceInit(params: {
|
||||
activeDeviceId: string | undefined;
|
||||
agencyConfig?: LobeAgentAgencyConfig;
|
||||
topicId: string;
|
||||
}): Promise<WorkspaceInitResult> {
|
||||
const empty: WorkspaceInitResult = { instructions: [], skills: [] };
|
||||
const { activeDeviceId, topicId } = params;
|
||||
const { activeDeviceId, agencyConfig, topicId } = params;
|
||||
if (!activeDeviceId) return empty;
|
||||
|
||||
try {
|
||||
@@ -331,10 +333,15 @@ export class AiAgentService {
|
||||
const device = await deviceModel.findByDeviceId(activeDeviceId);
|
||||
if (!device) return empty;
|
||||
|
||||
// The bound project root: a topic-pinned cwd wins, else the device default
|
||||
// (mirrors the hetero dispatch resolution). This is the directory we scan.
|
||||
// The bound project root (unified precedence, mirrors hetero dispatch):
|
||||
// topic override > agent's per-device choice > device default.
|
||||
// This is the directory we scan.
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const boundCwd = topic?.metadata?.workingDirectory || device.defaultCwd || undefined;
|
||||
const boundCwd =
|
||||
topic?.metadata?.workingDirectory ||
|
||||
agencyConfig?.workingDirByDevice?.[activeDeviceId] ||
|
||||
device.defaultCwd ||
|
||||
undefined;
|
||||
if (!boundCwd) return empty;
|
||||
|
||||
const workingDirs = device.workingDirs ?? [];
|
||||
@@ -345,7 +352,11 @@ export class AiAgentService {
|
||||
return cached.workspace;
|
||||
}
|
||||
|
||||
const scanned = await deviceGateway.initWorkspace(this.userId, activeDeviceId, boundCwd);
|
||||
const scanned = await deviceGateway.initWorkspace({
|
||||
deviceId: activeDeviceId,
|
||||
scope: boundCwd,
|
||||
userId: this.userId,
|
||||
});
|
||||
if (!scanned) {
|
||||
// Scan failed (offline mid-run / parse error). Fall back to a stale
|
||||
// cache rather than dropping the project's skills + instructions.
|
||||
@@ -1065,12 +1076,14 @@ export class AiAgentService {
|
||||
const boundDevice = await new DeviceModel(this.db, this.userId).findByDeviceId(
|
||||
dispatchDeviceId,
|
||||
);
|
||||
// Prefer the topic's own pinned cwd — an existing topic carries it in
|
||||
// `metadata.workingDirectory`, whereas `initialTopicMetadata` is only
|
||||
// populated for a brand-new topic. Fall back to the device default.
|
||||
// Working-directory precedence (unified across client + server):
|
||||
// topic override > agent's per-device choice > device default.
|
||||
// An existing topic carries its pinned cwd in `metadata.workingDirectory`;
|
||||
// `initialTopicMetadata` is only populated for a brand-new topic.
|
||||
const deviceCwd =
|
||||
topic?.metadata?.workingDirectory ||
|
||||
appContext?.initialTopicMetadata?.workingDirectory ||
|
||||
agentConfig.agencyConfig?.workingDirByDevice?.[dispatchDeviceId] ||
|
||||
boundDevice?.defaultCwd ||
|
||||
undefined;
|
||||
|
||||
@@ -2346,7 +2359,11 @@ export class AiAgentService {
|
||||
// re-gates on `activeDeviceId`). Only `location` (the absolute SKILL.md
|
||||
// path) flows through; the directory tree is enumerated lazily, keeping the
|
||||
// op-param payload small.
|
||||
const workspaceInit = await this.resolveWorkspaceInit({ activeDeviceId, topicId });
|
||||
const workspaceInit = await this.resolveWorkspaceInit({
|
||||
activeDeviceId,
|
||||
agencyConfig: agentConfig.agencyConfig ?? undefined,
|
||||
topicId,
|
||||
});
|
||||
|
||||
const projectMetas = workspaceInit.skills.map((s) => ({
|
||||
description: s.description ?? '',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/database/schemas';
|
||||
import type { WorkspaceInitResult } from '@lobechat/types';
|
||||
import type { WorkingDirEntry, WorkspaceInitResult } from '@lobechat/types';
|
||||
|
||||
/** Reuse a cached workspace-init scan for this long before re-scanning the device. */
|
||||
export const WORKSPACE_INIT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -429,7 +429,11 @@ describe('DeviceGateway', () => {
|
||||
|
||||
it('should return undefined when not configured', async () => {
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.initWorkspace('user-1', 'dev-1', '/proj');
|
||||
const result = await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -456,7 +460,11 @@ describe('DeviceGateway', () => {
|
||||
});
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.initWorkspace('user-1', 'dev-1', '/proj');
|
||||
const result = await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
instructions: [{ content: '# Rules', source: 'AGENTS.md' }],
|
||||
@@ -479,7 +487,11 @@ describe('DeviceGateway', () => {
|
||||
mockClient.invokeRpc.mockResolvedValue({ data: {}, success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.initWorkspace('user-1', 'dev-1', '/proj');
|
||||
const result = await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ instructions: [], skills: [] });
|
||||
});
|
||||
@@ -489,7 +501,11 @@ describe('DeviceGateway', () => {
|
||||
mockClient.invokeRpc.mockResolvedValue({ error: 'offline', success: false });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.initWorkspace('user-1', 'dev-1', '/proj');
|
||||
const result = await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -499,7 +515,11 @@ describe('DeviceGateway', () => {
|
||||
mockClient.invokeRpc.mockResolvedValue({ success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.initWorkspace('user-1', 'dev-1', '/proj');
|
||||
const result = await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -509,7 +529,11 @@ describe('DeviceGateway', () => {
|
||||
mockClient.invokeRpc.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.initWorkspace('user-1', 'dev-1', '/proj');
|
||||
const result = await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -522,7 +546,12 @@ describe('DeviceGateway', () => {
|
||||
});
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
await proxy.initWorkspace('user-1', 'dev-1', '/proj', 60_000);
|
||||
await proxy.initWorkspace({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
timeout: 60_000,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 60_000, userId: 'user-1' },
|
||||
|
||||
@@ -8,7 +8,23 @@ import {
|
||||
type GatewayMcpStdioParams,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
import type { DeviceGitInfo, ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
|
||||
import type {
|
||||
DeviceGitAheadBehind,
|
||||
DeviceGitBranchDiffPatches,
|
||||
DeviceGitBranchInfo,
|
||||
DeviceGitBranchListItem,
|
||||
DeviceGitCheckoutResult,
|
||||
DeviceGitFileRevertResult,
|
||||
DeviceGitLinkedPullRequestResult,
|
||||
DeviceGitRemoteBranchListItem,
|
||||
DeviceGitSyncResult,
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
DeviceGitWorkingTreeStatus,
|
||||
DeviceProjectFileIndexResult,
|
||||
ProjectSkillMeta,
|
||||
WorkspaceInitResult,
|
||||
} from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { gatewayEnv } from '@/envs/gateway';
|
||||
@@ -89,12 +105,13 @@ export class DeviceGateway {
|
||||
* Returns `undefined` when the gateway is unconfigured, the device is offline,
|
||||
* or the call fails — callers fall back to the cached scan.
|
||||
*/
|
||||
async initWorkspace(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
scope: string,
|
||||
timeout = 30_000,
|
||||
): Promise<WorkspaceInitResult | undefined> {
|
||||
async initWorkspace(params: {
|
||||
deviceId: string;
|
||||
scope: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<WorkspaceInitResult | undefined> {
|
||||
const { userId, deviceId, scope, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
@@ -128,34 +145,424 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch git status (branch / file changes / PR) for a directory on a remote
|
||||
* device, via the same generic `invokeRpc` channel as `initWorkspace`. Lets
|
||||
* the UI render a remote device's git the same as the local desktop.
|
||||
* Generic helper for the granular git read RPCs (branch / PR / working-tree /
|
||||
* ahead-behind). Returns `undefined` when the gateway is unconfigured, the
|
||||
* device is offline, or the call fails — callers treat that as "unknown".
|
||||
*/
|
||||
async gitInfo(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
scope: string,
|
||||
isGithub = false,
|
||||
timeout = 15_000,
|
||||
): Promise<DeviceGitInfo | undefined> {
|
||||
private async invokeGitRead<T>(
|
||||
method: string,
|
||||
params: { deviceId: string; timeout?: number; userId: string },
|
||||
rpcParams: Record<string, unknown>,
|
||||
): Promise<T | undefined> {
|
||||
const { userId, deviceId, timeout = 15_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitInfo>(
|
||||
const result = await client.invokeRpc<T>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'gitInfo', params: { isGithub, scope } },
|
||||
{ method, params: rpcParams },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('gitInfo: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
if (!result.success || result.data === undefined) {
|
||||
log('%s: failed for deviceId=%s — %s', method, deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('gitInfo: error for deviceId=%s — %O', deviceId, error);
|
||||
log('%s: error for deviceId=%s — %O', method, deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Branch name + detached flag for a directory on a remote device. */
|
||||
gitBranch(params: { deviceId: string; path: string; userId: string }) {
|
||||
return this.invokeGitRead<DeviceGitBranchInfo>('getGitBranch', params, { path: params.path });
|
||||
}
|
||||
|
||||
/** The GitHub PR linked to a branch in a directory on a remote device. */
|
||||
gitLinkedPullRequest(params: { branch: string; deviceId: string; path: string; userId: string }) {
|
||||
return this.invokeGitRead<DeviceGitLinkedPullRequestResult>('getLinkedPullRequest', params, {
|
||||
branch: params.branch,
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
/** Working-tree dirty-file counts for a directory on a remote device. */
|
||||
gitWorkingTreeStatus(params: { deviceId: string; path: string; userId: string }) {
|
||||
return this.invokeGitRead<DeviceGitWorkingTreeStatus>('getGitWorkingTreeStatus', params, {
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
/** Ahead/behind commit counts for a directory on a remote device. */
|
||||
gitAheadBehind(params: { deviceId: string; path: string; userId: string }) {
|
||||
return this.invokeGitRead<DeviceGitAheadBehind>('getGitAheadBehind', params, {
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the local branches of a directory on a remote device via the
|
||||
* `listGitBranches` device RPC, so the web/remote branch switcher can populate
|
||||
* the same dropdown the local desktop renders over IPC.
|
||||
*/
|
||||
async listGitBranches(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitBranchListItem[] | undefined> {
|
||||
const { userId, deviceId, path, timeout = 15_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitBranchListItem[]>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'listGitBranches', params: { path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('listGitBranches: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('listGitBranches: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout (or create) a branch in a directory on a remote device via the
|
||||
* `checkoutGitBranch` device RPC.
|
||||
*/
|
||||
async checkoutGitBranch(params: {
|
||||
branch: string;
|
||||
create?: boolean;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitCheckoutResult> {
|
||||
const { userId, deviceId, branch, create, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitCheckoutResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'checkoutGitBranch', params: { branch, create, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('checkoutGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Checkout failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('checkoutGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Checkout failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device via
|
||||
* the `pullGitBranch` device RPC.
|
||||
*/
|
||||
async pullGitBranch(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitSyncResult> {
|
||||
const { userId, deviceId, path, timeout = 65_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitSyncResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'pullGitBranch', params: { path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('pullGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Pull failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('pullGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Pull failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current branch of a directory on a remote device via the
|
||||
* `pushGitBranch` device RPC.
|
||||
*/
|
||||
async pushGitBranch(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitSyncResult> {
|
||||
const { userId, deviceId, path, timeout = 65_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitSyncResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'pushGitBranch', params: { path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('pushGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Push failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('pushGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Push failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Working-tree (unstaged) per-file patches for a directory on a remote device
|
||||
* via the `getGitWorkingTreePatches` device RPC, so the web/remote Review panel
|
||||
* renders the same diffs the local desktop shows over IPC.
|
||||
*/
|
||||
async getGitWorkingTreePatches(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitWorkingTreePatches | undefined> {
|
||||
const { userId, deviceId, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitWorkingTreePatches>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'getGitWorkingTreePatches', params: { path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('getGitWorkingTreePatches: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('getGitWorkingTreePatches: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch diff (current branch vs base ref) per-file patches for a directory on
|
||||
* a remote device via the `getGitBranchDiff` device RPC.
|
||||
*/
|
||||
async getGitBranchDiff(params: {
|
||||
baseRef?: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitBranchDiffPatches | undefined> {
|
||||
const { userId, deviceId, baseRef, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitBranchDiffPatches>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'getGitBranchDiff', params: { baseRef, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('getGitBranchDiff: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('getGitBranchDiff: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo-relative paths of dirty working-tree files for a directory on a remote
|
||||
* device via the `getGitWorkingTreeFiles` device RPC — the Files tab's git
|
||||
* status overlay.
|
||||
*/
|
||||
async getGitWorkingTreeFiles(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitWorkingTreeFiles | undefined> {
|
||||
const { userId, deviceId, path, timeout = 15_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitWorkingTreeFiles>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'getGitWorkingTreeFiles', params: { path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('getGitWorkingTreeFiles: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('getGitWorkingTreeFiles: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project file index (tree) for a directory on a remote device via the
|
||||
* `getProjectFileIndex` device RPC — the Files tab's tree.
|
||||
*/
|
||||
async getProjectFileIndex(params: {
|
||||
deviceId: string;
|
||||
scope: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceProjectFileIndexResult | undefined> {
|
||||
const { userId, deviceId, scope, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceProjectFileIndexResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'getProjectFileIndex', params: { scope } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('getProjectFileIndex: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('getProjectFileIndex: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the remote branches (`refs/remotes/origin/*`) of a directory on a
|
||||
* remote device via the `listGitRemoteBranches` device RPC, so the web/remote
|
||||
* Review base-ref picker mirrors the local desktop's.
|
||||
*/
|
||||
async listGitRemoteBranches(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitRemoteBranchListItem[] | undefined> {
|
||||
const { userId, deviceId, path, timeout = 15_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitRemoteBranchListItem[]>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'listGitRemoteBranches', params: { path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('listGitRemoteBranches: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('listGitRemoteBranches: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert (discard working-tree changes to) a single file in a directory on a
|
||||
* remote device via the `revertGitFile` device RPC.
|
||||
*/
|
||||
async revertGitFile(params: {
|
||||
deviceId: string;
|
||||
filePath: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitFileRevertResult> {
|
||||
const { userId, deviceId, filePath, path, timeout = 15_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitFileRevertResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'revertGitFile', params: { filePath, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('revertGitFile: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Revert failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('revertGitFile: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Revert failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists on the device and is a directory, via the same
|
||||
* generic `invokeRpc` channel as `gitInfo`. Lets a web / remote client
|
||||
* validate a manually-entered working directory before binding it. Returns
|
||||
* `undefined` when the gateway is unconfigured or the device is unreachable
|
||||
* (the caller treats "can't verify" as non-blocking).
|
||||
*/
|
||||
async statPath(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' } | undefined> {
|
||||
const { userId, deviceId, path, timeout = 8000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<{
|
||||
exists: boolean;
|
||||
isDirectory: boolean;
|
||||
repoType?: 'git' | 'github';
|
||||
}>({ deviceId, timeout, userId }, { method: 'statPath', params: { path } });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('statPath: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('statPath: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
|
||||
type DeviceClient = typeof lambdaClient.device;
|
||||
|
||||
/**
|
||||
* Single chokepoint for the `device` TRPC router. Components, hooks and stores
|
||||
* should call this instead of reaching into `lambdaClient.device.*` directly.
|
||||
*/
|
||||
class DeviceService {
|
||||
/** All devices the user has registered (incl. offline) + live gateway sessions. */
|
||||
listDevices() {
|
||||
return lambdaClient.device.listDevices.query();
|
||||
}
|
||||
|
||||
/** Update user-editable device fields (defaultCwd / friendlyName / workingDirs). */
|
||||
updateDevice(input: Parameters<DeviceClient['updateDevice']['mutate']>[0]) {
|
||||
return lambdaClient.device.updateDevice.mutate(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists on a device and is a directory (via the device's
|
||||
* `statPath` RPC). Returns `null` when the device is unreachable — callers
|
||||
* treat "can't verify" as non-blocking.
|
||||
*/
|
||||
statPath(deviceId: string, path: string) {
|
||||
return lambdaClient.device.statPath.query({ deviceId, path });
|
||||
}
|
||||
|
||||
/** Probe whether an agent platform (openclaw / hermes) is available on a device. */
|
||||
checkCapability(input: Parameters<DeviceClient['checkCapability']['query']>[0]) {
|
||||
return lambdaClient.device.checkCapability.query(input);
|
||||
}
|
||||
|
||||
/** Fetch the agent profile (title / description / avatar) from a device platform. */
|
||||
getAgentProfile(input: Parameters<DeviceClient['getAgentProfile']['query']>[0]) {
|
||||
return lambdaClient.device.getAgentProfile.query(input);
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceService = new DeviceService();
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
GitBranchDiffPatches,
|
||||
GitFileRevertResult,
|
||||
GitRemoteBranchListItem,
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatches,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import type {
|
||||
DeviceGitAheadBehind,
|
||||
DeviceGitBranchListItem,
|
||||
DeviceGitCheckoutResult,
|
||||
DeviceGitLinkedPullRequest,
|
||||
DeviceGitSyncResult,
|
||||
DeviceGitWorkingTreeStatus,
|
||||
} from '@lobechat/types';
|
||||
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { electronGitService } from '@/services/electron/git';
|
||||
|
||||
/** Branch + linked-PR summary, composed from the branch and PR reads. */
|
||||
export interface GitInfo {
|
||||
branch?: string;
|
||||
detached?: boolean;
|
||||
extraCount?: number;
|
||||
ghMissing?: boolean;
|
||||
pullRequest?: DeviceGitLinkedPullRequest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git operations chokepoint. Each call picks its own transport from `deviceId`:
|
||||
* a remote / web target (deviceId set) goes through the `device.*` TRPC RPCs;
|
||||
* the local desktop talks to Electron over IPC. UI / store / hooks only see this
|
||||
* service — the electron-vs-lambda decision never leaks up. Reads and writes are
|
||||
* symmetric: both transports expose the same granular git operations.
|
||||
*/
|
||||
class GitService {
|
||||
/** Local branches of a working directory. */
|
||||
listGitBranches({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitBranchListItem[]> {
|
||||
return deviceId
|
||||
? lambdaClient.device.listGitBranches.query({ deviceId, path })
|
||||
: electronGitService.listGitBranches(path);
|
||||
}
|
||||
|
||||
/** Checkout (or create) a branch in a working directory. */
|
||||
checkoutGitBranch({
|
||||
branch,
|
||||
create,
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
branch: string;
|
||||
create?: boolean;
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitCheckoutResult> {
|
||||
return deviceId
|
||||
? lambdaClient.device.checkoutGitBranch.mutate({ branch, create, deviceId, path })
|
||||
: electronGitService.checkoutGitBranch({ branch, create, path });
|
||||
}
|
||||
|
||||
/** Pull (`--ff-only`) the current branch of a working directory. */
|
||||
pullGitBranch({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitSyncResult> {
|
||||
return deviceId
|
||||
? lambdaClient.device.pullGitBranch.mutate({ deviceId, path })
|
||||
: electronGitService.pullGitBranch({ path });
|
||||
}
|
||||
|
||||
/** Push the current branch of a working directory. */
|
||||
pushGitBranch({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitSyncResult> {
|
||||
return deviceId
|
||||
? lambdaClient.device.pushGitBranch.mutate({ deviceId, path })
|
||||
: electronGitService.pushGitBranch({ path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch + linked PR summary. Composes a branch read with a conditional PR
|
||||
* lookup (skipped for detached HEAD / non-github repos) — both legs dispatch
|
||||
* per `deviceId`, so the gh-CLI lookup runs on whichever machine owns the repo.
|
||||
*/
|
||||
async getGitInfo({
|
||||
deviceId,
|
||||
isGithub,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
isGithub?: boolean;
|
||||
path: string;
|
||||
}): Promise<GitInfo> {
|
||||
const branchInfo = deviceId
|
||||
? await lambdaClient.device.gitBranch.query({ deviceId, path })
|
||||
: await electronGitService.getGitBranch(path);
|
||||
const branch = branchInfo?.branch;
|
||||
const detached = branchInfo?.detached;
|
||||
if (!branch) return {};
|
||||
|
||||
// Skip the PR lookup for detached HEAD or non-github repos.
|
||||
if (detached || !isGithub) return { branch, detached };
|
||||
|
||||
const pr = deviceId
|
||||
? await lambdaClient.device.gitLinkedPullRequest.query({ branch, deviceId, path })
|
||||
: await electronGitService.getLinkedPullRequest({ branch, path });
|
||||
if (!pr) return { branch, detached };
|
||||
|
||||
return {
|
||||
branch,
|
||||
detached,
|
||||
extraCount: pr.extraCount,
|
||||
ghMissing: pr.status === 'gh-missing',
|
||||
pullRequest: pr.pullRequest,
|
||||
};
|
||||
}
|
||||
|
||||
/** Working-tree dirty-file counts for a working directory. */
|
||||
async getGitWorkingTreeStatus({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitWorkingTreeStatus | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.gitWorkingTreeStatus.query({ deviceId, path })) ?? undefined)
|
||||
: electronGitService.getGitWorkingTreeStatus(path);
|
||||
}
|
||||
|
||||
/** Ahead/behind commit counts for the current branch vs its upstream. */
|
||||
async getGitAheadBehind({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitAheadBehind | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.gitAheadBehind.query({ deviceId, path })) ?? undefined)
|
||||
: electronGitService.getGitAheadBehind(path);
|
||||
}
|
||||
|
||||
/** Working-tree (unstaged) per-file patches for a working directory. */
|
||||
async getGitWorkingTreePatches({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<GitWorkingTreePatches | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.getGitWorkingTreePatches.query({ deviceId, path })) ??
|
||||
undefined)
|
||||
: electronGitService.getGitWorkingTreePatches(path);
|
||||
}
|
||||
|
||||
/** Repo-relative paths of dirty working-tree files (the Files tab git overlay). */
|
||||
async getGitWorkingTreeFiles({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<GitWorkingTreeFiles | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.getGitWorkingTreeFiles.query({ deviceId, path })) ?? undefined)
|
||||
: electronGitService.getGitWorkingTreeFiles(path);
|
||||
}
|
||||
|
||||
/** Branch diff (current branch vs base ref) per-file patches for a working directory. */
|
||||
async getGitBranchDiff({
|
||||
baseRef,
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
baseRef?: string;
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<GitBranchDiffPatches | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.getGitBranchDiff.query({ baseRef, deviceId, path })) ??
|
||||
undefined)
|
||||
: electronGitService.getGitBranchDiff({ baseRef, path });
|
||||
}
|
||||
|
||||
/** Remote branches (`refs/remotes/origin/*`) of a working directory. */
|
||||
listGitRemoteBranches({
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<GitRemoteBranchListItem[]> {
|
||||
return deviceId
|
||||
? lambdaClient.device.listGitRemoteBranches.query({ deviceId, path })
|
||||
: electronGitService.listGitRemoteBranches(path);
|
||||
}
|
||||
|
||||
/** Revert (discard working-tree changes to) a single file in a working directory. */
|
||||
revertGitFile({
|
||||
deviceId,
|
||||
filePath,
|
||||
path,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
filePath: string;
|
||||
path: string;
|
||||
}): Promise<GitFileRevertResult> {
|
||||
return deviceId
|
||||
? lambdaClient.device.revertGitFile.mutate({ deviceId, filePath, path })
|
||||
: electronGitService.revertGitFile({ filePath, path });
|
||||
}
|
||||
}
|
||||
|
||||
export const gitService = new GitService();
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ProjectFileIndexResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
|
||||
/**
|
||||
* Project file tree chokepoint. Picks the transport per call from `deviceId`: a
|
||||
* remote / web target goes through the `device.getProjectFileIndex` RPC; the
|
||||
* local desktop talks to Electron over IPC. UI / store only see this service —
|
||||
* the electron-vs-lambda decision never leaks up. (Parallels `gitService`.)
|
||||
*/
|
||||
class ProjectFileService {
|
||||
/** Project file index (tree) for a working directory. */
|
||||
async getProjectFileIndex({
|
||||
deviceId,
|
||||
scope,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
scope: string;
|
||||
}): Promise<ProjectFileIndexResult | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.getProjectFileIndex.query({ deviceId, scope })) ?? undefined)
|
||||
: localFileService.getProjectFileIndex({ scope });
|
||||
}
|
||||
}
|
||||
|
||||
export const projectFileService = new ProjectFileService();
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@lobechat/const';
|
||||
import { type LobeAgentChatConfig, type RuntimeEnvMode } from '@lobechat/types';
|
||||
|
||||
import { resolveRuntimeMode } from '@/helpers/executionTarget';
|
||||
import { type AgentStoreState } from '@/store/agent/initialState';
|
||||
|
||||
import { agentSelectors } from './selectors';
|
||||
@@ -59,16 +60,22 @@ const isLocalSystemEnabledById = (agentId: string) => (s: AgentStoreState) =>
|
||||
getRuntimeModeById(agentId)(s) === 'local';
|
||||
|
||||
/**
|
||||
* Get runtime environment mode by agent ID.
|
||||
* Reads from `runtimeMode[platform]`, defaults to 'local' on desktop, 'none' on web.
|
||||
* Get the agent's runtime mode, derived from the unified
|
||||
* `agencyConfig.executionTarget` (sandbox → cloud, local → local, device →
|
||||
* none), falling back to the legacy per-platform `runtimeMode` for agents that
|
||||
* predate `executionTarget`.
|
||||
*/
|
||||
const getRuntimeModeById =
|
||||
(agentId: string) =>
|
||||
(s: AgentStoreState): RuntimeEnvMode => {
|
||||
const runtimeEnv = getChatConfigById(agentId)(s).runtimeEnv;
|
||||
const config = agentSelectors.getAgentConfigById(agentId)(s);
|
||||
const platform = isDesktop ? 'desktop' : 'web';
|
||||
|
||||
return runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
|
||||
return resolveRuntimeMode(
|
||||
config?.agencyConfig,
|
||||
config?.chatConfig?.runtimeEnv?.runtimeMode?.[platform],
|
||||
isDesktop,
|
||||
);
|
||||
};
|
||||
|
||||
const getSkillActivateModeById =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isDesktop as defaultIsDesktop } from '@lobechat/const';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { type HeteroExecutionTarget, type HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
import { type DeviceExecutionTarget, type HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Which agent runtime should handle an operation.
|
||||
@@ -56,7 +56,7 @@ export interface RuntimeSelectionContext {
|
||||
* - `'local'` / `undefined` → keep today's default (desktop → `hetero`
|
||||
* in-process spawn, web → `gateway` sandbox).
|
||||
*/
|
||||
executionTarget?: HeteroExecutionTarget;
|
||||
executionTarget?: DeviceExecutionTarget;
|
||||
/** Per-agent heterogeneous provider config (desktop only — takes priority over gateway). */
|
||||
heterogeneousProvider?: HeterogeneousProviderConfig;
|
||||
/** Result of `chatStore.isGatewayModeEnabled()`. */
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { DeviceListItem, WorkingDirEntry } from '@lobechat/types';
|
||||
import { type SWRResponse } from 'swr';
|
||||
|
||||
import { mutate, useClientDataSWR } from '@/libs/swr';
|
||||
import { deviceService } from '@/services/device';
|
||||
import { type StoreSetter } from '@/store/types';
|
||||
|
||||
import { nextWorkingDirs, removeWorkingDir, WORKING_DIRS_MAX } from './deviceCwd';
|
||||
import { type DeviceStore } from './store';
|
||||
|
||||
const FETCH_DEVICES_KEY = 'device:listDevices';
|
||||
|
||||
type Setter = StoreSetter<DeviceStore>;
|
||||
|
||||
export const deviceSlice = (set: Setter, get: () => DeviceStore, _api?: unknown) =>
|
||||
new DeviceActionImpl(set, get, _api);
|
||||
|
||||
export class DeviceActionImpl {
|
||||
readonly #get: () => DeviceStore;
|
||||
readonly #set: Setter;
|
||||
|
||||
constructor(set: Setter, get: () => DeviceStore, _api?: unknown) {
|
||||
void _api;
|
||||
this.#set = set;
|
||||
this.#get = get;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a working-directory choice to a device (`defaultCwd` + `workingDirs`)
|
||||
* with an optimistic store update, then revalidate from the server. Pass
|
||||
* `setDefault: false` to record the dir in the recent list without repointing
|
||||
* the device's default cwd.
|
||||
*/
|
||||
updateDeviceCwd = async (
|
||||
deviceId: string,
|
||||
entry: WorkingDirEntry,
|
||||
options: { setDefault?: boolean } = {},
|
||||
): Promise<void> => {
|
||||
const trimmed = entry.path.trim();
|
||||
if (!trimmed) return;
|
||||
const setDefault = options.setDefault ?? true;
|
||||
|
||||
const device = this.#get().devices.find((d) => d.deviceId === deviceId);
|
||||
const updatedDirs = nextWorkingDirs(entry, device?.workingDirs ?? []);
|
||||
|
||||
// Optimistic: patch the touched device in place. Spreading widens the item
|
||||
// out of the listDevices union, so re-assert the element type.
|
||||
this.#set(
|
||||
{
|
||||
devices: this.#get().devices.map((d) =>
|
||||
d.deviceId === deviceId
|
||||
? { ...d, ...(setDefault ? { defaultCwd: trimmed } : {}), workingDirs: updatedDirs }
|
||||
: d,
|
||||
) as DeviceListItem[],
|
||||
},
|
||||
false,
|
||||
'updateDeviceCwd',
|
||||
);
|
||||
|
||||
try {
|
||||
await deviceService.updateDevice({
|
||||
deviceId,
|
||||
...(setDefault ? { defaultCwd: trimmed } : {}),
|
||||
workingDirs: updatedDirs,
|
||||
});
|
||||
} finally {
|
||||
// Re-fetch the truth (self-corrects a failed optimistic write).
|
||||
await mutate(FETCH_DEVICES_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge legacy recent dirs (read from localStorage by the caller — the store
|
||||
* stays out of feature-layer storage) into a device's `device.workingDirs`.
|
||||
* Existing device entries win on conflict. Rejects if the persist fails so the
|
||||
* caller can keep localStorage for a retry; resolves once safely merged.
|
||||
*/
|
||||
migrateLocalRecentsToDevice = async (
|
||||
deviceId: string,
|
||||
legacyEntries: WorkingDirEntry[],
|
||||
): Promise<void> => {
|
||||
if (legacyEntries.length === 0) return;
|
||||
|
||||
const device = this.#get().devices.find((d) => d.deviceId === deviceId);
|
||||
const existing = device?.workingDirs ?? [];
|
||||
const existingPaths = new Set(existing.map((d) => d.path));
|
||||
const merged = [...existing, ...legacyEntries.filter((d) => !existingPaths.has(d.path))].slice(
|
||||
0,
|
||||
WORKING_DIRS_MAX,
|
||||
);
|
||||
|
||||
this.#set(
|
||||
{
|
||||
devices: this.#get().devices.map((d) =>
|
||||
d.deviceId === deviceId ? { ...d, workingDirs: merged } : d,
|
||||
) as DeviceListItem[],
|
||||
},
|
||||
false,
|
||||
'migrateLocalRecents',
|
||||
);
|
||||
|
||||
try {
|
||||
await deviceService.updateDevice({ deviceId, workingDirs: merged });
|
||||
} finally {
|
||||
await mutate(FETCH_DEVICES_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
/** Remove a path from a device's `workingDirs` recent list (optimistic). */
|
||||
removeDeviceWorkingDir = async (deviceId: string, path: string): Promise<void> => {
|
||||
const device = this.#get().devices.find((d) => d.deviceId === deviceId);
|
||||
if (!device) return;
|
||||
const updated = removeWorkingDir(path, device.workingDirs ?? []);
|
||||
|
||||
this.#set(
|
||||
{
|
||||
devices: this.#get().devices.map((d) =>
|
||||
d.deviceId === deviceId ? { ...d, workingDirs: updated } : d,
|
||||
) as DeviceListItem[],
|
||||
},
|
||||
false,
|
||||
'removeDeviceWorkingDir',
|
||||
);
|
||||
|
||||
try {
|
||||
await deviceService.updateDevice({ deviceId, workingDirs: updated });
|
||||
} finally {
|
||||
await mutate(FETCH_DEVICES_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
useFetchDevices = (enabled = true): SWRResponse<DeviceListItem[]> =>
|
||||
useClientDataSWR<DeviceListItem[]>(
|
||||
enabled ? FETCH_DEVICES_KEY : null,
|
||||
() => deviceService.listDevices(),
|
||||
{
|
||||
fallbackData: [],
|
||||
onSuccess: (data) => {
|
||||
this.#set({ devices: data, isDevicesInit: true }, false, 'fetchDevices');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type DeviceAction = Pick<DeviceActionImpl, keyof DeviceActionImpl>;
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { nextWorkingDirs, WORKING_DIRS_MAX, type WorkingDirEntry } from './deviceCwd';
|
||||
import { nextWorkingDirs, WORKING_DIRS_MAX } from './deviceCwd';
|
||||
|
||||
const entry = (path: string, repoType?: 'git' | 'github'): WorkingDirEntry => ({ path, repoType });
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import type { WorkspaceInitResult } from '@lobechat/types';
|
||||
import type { WorkingDirEntry } from '@lobechat/types';
|
||||
|
||||
/** Max number of working directories persisted per device. Matches the
|
||||
* `workingDirs` cap enforced by the `device.updateDevice` tRPC input. */
|
||||
export const WORKING_DIRS_MAX = 20;
|
||||
|
||||
/**
|
||||
* A working directory recorded on a device. Structured so metadata such as the
|
||||
* detected repo type survives across machines — a remote client viewing this
|
||||
* device can't re-probe its filesystem. Mirrors the DB `WorkingDirEntry`.
|
||||
*/
|
||||
export interface WorkingDirEntry {
|
||||
path: string;
|
||||
repoType?: 'git' | 'github';
|
||||
/** Cached "workspace init" scan (AGENTS.md + project skills). See DB `WorkingDirEntry`. */
|
||||
workspace?: WorkspaceInitResult;
|
||||
/** Epoch ms of the last `workspace` scan (top-level for cheap freshness checks). */
|
||||
workspaceScannedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next `workingDirs` list after the user picks `entry`: move it to
|
||||
* the front (most-recent-first), drop any earlier entry with the same path, and
|
||||
@@ -35,3 +21,10 @@ export const nextWorkingDirs = (
|
||||
if (!path) return [...current];
|
||||
return [{ ...entry, path }, ...current.filter((d) => d.path !== path)].slice(0, max);
|
||||
};
|
||||
|
||||
/** Drop a path from a device's `workingDirs` recent list (used by the picker's
|
||||
* remove-recent affordance). */
|
||||
export const removeWorkingDir = (
|
||||
path: string,
|
||||
current: readonly WorkingDirEntry[] = [],
|
||||
): WorkingDirEntry[] => current.filter((d) => d.path !== path);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user