Compare commits

...

15 Commits

Author SHA1 Message Date
Arvin Xu 9b13cbd145 ♻️ refactor(agent): rename WorkingDirectoryBar to HeteroControlBar
Make the heterogeneous chat-input bar a symmetric sibling of ControlBar:
both compose the shared WorkspaceControls, so naming should match. Rename
the file, component and displayName, and update the controlBarSlot usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:51:07 +08:00
Arvin Xu 20dbca6955 ♻️ refactor(chat-input): rename RuntimeConfig to ControlBar
The bar below the chat input now composes mode switcher, execution
device + working directory, approval mode and context window — "runtime
config" no longer matches. Rename the directory, component, and the
showRuntimeConfig / runtimeConfigSlot props (→ showControlBar /
controlBarSlot) across all call sites. Reads as a sibling of ActionBar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:46:01 +08:00
Arvin Xu bf29b0d450 ♻️ refactor(git): move git info logic into local-file-shell (one impl, CLI-ready)
Extract the working-directory git read logic into `@lobechat/local-file-shell`
(shared by desktop + CLI), so there's one implementation behind the desktop
display, the device `gitInfo` RPC, and anything the CLI builds.

- New `local-file-shell/src/git`: repoType helpers + getGitBranch /
  getLinkedPullRequest / getGitWorkingTreeStatus / getGitAheadBehind / gitInfo
  aggregate + the shared `DeviceGitInfo` type (pure node, moved verbatim).
- Desktop `GitCtr` git read methods now delegate to it; `gitInfo` is also an
  `@IpcMethod` now. `utils/git` re-exports the helpers (SystemCtr unaffected).

The CLI already depends on `@lobechat/local-file-shell`, so it can call `gitInfo`
directly — a headless `lh connect` device gaining git status is now just wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:33:55 +08:00
Arvin Xu 4062fda7c0 feat(agent): show git branch / changes / PR for remote devices over RPC
Make a working directory's git status readable for a remote device (and from
web), not just the local desktop — via the same generic device `invokeRpc`
channel as initWorkspace (no device-gateway change needed).

- `@lobechat/types`: `DeviceGitInfo` (branch + PR / working-tree / ahead-behind),
  structurally matching the desktop git shapes.
- desktop main: `GitCtr.gitInfo()` aggregates branch + linked PR + working-tree +
  ahead/behind; registered as the `gitInfo` method in GatewayConnectionCtr's RPC
  dispatch.
