mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b13cbd145 | |||
| 20dbca6955 | |||
| bf29b0d450 | |||
| 4062fda7c0 | |||
| cedaef927c | |||
| fadc10e487 | |||
| f3482487cc | |||
| 79450589d8 | |||
| 810165c6c8 | |||
| 40e939ec39 | |||
| cc4d6839c7 | |||
| 01815147d0 | |||
| 14ca788ef1 | |||
| 127283ada0 | |||
| 94d2ec7784 |
@@ -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,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+45
-24
@@ -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>
|
||||
+4
-10
@@ -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]);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { LobeAgentAgencyConfig } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* The device a run targets: an explicitly bound device, else this machine.
|
||||
* Local execution treats the current machine as its own device, so local and
|
||||
* remote share one resolution model.
|
||||
*/
|
||||
export const resolveTargetDeviceId = (
|
||||
agencyConfig: LobeAgentAgencyConfig | undefined,
|
||||
currentDeviceId: string | undefined,
|
||||
): string | undefined =>
|
||||
agencyConfig?.executionTarget === 'device' ? agencyConfig?.boundDeviceId : currentDeviceId;
|
||||
|
||||
/**
|
||||
* Unified working-directory precedence (mirrors the server's resolution):
|
||||
*
|
||||
* topic override
|
||||
* > agent's per-device choice (`agencyConfig.workingDirByDevice[targetDeviceId]`)
|
||||
* > legacy per-agent localStorage value (pre-migration fallback)
|
||||
* > device default (`device.defaultCwd`)
|
||||
* > caller fallback (e.g. home dir for in-process runs)
|
||||
*
|
||||
* The legacy slot keeps existing desktop users' selections working until they
|
||||
* next pick a directory (which writes the new per-device map).
|
||||
*/
|
||||
export const resolveAgentWorkingDirectory = (params: {
|
||||
agencyConfig?: LobeAgentAgencyConfig;
|
||||
currentDeviceId?: string;
|
||||
deviceDefaultCwd?: string;
|
||||
fallback?: string;
|
||||
legacyAgentWorkingDirectory?: string;
|
||||
topicWorkingDirectory?: string;
|
||||
}): string | undefined => {
|
||||
const {
|
||||
agencyConfig,
|
||||
currentDeviceId,
|
||||
deviceDefaultCwd,
|
||||
fallback,
|
||||
legacyAgentWorkingDirectory,
|
||||
topicWorkingDirectory,
|
||||
} = params;
|
||||
const targetDeviceId = resolveTargetDeviceId(agencyConfig, currentDeviceId);
|
||||
const agentChoice = targetDeviceId
|
||||
? agencyConfig?.workingDirByDevice?.[targetDeviceId]
|
||||
: undefined;
|
||||
return (
|
||||
topicWorkingDirectory ||
|
||||
agentChoice ||
|
||||
legacyAgentWorkingDirectory ||
|
||||
deviceDefaultCwd ||
|
||||
fallback ||
|
||||
undefined
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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));
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
+84
@@ -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;
|
||||
-193
@@ -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?: {
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './deviceCwd';
|
||||
export * from './selectors';
|
||||
export * from './store';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user