- server: `deviceGateway.gitInfo()` wrapper + a `device.gitInfo` TRPC query.
- client: `useDeviceGitInfo` hook; `GitStatus` gains a `deviceId` prop — remote is
  read-only (no branch switch / pull / push / review toggle). WorkingDirectorySection
  now renders git status for remote devices too (repoType from the cached
  workingDirs entry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:19:59 +08:00
Arvin Xu cedaef927c feat(agent): make the execution-device switcher the single runtime control
Graduate the device switcher out of labs: it's now the one control for where an
agent runs (云端沙箱 / 本机 / remote device), driven by the unified
`executionTarget`.

- RuntimeConfig drops the legacy local/cloud/none mode popover entirely.
- WorkspaceControls always renders the device switcher (no longer gated on the
  `enableExecutionDeviceSwitcher` lab flag).
- Remove the lab toggle + its selector + preference field; the Devices settings
  tab is now always available (you need it to manage bound devices).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:05:00 +08:00
Arvin Xu fadc10e487 ♻️ refactor(device-store): keep store→feature dependency one-directional
The device store action was importing feature-layer modules (deviceCwd +
recentDirs), reversing the store←feature flow.

- Move `deviceCwd` (WorkingDirEntry + nextWorkingDirs/removeWorkingDir, pure
  device-domain logic) into `src/store/device`; features now import it from
  `@/store/device`.
- Keep localStorage recents (`recentDirs`) in the feature layer: the migration
  hook moves there, reads/clears localStorage, and passes the entries *into* the
  store action — the store no longer touches feature storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:53:09 +08:00
Arvin Xu f3482487cc feat(agent): migrate legacy localStorage recents into device.workingDirs
One-time fold of the old `lobechat-recent-working-directories` localStorage list
into this machine's `device.workingDirs` (the unified recent source), then clear
localStorage. Existing device entries win on conflict; localStorage is cleared
only after a successful persist (failure retries next load); no-op once empty.
Runs once per session from the picker, guarded on `isDevicesInit` + a known
local deviceId (desktop only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:45:44 +08:00
Arvin Xu 79450589d8 ♻️ refactor(agent): device switcher writes only executionTarget
`executionTarget` is now the single execution-location param — the server tool
gate + client `getRuntimeModeById` derive `runtimeMode` from it. So the device
switcher stops writing the legacy per-platform `runtimeEnv.runtimeMode` record,
removing the desktop/web config split for device-switcher users.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:43:15 +08:00
Arvin Xu 810165c6c8 feat(agent): unify the working-directory picker for local + remote runs
Replace the two divergent pickers (WorkingDirectory + DeviceWorkingDirectory)
with one WorkingDirectoryPicker:
- recents come from the target device's `device.workingDirs` (one source for
  local and remote, via the device store)
- picks write through `useCommitWorkingDirectory` (topic override / agent
  per-device choice + upsert device.workingDirs)
- this machine → native folder dialog + Clear + remove-recent; a true remote
  device → manual path entry (its filesystem isn't browsable here)

WorkingDirectorySection now renders the unified picker + git status (git status
only on this machine — remote git is the over-RPC follow-up). Deletes the two
old pickers + useUpdateDeviceCwd (replaced by the device store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:41:22 +08:00
Arvin Xu 40e939ec39 feat(agent): unified per-device working directory + execution-target foundation
Establish one canonical model for "where an agent runs + its working directory",
shared by desktop and web, replacing the per-platform split.

cwd model:
- `agencyConfig.workingDirByDevice: Record<deviceId, string>` — agent-level cwd,
  keyed per device (local machine uses its own gateway deviceId), so switching
  the bound device never resolves a path that only exists elsewhere.
- Unified precedence (client + server): topic override >
  workingDirByDevice[targetDeviceId] > device.defaultCwd. Applied in the server
  `resolveWorkspaceInit` + hetero dispatch, and in a shared client resolver
  (`agentWorkingDirectory.ts`) + `useEffectiveWorkingDirectory` hook.
- New `src/store/device` zustand store (SWR fetch + updateDeviceCwd) so device
  data (defaultCwd / workingDirs) is readable from hooks AND store actions.
- `useCommitWorkingDirectory`: write rules (no topic → workingDirByDevice; topic
  → topic override; always upsert device.workingDirs).
- Project-skills reader now uses the unified cwd (fixes skills not loading when
  only a device default is set / local-device runs).

execution target:
- `executionTarget` (sandbox/local/device) becomes the single source; server
  tool gate + client `getRuntimeModeById` derive `runtimeMode` from it via
  `executionTarget.ts`, with a no-regression fallback to the legacy per-platform
  `runtimeMode` for agents that predate `executionTarget`.

Foundation only — picker merge, write-path wiring, localStorage migration, and
git/PR-over-RPC are follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:24:52 +08:00
Arvin Xu cc4d6839c7 feat(agent): animate subagent token count with count-up effect
Promote a shared AnimatedNumber into @lobechat/shared-tool-ui/components and
use it for the subagent metrics token total so it rolls up smoothly while
streaming instead of jumping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:21:42 +08:00
Arvin Xu 01815147d0 ♻️ refactor(agent): extract shared WorkspaceControls strip for both runtime bars
The device selector + working directory + git branch / file changes / PR info
were wired up separately in RuntimeConfig (normal agents) and WorkingDirectoryBar
(heterogeneous agents). Extract them into one WorkspaceControls component both
bars compose, so the Device/Branch/diff/PR cluster can't drift between them.

`alwaysShowWorkspace` keeps the one genuine behavior fork: heterogeneous agents
always run inside a working directory (show it on desktop regardless of mode),
while normal agents only surface it in local mode. Bar-specific bits
(ModeSelector, ApprovalMode, ContextWindow, full-access badge) stay in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:21:42 +08:00
Arvin Xu 14ca788ef1 feat(agent): use native folder picker when the bound device is this machine
DeviceWorkingDirectory forced manual path entry because a remote device's
filesystem isn't browsable. But when the bound device is the current machine
(boundDeviceId === gatewayDeviceInfo.deviceId), it *is* browsable — so swap the
text input for the native folder dialog (electronSystemService.selectFolder),
matching the local working-directory picker. Remote devices keep manual entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:21:42 +08:00
Arvin Xu 127283ada0 🐛 fix: eliminate blank loading state during Gateway/ServerRuntime execution
When sending a message in Gateway (ServerRuntime) mode, the UI showed
a blank state between 'Sending message' and 'Task is running in server'
because the new execServerAgentRuntime operation was associated with the
server-created message ID, while the UI was still rendering the temp
message ID. The temp ID had no running operation, so ContentLoading
returned null.

Fix: pass temp message IDs to executeGatewayAgent and associate them
with the gateway operation alongside the server message ID. This ensures
ContentLoading finds a running operation regardless of which message ID
the UI is currently rendering.
2026-06-08 10:21:42 +08:00
Arvin Xu 94d2ec7784 🐛 fix(agent): keep working directory picker when a remote device is selected
Selecting a remote execution device sets runtimeMode to 'none', so the
RuntimeConfig bar's `rightContent()` fell through to `return null` and the
directory picker (and git status) disappeared. The sibling WorkingDirectoryBar
already handled device mode via DeviceWorkingDirectory, but RuntimeConfig was
never updated — two bars, divergent code paths.

Fix: check device mode first in `rightContent()` and render the device-scoped
picker. Extract the shared device/local directory logic into a new
WorkingDirectorySection used by both bars so they can't drift again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:21:42 +08:00
78 changed files with 1909 additions and 1575 deletions
@@ -29,6 +29,7 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
@@ -168,6 +169,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(WorkspaceCtr);
}
private get gitCtr() {
return this.app.getController(GitCtr);
}
private get shellCommandCtr() {
return this.app.getController(ShellCommandCtr);
}
@@ -356,6 +361,10 @@ 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 });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
+22 -165
View File
@@ -22,8 +22,16 @@ import type {
GitWorkingTreeStatus,
SubmoduleWorkingTreePatches,
} from '@lobechat/electron-client-ipc';
import {
type DeviceGitInfo,
getGitAheadBehind as computeGitAheadBehind,
getGitBranch as computeGitBranch,
getGitWorkingTreeStatus as computeGitWorkingTreeStatus,
getLinkedPullRequest as computeLinkedPullRequest,
gitInfo as computeGitInfo,
} from '@lobechat/local-file-shell';
import { detectRepoType, resolveGitDir } from '@/utils/git';
import { detectRepoType } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@@ -450,23 +458,17 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
try {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return {};
return computeGitBranch(dirPath);
}
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
if (refMatch) {
return { branch: refMatch[1] };
}
// Detached HEAD — HEAD file contains the full sha
if (/^[\da-f]{40}$/i.test(head)) {
return { branch: head.slice(0, 7), detached: true };
}
return {};
} catch {
return {};
}
/**
* Aggregate git status (branch + linked PR + working tree + ahead/behind) for a
* directory. The single entry point shared by the local desktop display, the
* device `gitInfo` RPC, and the CLI — implemented in `@lobechat/local-file-shell`.
*/
@IpcMethod()
async gitInfo(params: { isGithub?: boolean; scope: string }): Promise<DeviceGitInfo> {
return computeGitInfo(params);
}
/**
@@ -479,58 +481,7 @@ export default class GitController extends ControllerModule {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> {
const { path: dirPath, branch } = payload;
if (!branch) {
return { pullRequest: null, status: 'ok' };
}
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--limit',
'5',
'--json',
'number,url,title,state',
],
{ cwd: dirPath, timeout: 8000 },
);
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
number: number;
state: string;
title: string;
url: string;
}>;
if (parsed.length === 0) {
return { pullRequest: null, status: 'ok' };
}
const [primary, ...rest] = parsed;
return {
extraCount: rest.length,
pullRequest: primary,
status: 'ok',
};
} catch (error: any) {
const code = error?.code;
const stderr: string = error?.stderr ?? '';
// `gh` binary not on PATH
if (code === 'ENOENT') {
return { pullRequest: null, status: 'gh-missing' };
}
// gh reports auth issues via stderr; treat as a soft-fail
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
return { pullRequest: null, status: 'gh-missing' };
}
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
return { pullRequest: null, status: 'error' };
}
return computeLinkedPullRequest(payload);
}
/**
@@ -635,42 +586,7 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let added = 0;
let modified = 0;
let deleted = 0;
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 2) continue;
const x = entry[0];
const y = entry[1];
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (x === '?' && y === '?') {
added++;
} else if (x === '!' && y === '!') {
// ignored — skip
} else if (x === 'D' || y === 'D') {
deleted++;
} else if (x === 'A' || y === 'A') {
added++;
} else {
modified++;
}
}
const total = added + modified + deleted;
return { added, clean: total === 0, deleted, modified, total };
} catch {
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
}
return computeGitWorkingTreeStatus(dirPath);
}
/**
@@ -1133,66 +1049,7 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
const execFileAsync = promisify(execFile);
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to compute against cached refs
}
try {
const { stdout: upstreamOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: dirPath, timeout: 5000 },
);
const upstream = upstreamOut.trim();
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
const { stdout } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
{ cwd: dirPath, timeout: 5000 },
);
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
// which may differ from upstream (the branched-off-canary case).
let pushTarget: string | undefined;
let pushTargetExists = false;
try {
const { stdout: branchOut } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
const branch = branchOut.trim();
if (branch) {
pushTarget = `origin/${branch}`;
try {
await execFileAsync(
'git',
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
{ cwd: dirPath, timeout: 5000 },
);
pushTargetExists = true;
} catch {
pushTargetExists = false;
}
}
} catch {
// detached HEAD — leave pushTarget undefined
}
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
} catch {
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
return { ahead: 0, behind: 0, hasUpstream: false };
}
return computeGitAheadBehind(dirPath);
}
/**
+4 -69
View File
@@ -1,71 +1,6 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { readdir } from 'fs-extra';
/**
* Resolve the actual `.git` directory for a working tree.
* Supports both standard layouts and worktree pointer files (`.git` as a regular file
* containing `gitdir: <path>`).
* Git repo-type / gitdir helpers. The implementations now live in
* `@lobechat/local-file-shell` so desktop, the device RPC, and the CLI share one
* copy; re-exported here to keep existing `@/utils/git` import sites stable.
*/
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitPath = path.join(dirPath, '.git');
try {
const content = await readFile(gitPath, 'utf8');
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
if (worktreeMatch) {
const resolved = worktreeMatch[1].trim();
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
}
} catch {
// `.git` is a directory (EISDIR) or missing — fall through
}
try {
const stat = await readdir(gitPath);
if (stat.length > 0) return gitPath;
} catch {
return undefined;
}
return undefined;
};
/**
* Resolve the common git dir — where shared state like `config` and
* `packed-refs` lives. For linked worktrees, `resolveGitDir` returns
* `.git/worktrees/<name>/` which has its own `HEAD` but no `config`;
* the `commondir` pointer inside it resolves to the main repo's gitdir.
*/
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return undefined;
try {
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
if (!commondir) return gitDir;
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
} catch {
return gitDir;
}
};
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`,
// or line start (covers `git@github.com:`, `https://github.com/`,
// `ssh://git@github.com/`, etc.) and followed by `:` or `/`. Avoids matching
// look-alikes like `evilgithub.com` or `github.com.attacker.com`.
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
/**
* Classify a working tree as `git` (plain) / `github` (origin points at github.com) /
* `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules and
* linked worktrees resolve the same as the main repo.
*/
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
const commonDir = await resolveCommonGitDir(dirPath);
if (!commonDir) return undefined;
try {
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
return 'git';
} catch {
return undefined;
}
};
export { detectRepoType, resolveCommonGitDir, resolveGitDir } from '@lobechat/local-file-shell';
@@ -1,5 +1,6 @@
'use client';
import { AnimatedNumber } from '@lobechat/shared-tool-ui/components';
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Tooltip } from '@lobehub/ui';
@@ -175,7 +176,11 @@ export const AgentInspector = memo<BuiltinInspectorProps<AgentArgs>>(
{metrics.toolCalls > 0 && metrics.totalTokens > 0 && (
<span className={styles.metricsDot}>·</span>
)}
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)}</span>}
{metrics.totalTokens > 0 && (
<span>
<AnimatedNumber formatter={formatTokens} value={metrics.totalTokens} />
</span>
)}
</span>
</Tooltip>
) : null;
@@ -0,0 +1,3 @@
export * from './info';
export * from './repoType';
export * from './types';
+219
View File
@@ -0,0 +1,219 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import { createLogger } from '../logger';
import { resolveGitDir } from './repoType';
import type {
DeviceGitInfo,
GitAheadBehind,
GitBranchInfo,
GitLinkedPullRequestResult,
GitWorkingTreeStatus,
} from './types';
const log = createLogger('local-file-shell:git');
const execFileAsync = promisify(execFile);
/** Current branch short name, or short SHA + `detached` for detached HEAD. */
export const getGitBranch = async (dirPath: string): Promise<GitBranchInfo> => {
try {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return {};
const head = (await readFile(`${gitDir}/HEAD`, 'utf8')).trim();
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
if (refMatch) return { branch: refMatch[1] };
// Detached HEAD — HEAD file contains the full sha
if (/^[\da-f]{40}$/i.test(head)) return { branch: head.slice(0, 7), detached: true };
return {};
} catch {
return {};
}
};
/**
* Query `gh` CLI for an open pull request whose head branch matches `branch`.
* Returns `status: 'gh-missing'` when `gh` is unavailable / not authed.
*/
export const getLinkedPullRequest = async (payload: {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> => {
const { path: dirPath, branch } = payload;
if (!branch) return { pullRequest: null, status: 'ok' };
try {
const { stdout } = await execFileAsync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--limit',
'5',
'--json',
'number,url,title,state',
],
{ cwd: dirPath, timeout: 8000 },
);
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
number: number;
state: string;
title: string;
url: string;
}>;
if (parsed.length === 0) return { pullRequest: null, status: 'ok' };
const [primary, ...rest] = parsed;
return { extraCount: rest.length, pullRequest: primary, status: 'ok' };
} catch (error: any) {
const code = error?.code;
const stderr: string = error?.stderr ?? '';
if (code === 'ENOENT') return { pullRequest: null, status: 'gh-missing' };
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
return { pullRequest: null, status: 'gh-missing' };
}
log.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
return { pullRequest: null, status: 'error' };
}
};
/** Bucket dirty files into added / modified / deleted via `git status --porcelain -z`. */
export const getGitWorkingTreeStatus = async (dirPath: string): Promise<GitWorkingTreeStatus> => {
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let added = 0;
let modified = 0;
let deleted = 0;
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 2) continue;
const x = entry[0];
const y = entry[1];
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (x === '?' && y === '?') {
added++;
} else if (x === '!' && y === '!') {
// ignored — skip
} else if (x === 'D' || y === 'D') {
deleted++;
} else if (x === 'A' || y === 'A') {
added++;
} else {
modified++;
}
}
const total = added + modified + deleted;
return { added, clean: total === 0, deleted, modified, total };
} catch {
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
}
};
/**
* Count commits HEAD is ahead/behind its upstream. Does a best-effort `git fetch`
* first; swallows fetch failures (offline / no creds) and computes against cached
* refs. Returns `hasUpstream: false` when no upstream is configured.
*/
export const getGitAheadBehind = async (dirPath: string): Promise<GitAheadBehind> => {
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to compute against cached refs
}
try {
const { stdout: upstreamOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: dirPath, timeout: 5000 },
);
const upstream = upstreamOut.trim();
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
const { stdout } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
{ cwd: dirPath, timeout: 5000 },
);
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
// which may differ from upstream (the branched-off-canary case).
let pushTarget: string | undefined;
let pushTargetExists = false;
try {
const { stdout: branchOut } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
const branch = branchOut.trim();
if (branch) {
pushTarget = `origin/${branch}`;
try {
await execFileAsync(
'git',
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
{ cwd: dirPath, timeout: 5000 },
);
pushTargetExists = true;
} catch {
pushTargetExists = false;
}
}
} catch {
// detached HEAD — leave pushTarget undefined
}
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
} catch {
return { ahead: 0, behind: 0, hasUpstream: false };
}
};
/**
* Aggregate git status (branch + linked PR + working tree + ahead/behind) into one
* payload. The single source behind the desktop display, the device `gitInfo` RPC,
* and the CLI. PR lookup runs only for a real branch on a GitHub remote.
*/
export const gitInfo = async (params: {
isGithub?: boolean;
scope: string;
}): Promise<DeviceGitInfo> => {
const dirPath = params.scope;
const { branch, detached } = await getGitBranch(dirPath);
let info: DeviceGitInfo['info'] = { branch, detached };
if (branch && !detached && params.isGithub) {
const pr = await getLinkedPullRequest({ branch, path: dirPath });
info = {
branch,
detached,
extraCount: pr.extraCount,
ghMissing: pr.status === 'gh-missing',
pullRequest: pr.pullRequest,
};
}
const [workingStatus, aheadBehind] = await Promise.all([
getGitWorkingTreeStatus(dirPath),
getGitAheadBehind(dirPath),
]);
return { aheadBehind, info, workingStatus };
};
@@ -0,0 +1,68 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
/**
* Resolve the actual `.git` directory for a working tree. Supports both standard
* layouts and worktree pointer files (`.git` as a regular file containing
* `gitdir: <path>`).
*/
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitPath = path.join(dirPath, '.git');
try {
const content = await readFile(gitPath, 'utf8');
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
if (worktreeMatch) {
const resolved = worktreeMatch[1].trim();
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
}
} catch {
// `.git` is a directory (EISDIR) or missing — fall through
}
try {
const entries = await readdir(gitPath);
if (entries.length > 0) return gitPath;
} catch {
return undefined;
}
return undefined;
};
/**
* Resolve the common git dir — where shared state like `config` and `packed-refs`
* lives. For linked worktrees, `resolveGitDir` returns `.git/worktrees/<name>/`
* which has its own `HEAD` but no `config`; the `commondir` pointer inside it
* resolves to the main repo's gitdir.
*/
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return undefined;
try {
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
if (!commondir) return gitDir;
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
} catch {
return gitDir;
}
};
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`, or
// line start (covers `git@github.com:`, `https://github.com/`, `ssh://git@github.com/`)
// and followed by `:` or `/`. Avoids matching look-alikes like `evilgithub.com`.
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
/**
* Classify a working tree as `git` (plain) / `github` (origin points at github.com)
* / `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules
* and linked worktrees resolve the same as the main repo.
*/
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
const commonDir = await resolveCommonGitDir(dirPath);
if (!commonDir) return undefined;
try {
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
return 'git';
} catch {
return undefined;
}
};
@@ -0,0 +1,55 @@
/** Branch short name (or short SHA when detached). */
export interface GitBranchInfo {
branch?: string;
detached?: boolean;
}
export interface GitLinkedPullRequest {
number: number;
state: string;
title: string;
url: string;
}
export interface GitLinkedPullRequestResult {
/** 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: GitLinkedPullRequest | null;
/** 'ok' — succeeded; 'gh-missing' — gh CLI unavailable / not authed; 'error' — other. */
status: 'ok' | 'gh-missing' | 'error';
}
export interface GitWorkingTreeStatus {
added: number;
clean: boolean;
deleted: number;
modified: number;
total: number;
}
export interface GitAheadBehind {
ahead: number;
behind: number;
hasUpstream: boolean;
pushTarget?: string;
pushTargetExists?: boolean;
upstream?: string;
}
/**
* Aggregate git status for a working directory — the single payload behind both
* the desktop git display and the device `gitInfo` RPC (and CLI). Mirrors the
* three renderer hooks (`useGitInfo` / `useWorkingTreeStatus` / `useGitAheadBehind`).
*/
export interface DeviceGitInfo {
aheadBehind: GitAheadBehind;
info: {
branch?: string;
detached?: boolean;
extraCount?: number;
ghMissing?: boolean;
pullRequest?: GitLinkedPullRequest | null;
};
workingStatus: GitWorkingTreeStatus;
}
+1
View File
@@ -1,6 +1,7 @@
export * from './contentSearch';
export * from './file';
export * from './fileSearch';
export * from './git';
export { createLogger, type Logger, type LoggerFactory, setLoggerFactory } from './logger';
export * from './shell';
export type { ToolCategory, ToolDetector } from './toolDetector';
@@ -0,0 +1,71 @@
'use client';
import { memo, useEffect, useRef, useState } from 'react';
interface AnimatedNumberProps {
/**
* Animation duration in ms.
* @default 500
*/
duration?: number;
/**
* Render the in-flight (possibly fractional) value. Without it the value is
* rounded and rendered via `toLocaleString`.
*/
formatter?: (value: number) => string;
value: number;
}
/**
* Counts the displayed number up (or down) to `value` whenever it changes,
* using `requestAnimationFrame` + easeOutCubic. The first mount snaps to the
* initial value (no count-up from zero), so only subsequent updates animate —
* e.g. token totals ticking up while a subagent streams.
*/
export const AnimatedNumber = memo<AnimatedNumberProps>(({ value, duration = 500, formatter }) => {
const [displayValue, setDisplayValue] = useState(value);
const frameRef = useRef<number>(undefined);
const startTimeRef = useRef<number>(undefined);
const startValueRef = useRef(value);
useEffect(() => {
const startValue = startValueRef.current;
const diff = value - startValue;
if (diff === 0) return;
const animate = (currentTime: number) => {
if (!startTimeRef.current) {
startTimeRef.current = currentTime;
}
const elapsed = currentTime - startTimeRef.current;
const progress = Math.min(elapsed / duration, 1);
// easeOutCubic
const easeProgress = 1 - (1 - progress) ** 3;
const current = startValue + diff * easeProgress;
setDisplayValue(current);
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate);
} else {
startValueRef.current = value;
startTimeRef.current = undefined;
}
};
frameRef.current = requestAnimationFrame(animate);
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
};
}, [value, duration]);
return formatter ? formatter(displayValue) : Math.round(displayValue).toLocaleString();
});
AnimatedNumber.displayName = 'AnimatedNumber';
@@ -1,2 +1,3 @@
export { AnimatedNumber } from './AnimatedNumber';
export { FilePathDisplay } from './FilePathDisplay';
export { ToolResultCard } from './ToolResultCard';
+14
View File
@@ -75,4 +75,18 @@ export interface LobeAgentAgencyConfig {
* any `verifyCriteriaIds` — into its check plan. References `verify_rubrics.id`.
*/
verifyRubricId?: string;
/**
* Per-device working directory chosen for this agent. Key = `deviceId` (the
* local machine uses its own gateway deviceId, so local and remote share one
* model). This is the **agent-level** cwd in the resolution precedence:
*
* `topic.metadata.workingDirectory`
* > `workingDirByDevice[targetDeviceId]`
* > `device.defaultCwd`
*
* Keyed per device so switching the bound device never resolves a path that
* only exists on another machine. Persisted (server-synced) so the choice
* follows the user across sessions / ends.
*/
workingDirByDevice?: Record<string, string>;
}
@@ -146,6 +146,40 @@ export interface WorkspaceInitResult {
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
-5
View File
@@ -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
*/
@@ -33,7 +33,7 @@ const AgentBuilderConversation = memo<AgentBuilderConversationProps>(({ agentId
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<ChatList welcome={<AgentBuilderWelcome />} />
</Flexbox>
<ChatInput leftActions={actions} rightActions={rightActions} showRuntimeConfig={false} />
<ChatInput leftActions={actions} rightActions={rightActions} showControlBar={false} />
</Flexbox>
</DragUploadZone>
);
@@ -93,7 +93,7 @@ const Conversation = memo(() => {
leftContent={leftContent}
sendAreaPrefix={modelSelector}
sendButtonProps={COMPACT_SEND_BUTTON_PROPS}
showRuntimeConfig={false}
showControlBar={false}
/>
</Flexbox>
</DragUploadZone>
@@ -12,6 +12,7 @@ import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import BranchSwitcher from './BranchSwitcher';
import { useDeviceGitInfo } from './useDeviceGitInfo';
import { useGitAheadBehind } from './useGitAheadBehind';
import { useGitInfo } from './useGitInfo';
import { useWorkingTreeStatus } from './useWorkingTreeStatus';
@@ -136,15 +137,29 @@ const styles = createStaticStyles(({ css }) => {
});
interface GitStatusProps {
/** When set, git status is read from this remote device via RPC (read-only
* no branch switch / pull / push). Omit for the local machine. */
deviceId?: string;
isGithub: boolean;
path: string;
}
const GitStatus = memo<GitStatusProps>(({ path, isGithub }) => {
const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
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 local = !deviceId;
// Local machine probes its own filesystem; a remote device answers over RPC.
const { data: localInfo, mutate } = useGitInfo(local ? path : undefined, isGithub);
const { data: localWorkingStatus, mutate: mutateWorkingStatus } = useWorkingTreeStatus(
local ? path : undefined,
);
const { data: localAheadBehind, mutate: mutateAheadBehind } = useGitAheadBehind(
local ? path : undefined,
);
const { data: remoteGit } = useDeviceGitInfo(deviceId, path, isGithub);
const data = local ? localInfo : remoteGit?.info;
const workingStatus = local ? localWorkingStatus : remoteGit?.workingStatus;
const aheadBehind = local ? localAheadBehind : remoteGit?.aheadBehind;
const [switcherOpen, setSwitcherOpen] = useState(false);
const [pulling, setPulling] = useState(false);
const [pushing, setPushing] = useState(false);
@@ -254,24 +269,26 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub }) => {
</div>
);
const branchNode = data.detached ? (
<Tooltip title={branchTooltip}>{branchTrigger}</Tooltip>
) : (
<BranchSwitcher
currentBranch={data.branch}
open={switcherOpen}
path={path}
onExternalRefresh={refreshAfterSync}
onOpenChange={setSwitcherOpen}
onAfterCheckout={() => {
void mutate();
void mutateWorkingStatus();
void mutateAheadBehind();
}}
>
const branchNode =
data.detached || !local ? (
// Detached HEAD, or a remote device (read-only) → plain branch label.
<Tooltip title={branchTooltip}>{branchTrigger}</Tooltip>
</BranchSwitcher>
);
) : (
<BranchSwitcher
currentBranch={data.branch}
open={switcherOpen}
path={path}
onExternalRefresh={refreshAfterSync}
onOpenChange={setSwitcherOpen}
onAfterCheckout={() => {
void mutate();
void mutateWorkingStatus();
void mutateAheadBehind();
}}
>
<Tooltip title={branchTooltip}>{branchTrigger}</Tooltip>
</BranchSwitcher>
);
const pullTooltip = pulling
? t('localSystem.workingDirectory.pullInProgress')
@@ -292,7 +309,7 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub }) => {
},
);
const pullNode = showBehind && (
const pullNode = local && showBehind && (
<Tooltip title={pullTooltip}>
<div
aria-busy={pulling}
@@ -309,7 +326,7 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub }) => {
</Tooltip>
);
const pushNode = showAhead && (
const pushNode = local && showAhead && (
<Tooltip title={pushTooltip}>
<div
aria-busy={pushing}
@@ -329,7 +346,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,7 +3,7 @@
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 { HeteroExecutionTarget } from '@lobechat/types';
import { Microsoft } from '@lobehub/icons';
import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
@@ -259,21 +259,15 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
async (target: HeteroExecutionTarget, 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],
@@ -0,0 +1,331 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox, Icon, Input, Popover, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { CheckIcon, ChevronDownIcon, FolderIcon, FolderOpenIcon, XIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
resolveAgentWorkingDirectory,
resolveTargetDeviceId,
} from '@/helpers/agentWorkingDirectory';
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 { 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;
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('plugin');
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
// 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);
setInput('');
setOpen(false);
};
const handleChooseFolder = async () => {
const result = await electronSystemService.selectFolder({
defaultPath: selectedDir || undefined,
title: t('localSystem.workingDirectory.selectFolder'),
});
if (result) await pick({ path: result.path, repoType: result.repoType });
};
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('localSystem.workingDirectory.recent')}</div>
{selectedDir && (
<div className={styles.clearText} onClick={() => void clear().then(() => setOpen(false))}>
{t('localSystem.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('localSystem.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('localSystem.workingDirectory.removeRecent')}
onClick={(e) => handleRemoveRecent(e, entry.path)}
>
<Icon icon={XIcon} size={12} />
</div>
)}
</Flexbox>
);
})
)}
</div>
{isLocalDevice ? (
<Flexbox
horizontal
align={'center'}
className={styles.chooseFolderItem}
gap={8}
onClick={handleChooseFolder}
>
<Icon icon={FolderOpenIcon} size={14} />
<span>{t('localSystem.workingDirectory.chooseDifferentFolder')}</span>
</Flexbox>
) : (
<Input
placeholder={t('localSystem.workingDirectory.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onPressEnter={() => void pick({ path: input })}
/>
)}
</Flexbox>
);
const displayName = selectedDir
? getDirName(selectedDir)
: t('localSystem.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('localSystem.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 control bars:
* device selector + working directory + git branch / file changes / PR info.
*
* Both ControlBar (normal agents) and HeteroControlBar (heterogeneous agents)
* 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;
@@ -0,0 +1,61 @@
import { Flexbox, Skeleton } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import ContextWindow from '../ActionBar/Token';
import { useAgentId } from '../hooks/useAgentId';
import { useChatInputStore } from '../store';
import ApprovalMode from './ApprovalMode';
import ModeSelector from './ModeSelector';
import WorkspaceControls from './WorkspaceControls';
const styles = createStaticStyles(({ css }) => ({
bar: css`
padding-block: 0;
padding-inline: 4px;
`,
}));
const ControlBar = memo(() => {
const agentId = useAgentId();
const showContextWindow = useChatInputStore((s) =>
s.rightActions.flat().includes('contextWindow'),
);
const [isLoading, enableAgentMode] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
]);
// Skeleton placeholder to prevent layout jump during loading
if (!agentId || isLoading) {
return (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4}>
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 64, width: 64 }} />
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
</Flexbox>
);
}
return (
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
{/* Left: chat-mode switcher + (agent-only) execution device + working directory */}
<Flexbox horizontal align={'center'} gap={4}>
<ModeSelector />
{enableAgentMode && <WorkspaceControls agentId={agentId} />}
</Flexbox>
<Flexbox horizontal align={'center'} gap={4}>
{enableAgentMode && <ApprovalMode />}
{showContextWindow && <ContextWindow />}
</Flexbox>
</Flexbox>
);
});
ControlBar.displayName = 'ControlBar';
export default ControlBar;
@@ -0,0 +1,118 @@
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 { type WorkingDirEntry } 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 };
};
@@ -0,0 +1,16 @@
import { lambdaQuery } from '@/libs/trpc/client';
/**
* Git status (branch / file changes / PR) for a directory on a **remote** device,
* fetched via the `device.gitInfo` RPC so it works from web and from another
* desktop. Disabled (no request) until both `deviceId` and `scope` are present.
*/
export const useDeviceGitInfo = (deviceId?: string, scope?: string, isGithub = false) =>
lambdaQuery.device.gitInfo.useQuery(
{ deviceId: deviceId ?? '', isGithub, scope: scope ?? '' },
{
enabled: !!deviceId && !!scope,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
);
@@ -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]);
};
+11 -11
View File
@@ -19,10 +19,10 @@ import { systemStatusSelectors } from '@/store/global/selectors';
import { type ActionToolbarProps } from '../ActionBar';
import ActionBar from '../ActionBar';
import ControlBar from '../ControlBar';
import InputEditor from '../InputEditor';
import { useSkillDrop } from '../InputEditor/ActionTag/useSkillDrop';
import { type PlaceholderVariant } from '../InputEditor/Placeholder';
import RuntimeConfig from '../RuntimeConfig';
import SendArea from '../SendArea';
import TypoBar from '../TypoBar';
import ContextContainer from './ContextContainer';
@@ -61,6 +61,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
interface DesktopChatInputProps extends ActionToolbarProps {
actionBarStyle?: React.CSSProperties;
/**
* Custom node to render in place of the default ControlBar.
* When provided, used instead of `<ControlBar />` (ignores `showControlBar`).
*/
controlBarSlot?: ReactNode;
extentHeaderContent?: ReactNode;
hidden?: boolean;
inputContainerProps?: ChatInputProps;
@@ -74,21 +79,16 @@ interface DesktopChatInputProps extends ActionToolbarProps {
placeholder?: ReactNode;
placeholderVariant?: PlaceholderVariant;
rightContent?: ReactNode;
/**
* Custom node to render in place of the default RuntimeConfig bar.
* When provided, used instead of `<RuntimeConfig />` (ignores `showRuntimeConfig`).
*/
runtimeConfigSlot?: ReactNode;
sendAreaPrefix?: ReactNode;
showControlBar?: boolean;
showFootnote?: boolean;
showRuntimeConfig?: boolean;
}
const DesktopChatInput = memo<DesktopChatInputProps>(
({
showFootnote,
showRuntimeConfig = true,
runtimeConfigSlot,
showControlBar = true,
controlBarSlot,
inputContainerProps,
extentHeaderContent,
actionBarStyle,
@@ -207,7 +207,7 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
>
<InputEditor placeholder={placeholder} placeholderVariant={placeholderVariant} />
</ChatInput>
{runtimeConfigSlot ?? (showRuntimeConfig && <RuntimeConfig />)}
{controlBarSlot ?? (showControlBar && <ControlBar />)}
{showFootnote && !expand && (
<Center style={{ pointerEvents: 'none', zIndex: 100 }}>
<Text className={styles.footnote} type={'secondary'}>
@@ -227,4 +227,4 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
DesktopChatInput.displayName = 'DesktopChatInput';
export default DesktopChatInput;
export default DesktopChatInput;
@@ -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>(
@@ -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;
@@ -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;
@@ -1,333 +0,0 @@
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 { 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 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,
};
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) => [
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 (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4}>
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 64, width: 64 }} />
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
</Flexbox>
);
}
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 */}
<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()}
</>
)}
</Flexbox>
<Flexbox horizontal align={'center'} gap={4}>
{enableAgentMode && <ApprovalMode />}
{showContextWindow && <ContextWindow />}
</Flexbox>
</Flexbox>
);
});
RuntimeConfig.displayName = 'RuntimeConfig';
export default RuntimeConfig;
@@ -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],
);
};
+12 -12
View File
@@ -52,6 +52,11 @@ export interface ChatInputProps {
* Use this to add custom UI like error alerts, MessageFromUrl, etc.
*/
children?: ReactNode;
/**
* Custom node to render in place of the default ControlBar
* (Local/Cloud/Approval). When provided, replaces the default bar.
*/
controlBarSlot?: ReactNode;
/**
* Suppress the followUp placeholder variant (e.g. onboarding has no
* follow-up design). When true, placeholder stays in default variant.
@@ -96,11 +101,6 @@ export interface ChatInputProps {
* Right action buttons configuration
*/
rightActions?: ActionKeys[];
/**
* Custom node to render in place of the default RuntimeConfig bar
* (Local/Cloud/Approval). When provided, replaces the default bar.
*/
runtimeConfigSlot?: ReactNode;
/**
* Custom content to render before the SendArea (right side of action bar)
*/
@@ -114,9 +114,9 @@ export interface ChatInputProps {
*/
sendMenu?: MenuProps;
/**
* Whether to show the runtime config bar (Local/Cloud/Auto Approve)
* Whether to show the control bar (Local/Cloud/Auto Approve)
*/
showRuntimeConfig?: boolean;
showControlBar?: boolean;
/**
* Remove a small margin when placed adjacent to the ChatList
*/
@@ -143,11 +143,11 @@ const ChatInput = memo<ChatInputProps>(
extraActionItems,
isConfigLoading = false,
mentionItems,
runtimeConfigSlot,
controlBarSlot,
sendMenu,
sendAreaPrefix,
sendButtonProps: customSendButtonProps,
showRuntimeConfig = true,
showControlBar = true,
onEditorReady,
skipScrollMarginWithList,
}) => {
@@ -321,14 +321,14 @@ const ChatInput = memo<ChatInputProps>(
<DesktopChatInput
actionBarStyle={actionBarStyle}
borderRadius={12}
controlBarSlot={controlBarSlot}
extraActionItems={extraActionItems}
hidden={hasPendingInterventions}
isConfigLoading={isConfigLoading}
leftContent={leftContent}
placeholderVariant={placeholderVariant}
runtimeConfigSlot={runtimeConfigSlot}
sendAreaPrefix={businessSendAreaPrefix}
showRuntimeConfig={showRuntimeConfig}
showControlBar={showControlBar}
/>
</div>
</WideScreenContainer>
@@ -365,4 +365,4 @@ const ChatInput = memo<ChatInputProps>(
ChatInput.displayName = 'ConversationChatInput';
export default ChatInput;
export default ChatInput;
@@ -170,7 +170,7 @@ describe('AgentOnboardingConversation', () => {
allowExpand: false,
leftActions: [],
rightActions: [],
showRuntimeConfig: false,
showControlBar: false,
}),
);
});
@@ -205,7 +205,7 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
isConfigLoading={!isInputReady}
leftActions={chatInputLeftActions}
rightActions={chatInputRightActions}
showRuntimeConfig={false}
showControlBar={false}
/>
</Flexbox>
)}
@@ -82,7 +82,7 @@ const Conversation = memo(() => {
leftContent={leftContent}
sendAreaPrefix={modelSelector}
sendButtonProps={COMPACT_SEND_BUTTON_PROPS}
showRuntimeConfig={false}
showControlBar={false}
/>
</Flexbox>
</DragUploadZone>
@@ -44,7 +44,7 @@ const FileCopilot = memo(() => {
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<ChatList />
</Flexbox>
<ChatInput leftActions={actions} showRuntimeConfig={false} />
<ChatInput leftActions={actions} showControlBar={false} />
</Flexbox>
</DragUploadZone>
</RightPanel>
+54
View File
@@ -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
);
};
+58
View File
@@ -0,0 +1,58 @@
import type { HeteroExecutionTarget, 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.
*
* - `local` → 本机 (this machine, in-process; desktop only)
* - `sandbox` → 云端沙箱 (server cloud sandbox)
* - `device` → 远程设备 (dispatched to `boundDeviceId`)
*
* Defaults: desktop → `local`, web → `sandbox`. 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,
): HeteroExecutionTarget => {
const stored = agencyConfig?.executionTarget;
const effective = stored ?? (isDesktop ? 'local' : 'sandbox');
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.
*/
export const executionTargetToRuntimeMode = (target: HeteroExecutionTarget): 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));
};
+53
View File
@@ -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,
});
};
@@ -0,0 +1,84 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox, Icon, Skeleton, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { CircleAlertIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import WorkspaceControls from '@/features/ChatInput/ControlBar/WorkspaceControls';
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
const styles = createStaticStyles(({ css }) => ({
bar: css`
padding-block: 0;
padding-inline: 4px;
`,
fullAccess: css`
cursor: default;
display: flex;
gap: 6px;
align-items: center;
padding-block: 2px;
padding-inline: 4px;
border-radius: 4px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
}));
const HeteroControlBar = memo(() => {
const { t: tChat } = useTranslation('chat');
const agentId = useAgentId();
// All hooks must be called unconditionally (Rules of Hooks)
const isLoading = useAgentStore(agentByIdSelectors.isAgentConfigLoadingById(agentId));
// 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}>
<WorkspaceControls alwaysShowWorkspace agentId={agentId} />
</Flexbox>
</Flexbox>
);
}
if (!agentId || isLoading) {
return (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4} justify={'space-between'}>
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 80, width: 80 }} />
</Flexbox>
);
}
const fullAccessBadge = (
<div className={styles.fullAccess}>
<Icon icon={CircleAlertIcon} size={14} />
<span>{tChat('heteroAgent.fullAccess.label')}</span>
</div>
);
return (
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
<Flexbox horizontal align={'center'} gap={4}>
<WorkspaceControls alwaysShowWorkspace agentId={agentId} />
</Flexbox>
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
</Flexbox>
);
});
HeteroControlBar.displayName = 'HeteroControlBar';
export default HeteroControlBar;
@@ -1,193 +0,0 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover, 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 { 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 { 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;
display: flex;
gap: 6px;
align-items: center;
padding-block: 2px;
padding-inline: 4px;
border-radius: 4px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
}));
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
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} />
)}
</Flexbox>
</Flexbox>
);
}
if (!agentId || isLoading) {
return (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4} justify={'space-between'}>
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 80, width: 80 }} />
</Flexbox>
);
}
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} />
<span>{tChat('heteroAgent.fullAccess.label')}</span>
</div>
);
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} />
)}
</>
)}
</Flexbox>
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
</Flexbox>
);
});
WorkingDirectoryBar.displayName = 'HeterogeneousWorkingDirectoryBar';
export default WorkingDirectoryBar;
@@ -19,7 +19,7 @@ import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import WorkingDirectoryBar from './WorkingDirectoryBar';
import HeteroControlBar from './HeteroControlBar';
// Heterogeneous agents (e.g. Claude Code) bring their own toolchain, memory,
// and model, so LobeHub-side pickers don't apply. Typo is kept so the user
@@ -133,9 +133,9 @@ const HeterogeneousChatInput = memo(() => {
{renderDeviceGuard()}
<ChatInput
skipScrollMarginWithList
controlBarSlot={<HeteroControlBar />}
leftActions={leftActions}
rightActions={rightActions}
runtimeConfigSlot={<WorkingDirectoryBar />}
sendButtonProps={{ disabled: inputDisabled, shape: 'round' }}
onEditorReady={(instance) => {
// Sync to global ChatStore for compatibility with other features
@@ -22,7 +22,7 @@ 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 { useGitInfo } from '@/features/ChatInput/ControlBar/useGitInfo';
import { useLocalStorageState } from '@/hooks/useLocalStorageState';
import FileRow from './FileRow';
@@ -36,7 +36,7 @@ vi.mock('./Files', () => ({
),
}));
vi.mock('@/features/ChatInput/RuntimeConfig/useRepoType', () => ({
vi.mock('@/features/ChatInput/ControlBar/useRepoType', () => ({
useRepoType: (path?: string) => (path ? mocks.repoType : undefined),
}));
@@ -5,7 +5,7 @@ import { lazy, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
import { useRepoType } from '@/features/ChatInput/ControlBar/useRepoType';
import RightPanel from '@/features/RightPanel';
import { useAgentStore } from '@/store/agent';
import {
@@ -24,7 +24,7 @@ const AgentBuilderConversation = memo<AgentBuilderConversationProps>(({ agentId
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<ChatList welcome={<AgentBuilderWelcome mode="group" />} />
</Flexbox>
<ChatInput leftActions={actions} rightActions={rightActions} showRuntimeConfig={false} />
<ChatInput leftActions={actions} rightActions={rightActions} showControlBar={false} />
</Flexbox>
);
});
@@ -259,7 +259,7 @@ export const CreateAgentModal = memo<CreateAgentModalProps>(
>
<DesktopChatInput
inputContainerProps={inputContainerProps}
showRuntimeConfig={false}
showControlBar={false}
placeholder={
isAgent ? t('createModal.placeholder') : t('createModal.groupPlaceholder')
}
@@ -160,7 +160,7 @@ const InputArea = () => {
dropdownPlacement="bottomLeft"
inputContainerProps={inputContainerProps}
placeholder={dailyHint}
showRuntimeConfig={false}
showControlBar={false}
/>
</ChatInputProvider>
</DragUploadZone>
@@ -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
? [
{
@@ -8,10 +8,10 @@ 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 { renderDirIcon } from '@/features/ChatInput/ControlBar/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';
@@ -8,8 +8,8 @@ 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 type { WorkingDirEntry } from '@/store/device';
import { getDeviceIcon } from './getDeviceIcon';
@@ -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?: {
/**
+24
View File
@@ -76,6 +76,30 @@ 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.
*/
gitInfo: deviceProcedure
.input(
z.object({
deviceId: z.string(),
isGithub: z.boolean().optional(),
scope: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const result = await deviceGateway.gitInfo(
ctx.userId,
input.deviceId,
input.scope,
input.isGithub,
);
return result ?? null;
}),
/**
* Fetch the agent profile (title, description, avatar) from the platform
* installed on the given device. Used to pre-fill the creation modal.
+21 -8
View File
@@ -35,6 +35,7 @@ import type {
ExecGroupAgentResult,
ExecSubAgentTaskParams,
ExecSubAgentTaskResult,
LobeAgentAgencyConfig,
MessagePluginItem,
UserInterventionConfig,
WorkspaceInitResult,
@@ -319,10 +320,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 {
@@ -330,10 +332,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 ?? [];
@@ -1064,12 +1071,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;
@@ -2299,7 +2308,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 ?? '',
@@ -8,7 +8,7 @@ import {
type GatewayMcpStdioParams,
} from '@lobechat/device-gateway-client';
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
import type { ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
import type { DeviceGitInfo, ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
import debug from 'debug';
import { gatewayEnv } from '@/envs/gateway';
@@ -127,6 +127,39 @@ 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.
*/
async gitInfo(
userId: string,
deviceId: string,
scope: string,
isGithub = false,
timeout = 15_000,
): Promise<DeviceGitInfo | undefined> {
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitInfo>(
{ deviceId, timeout, userId },
{ method: 'gitInfo', params: { isGithub, scope } },
);
if (!result.success || !result.data) {
log('gitInfo: failed for deviceId=%s — %s', deviceId, result.error);
return undefined;
}
return result.data;
} catch (error) {
log('gitInfo: error for deviceId=%s — %O', deviceId, error);
return undefined;
}
}
async dispatchAgentRun(params: {
agentType: HeterogeneousAgentType;
cwd?: string;
@@ -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 =
@@ -705,6 +705,10 @@ export class ConversationLifecycleActionImpl {
message,
metadata: requestMetadata,
parentOperationId: operationId,
// Pass temp message IDs so the UI doesn't show a blank loading
// state while waiting for the first step_start event to replace
// messages with the server's real IDs.
tempMessageIds: [tempAssistantId],
});
return {
@@ -315,6 +315,13 @@ export class GatewayActionImpl {
* a fresh user prompt.
*/
resumeApproval?: ResumeApprovalParam;
/**
* Temporary message IDs created during the initial sendMessage phase.
* These are associated with the new gateway operation so the UI doesn't
* show a blank loading state while waiting for the first `step_start`
* event to call `replaceMessages` with the server's real message IDs.
*/
tempMessageIds?: string[];
}): Promise<ExecAgentResult> => {
const {
context,
@@ -325,6 +332,7 @@ export class GatewayActionImpl {
parentMessageId,
parentOperationId,
resumeApproval,
tempMessageIds,
} = params;
const agentGatewayUrl =
@@ -450,6 +458,15 @@ export class GatewayActionImpl {
// Associate the server-created assistant message with the gateway operation
this.#get().associateMessageWithOperation(result.assistantMessageId, gatewayOpId);
// Also associate temp message IDs so the UI doesn't show a blank loading
// state while waiting for the first `step_start` event to call
// `replaceMessages` with the server's real message IDs.
if (tempMessageIds?.length) {
for (const tempId of tempMessageIds) {
this.#get().associateMessageWithOperation(tempId, gatewayOpId);
}
}
// Phase-1 init done: child op is running. Hand off loading state from
// the caller's op (e.g. `sendMessage`) to the child without a gap.
if (parentOperationId) this.#get().completeOperation(parentOperationId);
+150
View File
@@ -0,0 +1,150 @@
import { type SWRResponse } from 'swr';
import { mutate, useClientDataSWR } from '@/libs/swr';
import { lambdaClient } from '@/libs/trpc/client';
import { type StoreSetter } from '@/store/types';
import {
nextWorkingDirs,
removeWorkingDir,
WORKING_DIRS_MAX,
type WorkingDirEntry,
} from './deviceCwd';
import { type DeviceListItem } from './initialState';
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 lambdaClient.device.updateDevice.mutate({
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 lambdaClient.device.updateDevice.mutate({ 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 lambdaClient.device.updateDevice.mutate({ deviceId, workingDirs: updated });
} finally {
await mutate(FETCH_DEVICES_KEY);
}
};
useFetchDevices = (enabled = true): SWRResponse<DeviceListItem[]> =>
useClientDataSWR<DeviceListItem[]>(
enabled ? FETCH_DEVICES_KEY : null,
() => lambdaClient.device.listDevices.query(),
{
fallbackData: [],
onSuccess: (data) => {
this.#set({ devices: data, isDevicesInit: true }, false, 'fetchDevices');
},
},
);
}
export type DeviceAction = Pick<DeviceActionImpl, keyof DeviceActionImpl>;
@@ -35,3 +35,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);
+3
View File
@@ -0,0 +1,3 @@
export * from './deviceCwd';
export * from './selectors';
export * from './store';
+20
View File
@@ -0,0 +1,20 @@
import type { lambdaClient } from '@/libs/trpc/client';
/**
* A device row as returned by `device.listDevices` — either a registered device
* or an online-only "ghost" (connected but not yet persisted). Inferred from the
* router so the store stays in sync with the contract.
*/
export type DeviceListItem = Awaited<
ReturnType<typeof lambdaClient.device.listDevices.query>
>[number];
export interface DeviceState {
devices: DeviceListItem[];
isDevicesInit: boolean;
}
export const initialState: DeviceState = {
devices: [],
isDevicesInit: false,
};
+25
View File
@@ -0,0 +1,25 @@
import type { DeviceListItem, DeviceState } from './initialState';
const deviceList = (s: DeviceState): DeviceListItem[] => s.devices;
const getDeviceById =
(deviceId: string | undefined) =>
(s: DeviceState): DeviceListItem | undefined =>
deviceId ? s.devices.find((d) => d.deviceId === deviceId) : undefined;
/** A device's user-configured default working directory (per-device fallback cwd). */
const getDeviceDefaultCwd =
(deviceId: string | undefined) =>
(s: DeviceState): string | undefined =>
getDeviceById(deviceId)(s)?.defaultCwd ?? undefined;
/** A device's recent working dirs (also the cache for workspace-init / repoType). */
const getDeviceWorkingDirs = (deviceId: string | undefined) => (s: DeviceState) =>
getDeviceById(deviceId)(s)?.workingDirs ?? [];
export const deviceSelectors = {
deviceList,
getDeviceById,
getDeviceDefaultCwd,
getDeviceWorkingDirs,
};
+26
View File
@@ -0,0 +1,26 @@
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { type StateCreator } from 'zustand/vanilla';
import { createDevtools } from '../middleware/createDevtools';
import { expose } from '../middleware/expose';
import { flattenActions } from '../utils/flattenActions';
import { type DeviceAction, deviceSlice } from './action';
import { type DeviceState, initialState } from './initialState';
export interface DeviceStore extends DeviceState, DeviceAction {
/* empty */
}
const createStore: StateCreator<DeviceStore, [['zustand/devtools', never]]> = (...parameters) => ({
...initialState,
...flattenActions<DeviceAction>([deviceSlice(...parameters)]),
});
const devtools = createDevtools('device');
export const useDeviceStore = createWithEqualityFn<DeviceStore>()(devtools(createStore), shallow);
expose('device', useDeviceStore);
export const getDeviceStoreState = () => useDeviceStore.getState();
@@ -9,8 +9,6 @@ export const labPreferSelectors = {
false,
enableAgentSelfIteration: (s: UserState): boolean =>
s.preference.lab?.enableAgentSelfIteration ?? false,
enableExecutionDeviceSwitcher: (s: UserState): boolean =>
s.preference.lab?.enableExecutionDeviceSwitcher ?? false,
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
enableImessage: (s: UserState): boolean => s.preference.lab?.enableImessage ?? false,
enableInputMarkdown: (s: UserState): boolean =>