style(device): run remote CC on a configured device (#15343)

*  feat(device): run remote CC on a configured device with cwd + device context

Make `claude-code`/`codex` dispatched to an `lh connect` device (executionTarget
='device') run in the user's configured directory with a device-appropriate
system context, instead of inheriting the cloud-sandbox setup.

3a — server cwd passthrough:
- resolve the run cwd in the useDevice branch: topic-level workingDirectory
  override > the bound device's `defaultCwd` (read from DB via DeviceModel; the
  gateway only knows live connections, not the user-owned cwd), and pass it to
  dispatchAgentRun.

3b — device-specific systemContext, end to end:
- new `buildRemoteDeviceHeteroContext` — strips the cloud-sandbox boilerplate
  (ephemeral /workspace, pre-cloned repos, commit-or-lose warnings) that would
  mislead an agent on the user's own persistent machine; keeps agent static
  context + resumed conversation history + a minimal cwd note.
- thread `systemContext` through the contract: AgentRunRequestMessage,
  GatewayHttpClient.dispatchAgentRun, deviceProxy.dispatchAgentRun.
- desktop: spawnLhHeteroExec now injects systemContext as the first text block
  of a content-block array on stdin (mirrors spawnHeteroSandbox); previously it
  wrote only the bare prompt, so any context was silently dropped.

The gateway relays unknown fields transparently (`...runParams`), so no gateway
change is needed.

Tests: buildRemoteDeviceHeteroContext unit (6) + GatewayConnectionCtr forwards
cwd/systemContext. type-check clean; existing device/desktop/pkg suites green.

Part of LOBE-9579 (Step 3a/3b). Old ephemeral boundDeviceId migration (3d) and
the web cwd picker (3c) are out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): optimistic device cwd persistence (defaultCwd + recentCwds)

Foundation for the device-scoped cwd picker (executionTarget=device): persist a
working-directory pick to the bound device's registry record so the server's
hetero dispatch (which reads device.defaultCwd) stays in sync and the picker can
offer recent dirs.

- nextRecentCwds: pure most-recent-first / dedupe / cap-20 list builder (the
  server stores recentCwds verbatim, so the client owns this) — unit tested.
- useUpdateDeviceCwd: optimistic `device.updateDevice` — patches the listDevices
  cache in onMutate for instant UI, invalidates onSettled to re-sync truth (self-
  corrects a failed write without manual rollback).

Not yet wired into a picker — the target=device recentCwds-list + manual-input
picker mode that consumes this is the next step.

Part of LOBE-9579 (Step 3c, data layer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): gate send on bound-device online for device-targeted hetero

Extend the pre-send device guard from remote-only agents (openclaw / hermes) to
any hetero agent whose run dispatches to a device — i.e. claude-code / codex with
executionTarget='device'. If the bound device is offline (or none is bound), the
send button is disabled and a guard alert is shown, instead of letting the run
fail at dispatch time.

- new selector currentAgentExecutionTarget
- isDeviceExecution = remote-typed OR executionTarget==='device'; drives the
  guard's enabled flag, the blocked state, and the alert.
- device execution no longer requires cloud credentials (it doesn't use the
  cloud sandbox), so the cloud-not-configured gate now exempts it.

The guard hook already handled non-remote types (online check only, no platform
capability probe), so no hook change is needed.

Part of LOBE-9579 (Step 3, device online guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(tool-render): flatten nested-background tool renders into single-layer surfaces

Remove the card-in-card look across builtin tool renders by dropping the outer
colorFillQuaternary container fill (the framework tool card already provides the
surface) and keeping at most one delineated inner box.

- claude-code AskUserQuestion: rebuilt as a flat Question / divider / Selected
  layout; add i18n keys (question/selected/reply/noAnswer)
- claude-code Skill, local-system WriteFile: flat container + single previewBox
- agent-management CreateAgent/GetAgentDetail: flat container, keep outlined
  systemRole block
- web-onboarding SaveUserQuestion: drop the redundant inner value box

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(builtin-tool): document single-layer surface rule for tool renders

Add §0.8 "stay single-layer — don't nest filled cards": the framework tool
card is already the surface, so the Render's outer wrapper carries no fill and
at most one filled box delineates real content. Cross-link from §2 Render rules
and the diagnostic table, and note the deliberate outlined-panel exception
(TodoWrite / Task).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(builtin-tool): consolidate fragmented UI shared-style rules

The §0 shared rules had drifted into 8 one-line subsections (0.1–0.8). Fold the
five mechanical "every file looks like this" rules ('use client', memo +
displayName, BuiltinXProps generics, t('plugin'), store reads) into a single
annotated component skeleton (0.1), merge the two styling rules into 0.2, and
keep the single-layer surface rule as 0.3. Update the §0.8 cross-references in
§2 and the diagnostic table to §0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(builtin-tool): split UI reference into a per-topic ui/ folder

The single 770-line ui.md had grown unwieldy. Break it into references/ui/
with a README index and one file per topic: principles, shared-rules, the six
surfaces (inspector/render/placeholder/streaming/intervention/portal),
composition, and diagnostics. Convert in-doc §-number cross-refs to cross-file
links and repoint SKILL.md + tool-design.md at the new folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): device-scoped cwd picker for executionTarget=device

When a hetero run is bound to a remote device, the device's filesystem isn't
browsable from here, so the local folder picker doesn't apply. Add
DeviceWorkingDirectory — a self-contained bar item (chip + popover) sourced from
the bound device's recentCwds plus a manual path input.

- Picking/typing a cwd pins it to the active topic (override) and persists it to
  the device via useUpdateDeviceCwd (optimistic defaultCwd + recentCwds), which
  is exactly what the server's device-dispatch branch reads back.
- Same per-cwd CC-session-reset confirm as the local picker.
- WorkingDirectoryBar routes to it when executionTarget==='device' (both web —
  replacing CloudRepoSwitcher — and desktop, replacing the local picker +
  GitStatus); local/sandbox paths are unchanged.
- Reuses existing i18n keys (recent / noRecent / placeholder).

Completes LOBE-9579 Step 3c. type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(tool-render): flatten ToolResultCard + de-duplicate Read header

ToolResultCard was the card-in-card shared component (colorFillQuaternary
wrapper around a colorBgContainer box) behind CC Read/Grep/Glob/Write/WebSearch/
WebFetch. Flatten it to single-layer (flat wrapper, one colorFillTertiary
content box) so all consumers stop stacking fills inside the framework tool card.

CC Read header showed the filename strong-label and then dumped the full
absolute path whose tail repeated the same basename, end-truncated so the
meaningful suffix was hidden. Show the directory only (filename stays the
strong label), and drop the conflicting word-break so the dir ellipsizes on one
line.

Note ToolResultCard in the skill as the canonical single-layer header+content
card to reuse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): mark current device, native cwd browse, fix edit Save button

Settings → Devices page polish:
- Badge the row for the machine you're on ("This device"), resolved from the
  desktop gateway's own deviceId (web has no current device → no badge).
- For the current device, the edit modal's Default working directory gains a
  native folder picker (electronSystemService.selectFolder) next to the manual
  input — you can't browse a remote device's filesystem, only your own.
- Edit modal footer now uses real Button components (Cancel + primary Save)
  instead of the base-ui Modal's default okText, which rendered with the wrong
  (non-primary) color.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): neutral current-device tag + per-channel tags

- "This device" badge uses the default neutral tag instead of success green.
- Show each live connection's channel as a small tag (desktop / cli) so a
  multi-channel device's connections are individually legible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(devtools): add API jump-list column to the render gallery

The render gallery stacked all of a toolset's API previews in one scroll column
(67 for Claude Code), making any specific render slow to find. Add a middle
column listing the toolset's apiNames: clicking scrolls the matching preview
card into view (landing below the sticky lifecycle bar via scroll-margin), and
an rAF-throttled scrollspy highlights the API the reader is on and keeps that
item visible in the list. A leading dot marks APIs that ship a Render. The
content area now owns its own scroll so the list stays pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(devtools): make the API jump-list readable + deep-linkable

The jump-list was a wall of identical `mcp__claude_ai_Linear__…` truncations and
the active item barely differed from hover. Show just the trailing action for
mcp__ tools (full id in a title tooltip + the preview card header), render names
in monospace, and give the active item a primary left-accent so it reads as
selected. Clicking now pins a `#api-<name>` hash (deep-linkable / shareable) and
loading a hashed URL jumps straight to that card below the sticky bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(devtools): add an Aggregate message-flow preview tab

The gallery only previewed each API in isolation. Add a View tab (By API /
Aggregate): Aggregate stitches every render-bearing API into one compact
content + tool message flow, so renders can be judged in conversational context
across any lifecycle mode. Inspector-only MCP tools are dropped to keep the
thread about the renders, and the API jump-list column hides in this view.

Extract the Inspector/Body surface rendering out of ToolPreview into shared
ToolInspectorSlot / ToolBodySlot (toolSurfaces.tsx) so both tabs derive props
identically and never drift. View choice persists to localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(devtools): densify API list + keep mcp prefix visible

The earlier "shorten mcp names" change solved the wrong problem and hid the
`mcp__` prefix, so MCP tools no longer read as MCP. The actual complaint was row
height. Restore the full identifier and instead middle-elide it
(`mcp__claude_ai_Li…get_diff`) so both the muted `mcp` namespace and the
distinguishing trailing action stay visible; full id remains in the title
tooltip. Drop row height to a fixed dense 22px (flex-shrink:0 so it scrolls
instead of squishing) to fit far more APIs per screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(devtools): render Aggregate tab through the real Conversation renderer

The hand-rolled MessageList only approximated the chat. Replace it with the
actual shipping renderer: seed a `ConversationProvider` (skipFetch) with fixture
`assistantGroup` messages and map each render-bearing API to a real tool
payload, then render the real `MessageItem` for each. Tool state is driven
purely by the message shape — `result` → success, `result.error` → error,
`intervention.pending` → intervention, unterminated `arguments` JSON →
streaming — so the preview is byte-for-byte what users see in chat. Skips the
virtualized `ChatList` (and its data fetches) by mapping `MessageItem` directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): device detail drawer (channels + recent dirs + config)

Clicking a device row now opens a right-side detail drawer instead of a small
edit modal:
- Connections: render every live connection from the `channels` array, each
  with its channel tag (desktop / cli) + connected-since.
- Name + default working directory (native folder browse on the current
  device); saving a default cwd also seeds the recent list.
- Recent directories: list `recentCwds`, click to reuse, × to remove — this is
  where you can see and manage the recent list (previously not surfaced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): record recentCwds on the local device picker

Local-mode runs execute on this machine, but the local working-directory picker
only persisted to a desktop-local recents store — the dir never reached the
device registry, so the settings detail view (and a future device-mode picker)
couldn't see it.

- WorkingDirectory.selectDir now also records the chosen dir into the current
  device's recentCwds (resolved from the gateway's own deviceId).
- useUpdateDeviceCwd gains a { setDefault } option so local mode records
  recentCwds without repointing the device's defaultCwd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🩹 fix(devtools): thread Aggregate preview messages via parentId

Each fixture turn was an orphaned message with no parentId, so the renderer saw
a pile of disconnected messages rather than one conversation. Chain every turn
onto the previous one (`parentId` = prior message id) so they read as a single
linear thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(devtools): seed flat messages so conversation-flow groups the Aggregate

The previous version hand-built `role: 'assistantGroup'` messages, bypassing the
real grouping. Seed the flat DB-shaped messages instead — an `assistant` message
carrying the tool_use plus a linked `role: 'tool'` result message per API — and
let conversation-flow's `parse()` synthesize the assistantGroup exactly as it
does in chat. The consecutive tool turns now collapse into one real workflow
group (one avatar, N content+tool blocks) instead of N hand-rolled groups.
Lifecycle state rides the tool message the same way production carries it
(content/pluginState = success, pluginError = error, pluginIntervention = pending).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 refactor(device): inline master-detail device settings; drop uppercase labels

Per feedback:
- Replace the floating edit Drawer with an inline right-hand detail panel —
  the devices page is now a master-detail layout (device list on the left,
  selected device's detail on the right), like the rest of settings.
- Drop the ALL-CAPS section labels (no more text-transform: uppercase /
  letter-spacing) — labels use natural case + a muted color.

DeviceItem becomes a selectable list row (no own modal); DeviceDetailPanel
renders the detail inline (connections per channel, name, default cwd + browse,
recent dirs). Keyed on deviceId so the form resets on selection change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 refactor(device): detail panel opens on click, not by default

Per feedback — mirror the memory-preferences master-detail pattern:
- No device is selected by default; the right detail panel only renders once a
  row is clicked (clicking the selected row again closes it). Panel has its own
  close (×).
- List flexes to fill when nothing is selected; the detail appears as a right
  column on selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devtools): bind render gallery to viewport height so columns scroll

The page root used height:100%, which only resolves when an ancestor route
provides a bounded height — under mounts that don't, the whole page grew to
content height and the API list never scrolled internally. Bind the root to
100dvh directly and add min-height:0 to the flex chain (main + the API list)
so the scroll container engages regardless of how the route is mounted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(devtools): add WebFetch / WebSearch fixtures so they render

Both APIs had no fixture, so the gallery fell back to schema-sampled args with no
content and the renders drew empty (just the icon). Add fixtures with realistic
args + content: WebFetch (url + prompt + markdown answer), WebSearch (query +
allowed_domains + results), plus their apiList descriptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): render connections straight from device.channels[]

Drop the device.online-based synthetic single-channel fallback — the connection
rows now come purely from the device.channels[] array (one row per live
connection), with offline = empty array.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(hetero): distinguish CC server throttle from user quota limit

A 429 "Server is temporarily limiting requests (not your usage limit)"
was classified as a user rate_limit, rendering the misleading "Claude
Code usage limit reached" reset-time guide. Key the rate_limit vs
overloaded decision on the structured rate_limit_event reset window
(resetsAt / rateLimitType) instead of the HTTP status, so 429/529 with
no quota signal fall through to the overloaded (retry) UX.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(devtools): loosen the API list density

22px rows at 12px overcorrected into a cramped sidebar. Relax to 30px rows,
13px label, a small inter-row gap, and a touch more vertical padding so the
jump-list reads comfortably.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): align connection rows in the list item (drop 30px indent)

The connection rows had a 30px inline-start padding that pushed them right of
the cwd line; align them with the rest of the device info.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): move connection status dot to the first line

The online/offline status now sits as a dot next to the device name + badges
(with the connected / last-active time as a tooltip), instead of a separate
third line. Per-channel connection detail still lives in the detail panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 feat(devtools): show the Aggregate preview as "Lobe AI"

The seeded preview conversation resolved its avatar/name through an agentId that
wasn't in the agent store, so every turn fell back to the unresolved-agent
"Unnamed Assistant" / UN avatar. Seed agentMap with a Lobe AI meta
(DEFAULT_INBOX_AVATAR + title) for the devtools agentId, shared via
DEVTOOLS_AGENT_ID / DEVTOOLS_AGENT_META so MessageList's context and the store
seed stay in sync. Restored on unmount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devtools): carry tool result state in BuiltinInspectorProps

The Aggregate preview passes `result.state` to inspectors, matching the
real runtime, but the canonical `result` type omitted `state` — failing
type-check. Add `state?: any` so devtools and runtime agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(device): pin topic cwd and add hetero-tracing toggle

- Prefer the topic's own `metadata.workingDirectory` over the device
  default when dispatching, so an existing topic keeps its pinned cwd
- Add `heteroTracingEnabled` store flag to trace CLI raw streams in
  packaged builds (Help menu checkbox)
- Reorder the connection status dot ahead of badges in DeviceItem

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

*  feat(device): add Help-menu toggle to record hetero-agent CLI traces in production

Packaged builds previously never wrote hetero-agent (CC / Codex) CLI traces,
so production issues couldn't be captured. Add a persisted `heteroTracingEnabled`
toggle in the Help menu (all 3 platforms) plus an "Open HeteroAgent Directory"
entry. Dev still always traces to `cwd/.heerogeneous-tracing`; packaged builds,
when enabled, centralize traces under `<appStoragePath>/heteroAgent/tracing`
(sibling to the existing files cache) via shared dir constants.

Closes LOBE-9828

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(skills): fold stacked-prs guidance into the pr skill

Merge the standalone `stacked-prs` skill into `pr` as a supplementary section
(ordering rule, file placement, git split recipe, dependency verification,
Linear bookkeeping, gotchas) and absorb its triggers into the pr description,
rather than keeping a separate skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devtools): chain RenderGallery previews into one assistantGroup

Unfinished tool states (streaming / loading) now emit a paired tool result
message with `LOADING_FLAT` content instead of none, and every assistant turn
chains onto the previous message's id. The tool_use → tool_result link is what
lets conversation-flow merge the turns into one assistantGroup; without it the
unfinished modes rendered as one orphaned group per tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(device): key hetero trace location off the toggle, not isPackaged

`resolveTraceRootDir` now centralizes traces under
`<appStoragePath>/heteroAgent/tracing` whenever `heteroTracingEnabled` is on,
instead of gating on `isPackaged`. Packaged behavior is unchanged (it only
traces when the toggle is on), and a dev who opts in now also gets the
centralized dir reachable from the Help-menu entry. Plain dev runs keep
writing to `cwd/.heerogeneous-tracing`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): move hetero dir consts to a side-effect-free module

Importing the new `HETERO_AGENT_*` constants from `@/const/dir` dragged that
module's load-time `app.getPath()` / `app.getAppPath()` calls into the menu and
controller import graphs, breaking menu/controller suites whose electron mocks
or partial `@/const/dir` mocks didn't anticipate it. Relocate the pure path
segments to `@/const/heteroAgent` (no electron import) and point the controller
+ all three menu impls there. Also add the now-required `storeManager.get/set`
to the menu test app mocks (the Help-menu tracing checkbox reads it at build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(devtools): refine RenderGallery surfaces and fix local-system fixtures

- flatten the active ApiList item (drop accent bar) and the ToolPreview card shadow
- give the Aggregate thread a white container surface
- hide deprecated lobe-notebook toolset and legacy *Local* aliases from the gallery
- re-key local-system fixtures to current API names + add missing call args
- backfill agent-management call args so inspectors render their argument rows

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(desktop): default global electron mock so import-time app access is safe

`@/const/dir` reads `app.getAppPath()` / `app.getPath()` at module load — fine
in production (app is ready), but it forced every test that transitively imports
it to stub those basics, which is the real root of the recent breakages.

Register a default `electron` mock in the global vitest setup, giving every
suite a ready `app` (paths + readiness) plus light stubs for the common
namespaces. Suites that need specific behavior still declare their own
`vi.mock('electron', …)`, which overrides this per-file. This keeps production
free to use plain value-style path constants instead of lazy getter functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-05-31 15:11:55 +08:00
committed by GitHub
parent 3caa3efb18
commit 373b5e90b2
75 changed files with 3261 additions and 1242 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**:
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui/](references/ui/README.md) |
---
@@ -2,7 +2,7 @@
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui/](ui/README.md).
For where files live and how registries work, see [architecture.md](architecture.md).
---
@@ -156,7 +156,7 @@ export const TaskManifest: BuiltinToolManifest = {
executors: ['client', 'server'],
/* Default human intervention policy for all APIs that don't specify one.
Pair with an Intervention component (see ui.md). */
Pair with an Intervention component (see ui/intervention.md). */
humanIntervention: 'never' | 'always' | { /* extended config */ },
}
```
@@ -1,744 +0,0 @@
# Tool UI Surfaces
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
The two reference tools to read end-to-end:
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
---
## Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
---
## 0. Shared Style Rules
These apply across every surface.
### 0.1 Use `'use client'` at the top of every component file
Tool surfaces are leaves in the chat tree and must not block server rendering.
### 0.2 Prefer `createStaticStyles + cssVar.*`
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
```tsx
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
```
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
### 0.3 Use `@lobehub/ui`, not raw `antd`
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
### 0.4 Always `memo` and set `displayName`
```tsx
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args /* … */ }) => {
/* … */
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
### 0.6 Pull strings from `t('plugin')`
```tsx
const { t } = useTranslation('plugin');
t('builtins.<identifier>.apiName.<api>');
```
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
### 0.7 Read store state from `@/store/chat`, not props
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
---
## 1. Inspector — Header Chip (required)
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
### Props (`BuiltinInspectorProps<Args, State>`)
```ts
interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
```
### State machine
| Phase | What's available | What to show |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
### Canonical example — Search
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
```tsx
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}:&nbsp;</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### Inspector rules
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
### Inspector registry — `client/Inspector/index.ts`
```ts
import type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
```
---
## 2. Render — Rich Result Card (optional)
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
### Props (`BuiltinRenderProps<Args, State, Content>`)
```ts
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
```
### Two patterns
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
```tsx
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
```
**Pattern B — Folder with subcomponents** (web-browsing Search):
```
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
```
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
### Error handling in Render
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
```tsx
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
```
### Render rules
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
### Render registry — `client/Render/index.ts`
```ts
import type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
```
### Render display control (rare)
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
---
## 3. Placeholder — Skeleton Between Args and Result (optional)
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
### Props (`BuiltinPlaceholderProps<Args>`)
```ts
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
```
No `pluginState` — Placeholder lives entirely in the "executing" gap.
### Canonical example — Search Placeholder
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
```tsx
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
```
### Placeholder rules
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
### Placeholder registry — `client/Placeholder/index.ts`
```ts
import { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
```
---
## 4. Streaming — Live Output During Execution (optional)
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
### Props (`BuiltinStreamingProps<Args>`)
```ts
interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
```
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
### Canonical example — RunCommandStreaming
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
```tsx
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
```
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
```tsx
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
```
### Streaming rules
- Render `null` until you have something to display (avoids flash).
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
### Streaming registry — `client/Streaming/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
```
---
## 5. Intervention — Approval / Edit-Before-Run (optional)
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
### Props (`BuiltinInterventionProps<Args>`)
```ts
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
```
### Canonical example — RunCommand Intervention
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
```tsx
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
```
### Intervention rules
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
### Intervention registry — `client/Intervention/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
```
---
## 6. Portal — Full-Screen Detail View (optional)
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
### Props (`BuiltinPortalProps<Args, State>`)
```ts
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
```
### Canonical example — Web-Browsing Portal
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
```tsx
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
```
### Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
### Portal registry — `packages/builtin-tools/src/portals.ts`
```ts
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
```
---
## 7. `client/components/` — Shared Subcomponents
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from `web-browsing/src/client/components/`:
- `CategoryAvatar.tsx` — search category icon
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
Examples from `local-system/src/client/components/`:
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
### Rules
- Live under `client/components/`, exported via `client/components/index.ts`.
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
## 8. `client/index.ts` — Package Public API
Re-exports everything the registries need plus useful types/manifest:
```ts
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
```
---
## 9. Diagnostic Quick-Lookup
| Symptom | Surface to check | | |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
@@ -0,0 +1,36 @@
# Tool UI Surfaces
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
The two reference tools to read end-to-end:
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
---
## Files in this folder
Read **principles** and **shared-rules** first — they apply to every surface. Then jump to the surface you're building.
| File | What it covers |
| ---------------------------------- | ----------------------------------------------------------------------- |
| [principles.md](principles.md) | Design principles — when each surface exists and how far to take it |
| [shared-rules.md](shared-rules.md) | Cross-surface rules: component skeleton, styling, single-layer surfaces |
| [inspector.md](inspector.md) | Inspector — header chip (required) |
| [render.md](render.md) | Render — rich result card |
| [placeholder.md](placeholder.md) | Placeholder — skeleton between args and result |
| [streaming.md](streaming.md) | Streaming — live output during execution |
| [intervention.md](intervention.md) | Intervention — approval / edit-before-run |
| [portal.md](portal.md) | Portal — full-screen detail view |
| [composition.md](composition.md) | Shared subcomponents (`client/components/`) + package public API |
| [diagnostics.md](diagnostics.md) | Symptom → surface quick-lookup |
@@ -0,0 +1,51 @@
# Composition — Shared Components & Package API
## `client/components/` — Shared Subcomponents
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from `web-browsing/src/client/components/`:
- `CategoryAvatar.tsx` — search category icon
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
Examples from `local-system/src/client/components/`:
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
### Rules
- Live under `client/components/`, exported via `client/components/index.ts`.
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
## `client/index.ts` — Package Public API
Re-exports everything the registries need plus useful types/manifest:
```ts
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
```
@@ -0,0 +1,15 @@
# Diagnostic Quick-Lookup
| Symptom | Surface to check |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry |
| Header shows the API name but no chips | Inspector missing `args?.X \|\| partialArgs?.X` fallback |
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` |
| Empty result card under header | Render returned `<div />` instead of `null` when no data |
| Render looks "complex" / card-in-card | Filled container (`colorFillQuaternary`) wrapping more filled boxes — flatten to single-layer, see [shared-rules.md](shared-rules.md) |
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions |
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry |
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` |
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName |
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) |
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` |
@@ -0,0 +1,118 @@
# Inspector — Header Chip (required)
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
## Props (`BuiltinInspectorProps<Args, State>`)
```ts
interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
```
## State machine
| Phase | What's available | What to show |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
## Canonical example — Search
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
```tsx
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}:&nbsp;</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
## Inspector rules
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
## Inspector registry — `client/Inspector/index.ts`
```ts
import type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
```
@@ -0,0 +1,88 @@
# Intervention — Approval / Edit-Before-Run (optional)
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
## Props (`BuiltinInterventionProps<Args>`)
```ts
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
```
## Canonical example — RunCommand Intervention
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
```tsx
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
```
## Intervention rules
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
## Intervention registry — `client/Intervention/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
```
@@ -0,0 +1,93 @@
# Placeholder — Skeleton Between Args and Result (optional)
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
## Props (`BuiltinPlaceholderProps<Args>`)
```ts
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
```
No `pluginState` — Placeholder lives entirely in the "executing" gap.
## Canonical example — Search Placeholder
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
```tsx
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
```
## Placeholder rules
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
## Placeholder registry — `client/Placeholder/index.ts`
```ts
import { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
```
@@ -0,0 +1,71 @@
# Portal — Full-Screen Detail View (optional)
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
## Props (`BuiltinPortalProps<Args, State>`)
```ts
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
```
## Canonical example — Web-Browsing Portal
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
```tsx
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
```
## Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
## Portal registry — `packages/builtin-tools/src/portals.ts`
```ts
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
```
@@ -0,0 +1,19 @@
# Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。具体的样式约定见 [shared-rules.md](shared-rules.md)。
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
@@ -0,0 +1,101 @@
# Render — Rich Result Card (optional)
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
## Props (`BuiltinRenderProps<Args, State, Content>`)
```ts
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
```
## Two patterns
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
```tsx
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
```
**Pattern B — Folder with subcomponents** (web-browsing Search):
```
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
```
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
## Error handling in Render
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
```tsx
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
```
## Render rules
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- **Keep the Render single-layer** — the tool card is already your surface, so don't open with your own filled container and then nest more filled boxes inside it. See [shared-rules.md](shared-rules.md) → "Stay single-layer".
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
## Render registry — `client/Render/index.ts`
```ts
import type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
```
## Render display control (rare)
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
@@ -0,0 +1,89 @@
# Shared Style Rules
These apply across every surface.
## The component skeleton
Every surface file is the same shape, so internalize it once instead of re-deriving it per rule. The skeleton below bakes in five mechanical conventions — copy it and fill the body:
```tsx
'use client'; // (a) leaves of the chat tree must not block server rendering
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
// (b) type with BuiltinXProps<Args, State> — never widen to `any`.
// Args = the JSON Schema params, State = the executor's `state` field;
// they should match <Name>Params / <Name>State from types.ts.
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, pluginState }) => {
const { t } = useTranslation('plugin'); // (c) all strings from the `plugin` namespace
// (d) cross-cutting state (loading, streaming buffer) comes from the store,
// not props — props only carry args/state/messageId.
// const buffer = useChatStore((s) => chatToolSelectors.streamingBuffer(messageId)(s));
return <span>{t('builtins.<identifier>.apiName.search')}</span>;
},
);
SearchInspector.displayName = 'SearchInspector'; // (e) always memo + displayName
export default SearchInspector;
```
- **(c)** Default an Inspector to `t('builtins.<identifier>.apiName.<api>')` so the row is non-empty while args stream in.
- **(d)** Read the store via Zustand selectors inside the component; see [streaming.md](streaming.md) for the buffer selector.
## Styling: `createStaticStyles + cssVar.*`, `@lobehub/ui` over `antd`
Zero-runtime CSS-in-JS — styles compile once and read CSS variables at runtime:
```tsx
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
```
- Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
- Components come from `@lobehub/ui` (`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton`), not raw `antd`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
- Note: `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. For that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
## Stay single-layer — don't nest filled cards
The framework already wraps every Render / Intervention in a tool card, so that card **is** your surface. A Render that opens with its own `background: ${cssVar.colorFillQuaternary}` container is already one card deep; put another filled box inside it (`colorBgContainer` / `colorFillTertiary`) and you get the card-in-card look that reads as "complex" — two or three stacked fills for what is really a flat list of fields.
- **The outermost wrapper carries no fill.** Use a flat container with only `padding-block: 4px` for breathing room; let the tool card provide the card. (See `Agent/index.tsx`'s `container`.)
- **At most one filled box, and only to delineate real content** — a Markdown preview, a diff, a code/result block. Labels, keyvalue fields, question/answer text, chips: render flat on the surface, separated by spacing or a hairline divider (`height: 1px; background: ${cssVar.colorFillSecondary}`), not by wrapping each in its own box.
- **A box on a flat surface needs a visible fill.** Once the outer fill is gone, an inner `colorBgContainer` box can vanish against the tool card (same color). Use `colorFillTertiary` for the one content box so it still reads as delineated.
- Don't wrap a single value in a box just to give it padding — that's the redundant-nesting smell (a `detailCard` around a `value` box around one string).
```tsx
// ❌ card-in-card: filled container wrapping a filled preview box
container: css`
padding: 12px;
background: ${cssVar.colorFillQuaternary};
`,
previewBox: css`
background: ${cssVar.colorBgContainer};
`,
// ✅ single-layer: flat container, one visible content box
container: css`
padding-block: 4px;
`,
previewBox: css`
background: ${cssVar.colorFillTertiary};
`,
```
For the common "icon + file/title header, then one content box" shape, reuse `ToolResultCard` from `@lobechat/shared-tool-ui/components` instead of rebuilding it — it's already single-layer (flat wrapper, one `colorFillTertiary` content box) and is what CC `Read` / `Grep` / `Glob` / `Write` / `WebSearch` / `WebFetch` render through.
The exception is a deliberate **panel** pattern — an `<Block variant="outlined">` with a header bar + list rows (CC `TodoWrite` / `Task`). There the single outlined block is the panel and the header fill is a header bar, not a nested card. One structured panel is fine; stacked decorative fills are not.
@@ -0,0 +1,83 @@
# Streaming — Live Output During Execution (optional)
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
## Props (`BuiltinStreamingProps<Args>`)
```ts
interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
```
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
## Canonical example — RunCommandStreaming
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
```tsx
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
```
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
```tsx
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
```
## Streaming rules
- Render `null` until you have something to display (avoids flash).
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
## Streaming registry — `client/Streaming/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
```
+80 -1
View File
@@ -1,6 +1,6 @@
---
name: pr
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'."
description: "Create a PR for the current branch (targets `canary` by default), including splitting one cross-layer branch into ordered stacked PRs so a lower layer (db / shared package / server TRPC) merges before its callers (desktop / CLI / UI). Use when the user asks to create / submit a PR, or to split a branch because clients call a server contract that isn't on the trunk yet. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', 'split this PR', 'stacked PR', 'backend should merge first', '提 PR', '提个 PR', '新建 PR', '拆 PR', '后端先合', '分层合并'."
user-invocable: true
---
@@ -71,3 +71,82 @@ Use `.github/PULL_REQUEST_TEMPLATE.md` as the body structure. Key sections:
- **Language**: All PR content must be in English
- If a PR already exists for the branch, inform the user instead of creating a duplicate
---
# Stacked PRs (cross-layer feature)
The steps above create **one** PR for the current branch. When a single branch lands across layers — `packages/database` schema/model → a shared `packages/*` lib → `src/server` TRPC → `apps/desktop` + `apps/cli` callers → `src/features` UI — shipping it as one PR can't merge safely: the clients call an endpoint that doesn't exist on the trunk until the same PR merges, so any partial/rollback or independent review breaks. Split it into **ordered PRs**, lower layer first.
## The ordering rule
A PR may only merge **after** every layer it calls is already on the trunk.
- The **server contract** (new TRPC procedure, changed return shape, new table/model) merges first.
- The **callers** (desktop, CLI, UI) merge after — they invoke that contract.
- Tie-break with one question: _"if this merged alone to `canary` right now, would it build and behave?"_ If no, it belongs in a later PR.
## Which file goes in which PR
The non-obvious calls:
- **Frontend that adapts to a contract change goes WITH the server PR.** If you widen a TRPC return shape (e.g. `listDevices` now returns `platform: string | null`), the component consuming it must change in the _same_ PR — otherwise the server PR breaks the build on its own. Contract + its in-repo consumers ship together.
- **A new shared package goes with its consumer**, not the server, unless the server imports it too. A `@lobechat/*` package imported only by desktop/CLI ships in the client PR. Don't carry an unused package in the lower PR.
- **Workspace dep declarations** (`package.json` `workspace:*`, `pnpm-workspace.yaml`) travel with the code that imports the package.
## The git recipe — split an existing full branch
Starting point: one branch (`feat/x`) with a single commit `<FULL>` containing everything, already pushed (so it's also safe on the remote).
```bash
# 1. Safety nets — make the full work unloseable before rewriting anything
git branch backup/x-full <FULL> # local ref to the full commit
git branch feat/x-clients <FULL> # the higher-layer branch starts here
# 2. Rewrite the lower-layer branch to lower-layer files only
git checkout feat/x # this becomes the SERVER PR
git reset --hard origin/canary
git checkout <FULL> -- <server/db files…> # stages just those paths
git commit -m "✨ feat(...): <server half>"
git push --force-with-lease origin feat/x # never --force; never push to canary
# 3. Build the higher-layer branch STACKED on the lower branch
git checkout feat/x-clients
git reset --hard feat/x # base = the just-rewritten server HEAD
git checkout backup/x-full -- <client/ui files…> # only the remaining paths
git commit -m "✨ feat(...): <client half>"
git push -u origin feat/x-clients
```
Then open the higher PR **based on the lower branch**, not the trunk:
```bash
gh pr create --base feat/x --head feat/x-clients --title "…" --body "…"
```
`--base feat/x` keeps the diff client-only (no server files leak in) and makes it physically impossible to merge the clients before the server. **After the server PR merges to `canary`, retarget the client PR's base to `canary`** (GitHub usually auto-retargets when the base branch merges; note it in the PR body so a human confirms).
## Verify the dependency actually holds
The whole point is the higher layer needs the lower one. Prove it: on the stacked higher branch, type-check the caller and confirm the symbol the lower layer introduced resolves.
```bash
cd apps/cli && bun run type-check 2>&1 | grep -iE "connect\.ts|device\.register"
# empty (re: your change) = the stacked base supplies device.register ✓
```
Filter to your touched files — this repo's standalone type-check emits pre-existing env noise (`__ELECTRON__`, `@/types/llm`, unbuilt `@lobechat/types`) that isn't yours.
## PR + Linear bookkeeping
- **Each PR closes only its own layer's issues.** Server PR: `Closes LOBE-<server>`. Client PR: `Closes LOBE-<pkg> / <desktop> / <cli>`. Don't let one PR's body claim another layer's issue.
- Both PRs are `Part of LOBE-<parent>`.
- On PR creation, move each closed sub-issue to **In Review** (not Done) and add a completion comment — see the `linear` skill.
## Gotchas
- **Never push to `canary`.** A split branch cut with `git checkout -b feat/x origin/canary` _tracks_ `origin/canary`, so a bare `git push` targets canary. Always `git push origin feat/x` with the explicit branch name.
- **`--force-with-lease`, not `--force`** when rewriting the lower branch — it aborts if the remote moved under you.
- **Back up before `reset --hard`.** Step 1's `backup/x-full` + the pushed remote branch mean the full commit is referenced by ≥3 refs before you rewrite anything. Verify with `git branch --contains <FULL>`.
- **Lockfiles:** this monorepo commits no root `pnpm-lock.yaml`, so a new `workspace:*` dep needs no lockfile churn. In a repo that _does_ commit one, regenerate it on each branch after the split.
- **Don't over-split.** Two PRs (contract / callers) is usually enough. A UI page that only reads an existing endpoint can be its own later PR, but don't fragment a single layer across PRs for its own sake.
@@ -56,9 +56,11 @@
"help.about": "关于",
"help.githubRepo": "GitHub 仓库",
"help.openConfigDir": "配置目录",
"help.openHeteroAgentDir": "打开 HeteroAgent 目录",
"help.openLogsDir": "打开日志目录",
"help.reportIssue": "反馈问题",
"help.title": "帮助",
"help.toggleHeteroTracing": "记录 Agent CLI 调试日志",
"help.visitWebsite": "打开官网",
"history.back": "后退",
"history.forward": "前进",
@@ -0,0 +1,68 @@
/**
* Default global `electron` mock (registered in `setup.ts`).
*
* Provides a fully-formed `app` (paths + readiness) plus light stubs for the
* other commonly-imported namespaces. The point is that modules which touch
* electron at import time — notably `@/const/dir`'s eager `app.getAppPath()` /
* `app.getPath('userData')` — can be imported from ANY test without each suite
* re-stubbing these basics. This keeps production code free to use plain
* value-style path constants instead of lazy getter functions.
*
* Test files that need specific behavior still declare their own
* `vi.mock('electron', …)`, which takes precedence per-file over this default.
*/
import { vi } from 'vitest';
export const app = {
getAppPath: vi.fn(() => '/mock/app'),
getLocale: vi.fn(() => 'en-US'),
getName: vi.fn(() => 'LobeHub'),
getPath: vi.fn((name: string) => `/mock/${name}`),
getVersion: vi.fn(() => '0.0.0-test'),
isPackaged: false,
on: vi.fn(),
quit: vi.fn(),
requestSingleInstanceLock: vi.fn(() => true),
setName: vi.fn(),
whenReady: vi.fn(() => Promise.resolve()),
};
export const BrowserWindow = Object.assign(vi.fn(), {
getAllWindows: vi.fn(() => []),
getFocusedWindow: vi.fn(() => null),
});
export const Menu = {
buildFromTemplate: vi.fn(() => ({})),
setApplicationMenu: vi.fn(),
};
export const ipcMain = { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() };
export const shell = {
openExternal: vi.fn(() => Promise.resolve()),
openPath: vi.fn(() => Promise.resolve('')),
};
export const dialog = { showMessageBox: vi.fn(), showOpenDialog: vi.fn() };
export const nativeTheme = { on: vi.fn(), shouldUseDarkColors: false, themeSource: 'system' };
export const protocol = { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() };
export const clipboard = { readText: vi.fn(() => ''), writeText: vi.fn() };
export const nativeImage = { createEmpty: vi.fn(), createFromPath: vi.fn() };
export default {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
Menu,
nativeImage,
nativeTheme,
protocol,
shell,
};
+6
View File
@@ -5,3 +5,9 @@ import { vi } from 'vitest';
// Mock node-mac-permissions before any imports
vi.mock('node-mac-permissions', () => import('./node-mac-permissions'));
// Default electron mock: gives every suite a ready `app` (paths + readiness)
// so modules with import-time electron access (e.g. `@/const/dir`) load safely
// without per-suite stubbing. A test's own `vi.mock('electron', …)` overrides
// this per-file.
vi.mock('electron', () => import('./electron'));
@@ -0,0 +1,13 @@
/**
* Heterogeneous-agent (CC / Codex) working-directory segment names, relative to
* `appStoragePath`. Kept in this side-effect-free module (no electron import)
* so lightweight importers — the menu impls, the controller — get a single
* source of truth without dragging in `@/const/dir`'s load-time `app.getPath`
* calls.
*
* - `<HETERO_AGENT_DIR>/files` — downloaded-file cache
* - `<HETERO_AGENT_DIR>/tracing` — CLI trace sessions (packaged / opted-in)
*/
export const HETERO_AGENT_DIR = 'heteroAgent';
export const HETERO_AGENT_FILES_DIR = `${HETERO_AGENT_DIR}/files`;
export const HETERO_AGENT_TRACING_DIR = `${HETERO_AGENT_DIR}/tracing`;
+1
View File
@@ -34,6 +34,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
gatewayDeviceName: '',
gatewayEnabled: true,
gatewayUrl: 'https://device-gateway.lobehub.com',
heteroTracingEnabled: false,
imessageBridgeConfigs: [],
locale: 'auto',
localFileWorkspaceRoots: [],
@@ -206,6 +206,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: request.prompt,
resumeSessionId: request.resumeSessionId,
serverUrl,
systemContext: request.systemContext,
topicId: request.topicId,
});
@@ -28,6 +28,7 @@ import {
} from '@lobechat/heterogeneous-agents/spawn';
import { app as electronApp, BrowserWindow } from 'electron';
import { HETERO_AGENT_FILES_DIR, HETERO_AGENT_TRACING_DIR } from '@/const/heteroAgent';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import type {
HeterogeneousAgentBuildPlan,
@@ -62,7 +63,7 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
] as const;
/** Directory under appStoragePath for caching downloaded files */
const FILE_CACHE_DIR = 'heteroAgent/files';
const FILE_CACHE_DIR = HETERO_AGENT_FILES_DIR;
const CLI_TRACE_DIR = '.heerogeneous-tracing';
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
@@ -434,7 +435,32 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
private get shouldTraceCliOutput(): boolean {
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
if (process.env.NODE_ENV === 'test') return false;
// Dev builds always trace. Packaged builds trace only when the user has
// flipped the Help-menu developer toggle — so production issues can be
// captured on demand without polluting normal runs.
if (!electronApp.isPackaged) return true;
return this.app.storeManager.get('heteroTracingEnabled', false);
}
/**
* Root directory for CLI trace sessions.
*
* When the user has explicitly opted in via the `heteroTracingEnabled`
* Help-menu toggle, centralize traces under the app storage dir
* (`<appStoragePath>/heteroAgent/tracing`) — this is the only path packaged
* builds ever trace through, and it keeps traces out of the user's real
* project directory while staying reachable from one stable Help-menu entry.
*
* Otherwise (a plain dev run with the toggle off) keep writing into the
* working directory (`cwd/.heerogeneous-tracing`) — devs expect traces to
* show up alongside the repo they're running in.
*/
private resolveTraceRootDir(cwd: string): string {
if (this.app.storeManager.get('heteroTracingEnabled', false)) {
return path.join(this.app.appStoragePath, HETERO_AGENT_TRACING_DIR);
}
return path.join(cwd, CLI_TRACE_DIR);
}
private formatTraceTimestamp(date: Date): string {
@@ -501,7 +527,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
const createdAt = new Date();
const rootDir = path.join(cwd, CLI_TRACE_DIR);
const rootDir = this.resolveTraceRootDir(cwd);
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
session.sessionId,
@@ -1266,10 +1292,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
prompt: string;
resumeSessionId?: string;
serverUrl: string;
systemContext?: string;
topicId: string;
}): void {
const { agentType, cwd, jwt, operationId, prompt, resumeSessionId, serverUrl, topicId } =
params;
const {
agentType,
cwd,
jwt,
operationId,
prompt,
resumeSessionId,
serverUrl,
systemContext,
topicId,
} = params;
const workDir = cwd ?? process.cwd();
const args = [
@@ -1305,7 +1341,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
stdio: ['pipe', 'inherit', 'inherit'],
});
child.stdin.write(JSON.stringify(prompt));
// When systemContext is provided, send a content-block array so CC sees the
// context block first, then the user's actual message — mirrors
// spawnHeteroSandbox. lh handles JSON arrays via coerceJsonPrompt, so no lh
// changes are required.
const stdinPayload = systemContext
? JSON.stringify([
{ text: systemContext, type: 'text' },
{ text: prompt, type: 'text' },
])
: JSON.stringify(prompt);
child.stdin.write(stdinPayload);
child.stdin.end();
child.on('error', (err) => {
@@ -95,6 +95,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
operationId = 'op-1',
prompt = 'hello',
jwt = 'mock-jwt',
extra: Record<string, unknown> = {},
) {
this.emit('agent_run_request', {
agentType,
@@ -103,6 +104,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
prompt,
topicId: 'topic-1',
type: 'agent_run_request',
...extra,
});
}
@@ -733,6 +735,22 @@ describe('GatewayConnectionCtr', () => {
},
);
it('forwards cwd and systemContext from the request to spawnLhHeteroExec', async () => {
const client = await connectAndOpen();
client.simulateAgentRunRequest('claude-code', 'op-ctx', 'hi', 'mock-jwt', {
cwd: '/Users/alice/repo',
systemContext: 'WORKSPACE CONTEXT',
});
await vi.advanceTimersByTimeAsync(0);
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/Users/alice/repo',
systemContext: 'WORKSPACE CONTEXT',
}),
);
});
it('sends accepted ack and spawns lh hetero exec', async () => {
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-xyz');
@@ -5,6 +5,9 @@ import path from 'node:path';
import { PassThrough } from 'node:stream';
import { HeterogeneousAgentSessionErrorCode } from '@lobechat/electron-client-ipc';
// `electron` is mocked below; this binding is the mock object so tests can
// flip `isPackaged` to exercise the packaged-build tracing gate.
import { app as electronAppMock } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
@@ -23,6 +26,7 @@ const { mockGetAllWindows } = vi.hoisted(() => ({
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => mockGetAllWindows() },
app: {
getAppPath: vi.fn(() => '/fake/appPath'),
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
isPackaged: false,
on: vi.fn(),
@@ -331,13 +335,14 @@ describe('HeterogeneousAgentCtr', () => {
sessionOverrides: Record<string, any> = {},
stdoutLines: string[] = [],
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
storeGet?: (key: string, defaultValue?: any) => any,
) => {
const { proc, writes } = createFakeProc({ stdoutLines });
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
storeManager: { get: storeGet ? vi.fn(storeGet) : vi.fn() },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
@@ -620,6 +625,85 @@ describe('HeterogeneousAgentCtr', () => {
}
});
it('centralizes to heteroAgent/tracing in dev too when the toggle is on', async () => {
const originalNodeEnv = process.env.NODE_ENV;
// Dev (isPackaged stays false), but the user opted in via the toggle.
process.env.NODE_ENV = 'development';
try {
const prompt = 'trace this opted-in dev run';
const rawLine = `${JSON.stringify({
thread_id: 'thread_codex_dev_optin',
type: 'thread.started',
})}\n`;
await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {}, (key: string) =>
key === 'heteroTracingEnabled' ? true : undefined,
);
const agentTraceRoot = path.join(appStoragePath, 'heteroAgent', 'tracing', 'codex');
const traceDirs = await readdir(agentTraceRoot);
expect(traceDirs).toHaveLength(1);
// Toggle wins over the dev cwd default.
await expect(readdir(path.join(appStoragePath, '.heerogeneous-tracing'))).rejects.toThrow();
} finally {
process.env.NODE_ENV = originalNodeEnv;
}
});
it('traces to the centralized heteroAgent/tracing dir in packaged builds when the toggle is on', async () => {
const originalNodeEnv = process.env.NODE_ENV;
// The gate short-circuits to `false` under NODE_ENV=test, so simulate a
// real packaged production process.
process.env.NODE_ENV = 'production';
(electronAppMock as any).isPackaged = true;
try {
const prompt = 'trace this packaged run';
const rawLine = `${JSON.stringify({
thread_id: 'thread_codex_packaged',
type: 'thread.started',
})}\n`;
await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {}, (key: string) =>
key === 'heteroTracingEnabled' ? true : undefined,
);
// Centralized under appStoragePath/heteroAgent/tracing — NOT in the cwd.
const traceRoot = path.join(appStoragePath, 'heteroAgent', 'tracing');
const agentTraceRoot = path.join(traceRoot, 'codex');
const traceDirs = await readdir(agentTraceRoot);
expect(traceDirs).toHaveLength(1);
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
// The dev-style cwd location must NOT be written in packaged mode.
await expect(readdir(path.join(appStoragePath, '.heerogeneous-tracing'))).rejects.toThrow();
} finally {
process.env.NODE_ENV = originalNodeEnv;
(electronAppMock as any).isPackaged = false;
}
});
it('does not trace in packaged builds when the toggle is off', async () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
(electronAppMock as any).isPackaged = true;
try {
await runSendPrompt('no trace please', { cwd: appStoragePath }, [], {}, (key: string) =>
key === 'heteroTracingEnabled' ? false : undefined,
);
await expect(
readdir(path.join(appStoragePath, 'heteroAgent', 'tracing')),
).rejects.toThrow();
} finally {
process.env.NODE_ENV = originalNodeEnv;
(electronAppMock as any).isPackaged = false;
}
});
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
@@ -56,9 +56,11 @@ const menu = {
'help.about': 'About',
'help.githubRepo': 'GitHub Repository',
'help.openConfigDir': 'Open Config Directory',
'help.openHeteroAgentDir': 'Open HeteroAgent Directory',
'help.openLogsDir': 'Open Logs Directory',
'help.reportIssue': 'Send Feedback',
'help.title': 'Help',
'help.toggleHeteroTracing': 'Record Agent CLI Trace Logs',
'help.visitWebsite': 'Open Website',
'history.back': 'Back',
'history.forward': 'Forward',
@@ -108,6 +108,10 @@ const createMockApp = () => {
updaterManager: {
checkForUpdates: vi.fn(),
},
storeManager: {
get: vi.fn(),
set: vi.fn(),
},
} as unknown as App;
};
@@ -1,7 +1,10 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
import { BaseMenuPlatform } from './BaseMenuPlatform';
@@ -214,6 +217,25 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
label: t('help.githubRepo'),
},
{ type: 'separator' },
{
click: () => {
const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR);
console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`);
shell.openPath(heteroAgentPath).catch((err) => {
console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err);
});
},
label: t('help.openHeteroAgentDir'),
},
{
checked: this.app.storeManager.get('heteroTracingEnabled', false),
click: (item) => {
this.app.storeManager.set('heteroTracingEnabled', item.checked);
},
label: t('help.toggleHeteroTracing'),
type: 'checkbox',
},
{ type: 'separator' },
{
click: () => {
const commonT = this.app.i18n.ns('common');
@@ -94,7 +94,9 @@ const createMockApp = () => {
rebuildAppMenu: vi.fn(),
},
storeManager: {
get: vi.fn(),
openInEditor: vi.fn(),
set: vi.fn(),
},
} as unknown as App;
};
@@ -4,6 +4,7 @@ import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
import NotificationCtr from '@/controllers/NotificationCtr';
import SystemController from '@/controllers/SystemCtr';
@@ -294,6 +295,25 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
},
label: t('help.openConfigDir'),
},
{
click: () => {
const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR);
console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`);
shell.openPath(heteroAgentPath).catch((err) => {
console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err);
});
},
label: t('help.openHeteroAgentDir'),
},
{ type: 'separator' },
{
checked: this.app.storeManager.get('heteroTracingEnabled', false),
click: (item) => {
this.app.storeManager.set('heteroTracingEnabled', item.checked);
},
label: t('help.toggleHeteroTracing'),
type: 'checkbox',
},
],
},
];
@@ -85,6 +85,10 @@ const createMockApp = () => {
getUpdaterState: vi.fn(() => ({ stage: 'idle' })),
installNow: vi.fn(),
},
storeManager: {
get: vi.fn(),
set: vi.fn(),
},
} as unknown as App;
};
@@ -1,7 +1,10 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
import { BaseMenuPlatform } from './BaseMenuPlatform';
@@ -211,6 +214,25 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
},
label: t('help.githubRepo'),
},
{ type: 'separator' },
{
click: () => {
const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR);
console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`);
shell.openPath(heteroAgentPath).catch((err) => {
console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err);
});
},
label: t('help.openHeteroAgentDir'),
},
{
checked: this.app.storeManager.get('heteroTracingEnabled', false),
click: (item) => {
this.app.storeManager.set('heteroTracingEnabled', item.checked);
},
label: t('help.toggleHeteroTracing'),
type: 'checkbox',
},
],
},
];
+6
View File
@@ -19,6 +19,12 @@ export interface ElectronMainStore {
gatewayDeviceName: string;
gatewayEnabled: boolean;
gatewayUrl: string;
/**
* Developer toggle: when true, hetero-agent (CC / Codex) CLI raw streams are
* traced to disk even in packaged production builds. Dev builds always trace
* regardless of this flag. Exposed via the Help menu checkbox.
*/
heteroTracingEnabled: boolean;
imessageBridgeConfigs: ImessageBridgeConfig[];
locale: string;
localFileWorkspaceRoots: string[];
+5
View File
@@ -271,6 +271,11 @@
"devices.actions.edit": "Edit",
"devices.actions.remove": "Remove",
"devices.channel.connected": "Connected {{time}}",
"devices.currentBadge": "This device",
"devices.detail.connections": "Connections",
"devices.detail.noRecent": "No recent directories",
"devices.detail.recentDirs": "Recent directories",
"devices.edit.browse": "Browse…",
"devices.edit.cancel": "Cancel",
"devices.edit.defaultCwd": "Default working directory",
"devices.edit.defaultCwdPlaceholder": "e.g. /Users/me/projects",
+5
View File
@@ -271,6 +271,11 @@
"devices.actions.edit": "编辑",
"devices.actions.remove": "移除",
"devices.channel.connected": "已连接 {{time}}",
"devices.currentBadge": "当前设备",
"devices.detail.connections": "连接通道",
"devices.detail.noRecent": "暂无最近目录",
"devices.detail.recentDirs": "最近目录",
"devices.edit.browse": "浏览…",
"devices.edit.cancel": "取消",
"devices.edit.defaultCwd": "默认工作目录",
"devices.edit.defaultCwdPlaceholder": "例如 /Users/me/projects",
@@ -42,9 +42,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
color: ${cssVar.colorTextTertiary};
`,
container: css`
padding: 12px;
border-radius: 8px;
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
`,
field: css`
margin-block-end: 8px;
@@ -80,9 +78,9 @@ export const CreateAgentRender = memo<BuiltinRenderProps<CreateAgentParams, Crea
if (pluginState?.success && (pluginState.agentId || pluginState.sessionId)) {
return (
<Flexbox
horizontal
align={'center'}
className={styles.agentCard}
horizontal
gap={12}
onClick={handleNavigateToSession}
>
@@ -10,9 +10,7 @@ import type { GetAgentDetailParams, GetAgentDetailState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 12px;
border-radius: 8px;
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
`,
field: css`
margin-block-end: 8px;
@@ -2,9 +2,10 @@
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { createStaticStyles } from 'antd-style';
import { Check, PenLine } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AskUserQuestionArgs, AskUserQuestionItem } from '../../../types';
@@ -16,29 +17,36 @@ interface AskUserQuestionState {
const styles = createStaticStyles(({ css, cssVar }) => ({
answer: css`
line-height: 1.6;
color: ${cssVar.colorText};
`,
answerRow: css`
padding-block: 6px;
padding-inline: 10px;
border-radius: 6px;
background: ${cssVar.colorBgContainer};
`,
check: css`
flex-shrink: 0;
margin-block-start: 3px;
color: ${cssVar.colorPrimary};
`,
container: css`
padding: 12px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
`,
header: css`
description: css`
font-size: 13px;
line-height: 1.6;
color: ${cssVar.colorTextTertiary};
`,
divider: css`
align-self: stretch;
height: 1px;
background: ${cssVar.colorFillSecondary};
`,
label: css`
font-size: 12px;
color: ${cssVar.colorTextSecondary};
color: ${cssVar.colorTextTertiary};
`,
question: css`
font-size: 15px;
font-weight: 500;
line-height: 1.5;
color: ${cssVar.colorText};
`,
}));
@@ -48,46 +56,57 @@ interface QABlockProps {
}
/**
* One question/answer pair for the completed Render. The original question
* stays visible (header + body); the answer renders as one card per picked
* option (multi-select fans out into multiple rows). When `answer` is
* absent older messages persisted before added structured
* storage we show a `` placeholder so the layout stays uniform.
* One question/answer pair for the completed Render, laid out as a single
* flat surface (no nested cards): a "Question" label + the question text,
* a hairline divider, then a "Selected" label + the picked option(s). Each
* pick is one check-prefixed line with its description underneath; multi-select
* fans out into multiple lines. When `answer` is absent older messages
* persisted before structured storage we show a `` placeholder so the
* layout stays uniform.
*/
const QABlock = memo<QABlockProps>(({ question, answer }) => {
const { t } = useTranslation('plugin');
const labels: string[] = Array.isArray(answer) ? answer : answer ? [answer] : [];
const optionByLabel = new Map(question.options.map((o) => [o.label, o]));
return (
<Flexbox gap={6}>
{question.header && <span className={styles.header}>{question.header}</span>}
<Text className={styles.question}>{question.question}</Text>
{labels.length > 0 ? (
<Flexbox gap={4}>
{labels.map((label) => {
const opt = optionByLabel.get(label);
return (
<Flexbox
horizontal
align="center"
className={cx(styles.answerRow)}
gap={8}
key={label}
>
<Icon className={styles.check} icon={Check} size={14} />
<Flexbox flex={1} gap={2}>
<Text className={styles.answer}>{label}</Text>
<Flexbox gap={12}>
<Flexbox gap={6}>
<span className={styles.label}>
{t('builtins.lobe-claude-code.askUserQuestion.question')}
</span>
<div className={styles.question}>{question.question}</div>
</Flexbox>
<div className={styles.divider} />
<Flexbox gap={8}>
<span className={styles.label}>
{t('builtins.lobe-claude-code.askUserQuestion.selected')}
</span>
{labels.length > 0 ? (
<Flexbox gap={10}>
{labels.map((label) => {
const opt = optionByLabel.get(label);
return (
<Flexbox gap={2} key={label}>
<Flexbox horizontal align="flex-start" gap={8}>
<Icon className={styles.check} icon={Check} size={14} />
<Text className={styles.answer}>{label}</Text>
</Flexbox>
{opt?.description && opt.description !== label && (
<span className={styles.header}>{opt.description}</span>
<span className={styles.description} style={{ paddingInlineStart: 22 }}>
{opt.description}
</span>
)}
</Flexbox>
</Flexbox>
);
})}
</Flexbox>
) : (
<Text type="secondary"></Text>
)}
);
})}
</Flexbox>
) : (
<Text type="secondary"></Text>
)}
</Flexbox>
</Flexbox>
);
});
@@ -106,10 +125,15 @@ QABlock.displayName = 'CCAskUserQuestionQABlock';
* `setInterventionAnswers` in `conversationControl` at submit time. If the
* key is missing (older messages, or skipped/cancelled flows where there's
* nothing to show), we fall back to the question list with a status hint.
*
* Single-layer surface: the framework's tool card already provides the
* containing card, so this Render stays flat (no own background) to avoid the
* card-in-card look.
*/
const AskUserQuestion = memo<
BuiltinRenderProps<AskUserQuestionArgs, AskUserQuestionState, unknown>
>(({ args, pluginError, pluginState }) => {
const { t } = useTranslation('plugin');
const questions = args?.questions ?? [];
const answers = pluginState?.askUserAnswers;
const freeform = answers?.['__freeform__'];
@@ -118,35 +142,43 @@ const AskUserQuestion = memo<
// Escape-mode reply: the user opted out of the multi-choice form and
// wrote freeform text instead. The form picks are intentionally absent,
// so render the questions for context (header + body only) plus the
// typed reply as one card — Q&A pairs would render as empty rows.
// so render the questions for context (label + body) plus the typed reply
// as a check-style line — Q&A pairs would render as empty rows.
if (freeformText) {
return (
<Flexbox className={styles.container} gap={12}>
<Flexbox className={styles.container} gap={16}>
{questions.map((q, idx) => (
<Flexbox gap={4} key={`${q.question}-${idx}`}>
{q.header && <span className={styles.header}>{q.header}</span>}
<Text className={styles.question}>{q.question}</Text>
<Flexbox gap={6} key={`${q.question}-${idx}`}>
<span className={styles.label}>
{t('builtins.lobe-claude-code.askUserQuestion.question')}
</span>
<div className={styles.question}>{q.question}</div>
</Flexbox>
))}
<Flexbox horizontal align="flex-start" className={cx(styles.answerRow)} gap={8}>
<Icon className={styles.check} icon={PenLine} size={14} />
<Text className={styles.answer}>{freeformText}</Text>
<div className={styles.divider} />
<Flexbox gap={8}>
<span className={styles.label}>
{t('builtins.lobe-claude-code.askUserQuestion.reply')}
</span>
<Flexbox horizontal align="flex-start" gap={8}>
<Icon className={styles.check} icon={PenLine} size={14} />
<Text className={styles.answer}>{freeformText}</Text>
</Flexbox>
</Flexbox>
{isError && (
<Text type="warning">(No answer received model continued without their input.)</Text>
<Text type="warning">{t('builtins.lobe-claude-code.askUserQuestion.noAnswer')}</Text>
)}
</Flexbox>
);
}
return (
<Flexbox className={styles.container} gap={12}>
<Flexbox className={styles.container} gap={20}>
{questions.map((q, idx) => (
<QABlock answer={answers?.[q.question]} key={`${q.question}-${idx}`} question={q} />
))}
{isError && (
<Text type="warning">(No answer received model continued without their input.)</Text>
<Text type="warning">{t('builtins.lobe-claude-code.askUserQuestion.noAnswer')}</Text>
)}
</Flexbox>
);
@@ -10,9 +10,9 @@ import { memo, useMemo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
path: css`
min-width: 0;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
}));
@@ -39,6 +39,7 @@ const stripLineNumbers = (text: string): string => {
const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
const filePath = args?.file_path || '';
const fileName = filePath ? path.basename(filePath) : '';
const dir = filePath ? path.dirname(filePath) : '';
const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : '';
const source = useMemo(() => stripLineNumbers(content || ''), [content]);
@@ -49,9 +50,9 @@ const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
header={
<>
<Text strong>{fileName || 'Read'}</Text>
{filePath && filePath !== fileName && (
{dir && dir !== '.' && (
<Text ellipsis className={styles.path}>
{filePath}
{dir}
</Text>
)}
</>
@@ -10,9 +10,7 @@ import type { SkillArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
`,
header: css`
padding-inline: 4px;
@@ -25,7 +23,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
padding-inline: 8px;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
background: ${cssVar.colorFillTertiary};
`,
}));
@@ -10,14 +10,12 @@ import { LocalFile, LocalFolder } from '@/features/LocalFile';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
`,
previewBox: css`
overflow: hidden;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
background: ${cssVar.colorFillTertiary};
`,
}));
@@ -59,15 +59,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
color: ${cssVar.colorTextSecondary};
`,
value: css`
padding-block: 10px;
padding-inline: 12px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorText};
background: ${cssVar.colorFillQuaternary};
`,
}));
@@ -127,6 +127,7 @@ export class GatewayHttpClient {
operationId: string;
prompt: string;
resumeSessionId?: string;
systemContext?: string;
timeout?: number;
topicId: string;
userId: string;
@@ -137,6 +137,13 @@ export interface AgentRunRequestMessage {
operationId: string;
prompt: string;
resumeSessionId?: string;
/**
* Static context injected before the user prompt (workspace conventions,
* conversation history on resume). The desktop sends it to `lh hetero exec`
* as the first text block of a content-block array. Optional omitted for
* older servers that don't build a device-specific context.
*/
systemContext?: string;
topicId: string;
type: 'agent_run_request';
}
@@ -96,6 +96,86 @@ describe('ClaudeCodeAdapter', () => {
});
});
it('classifies a 429 "not your usage limit" server throttle as overloaded, not rate_limit', () => {
const adapter = new ClaudeCodeAdapter();
const rawError =
'API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited';
adapter.adapt({ subtype: 'init', type: 'system' });
// CC still emits a generic rate_limit_event (rejected, no resetsAt) for
// this transient throttle — it must NOT tip the classifier toward the
// user-facing usage-limit guide.
adapter.adapt({
rate_limit_info: { isUsingOverage: false, status: 'rejected' },
type: 'rate_limit_event',
});
const events = adapter.adapt({
api_error_status: 429,
is_error: true,
result: rawError,
type: 'result',
});
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
expect(events[1].data).toMatchObject({
agentType: 'claude-code',
clearEchoedContent: true,
code: 'overloaded',
message: rawError,
stderr: rawError,
});
});
it('treats a 429 with no reset window in rate_limit_event as overloaded, not rate_limit', () => {
const adapter = new ClaudeCodeAdapter();
// Generic "Rate limited" wording + a rate_limit_event that carries no
// resetsAt / rateLimitType. The structured signal — not the 429 status
// or the "rate limit" substring — decides: no reset window → transient
// server throttle → overloaded.
const rawError = 'API Error: 429 · Rate limited';
adapter.adapt({ subtype: 'init', type: 'system' });
adapter.adapt({
rate_limit_info: { status: 'rejected' },
type: 'rate_limit_event',
});
const events = adapter.adapt({
api_error_status: 429,
is_error: true,
result: rawError,
type: 'result',
});
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
expect(events[1].data).toMatchObject({ code: 'overloaded', message: rawError });
});
it('classifies a user quota limit from rateLimitType alone (no resetsAt)', () => {
const adapter = new ClaudeCodeAdapter();
const rawError = 'API Error: 429 · Rate limited';
adapter.adapt({ subtype: 'init', type: 'system' });
// rateLimitType is itself a user-quota signal even without resetsAt.
adapter.adapt({
rate_limit_info: { rateLimitType: 'seven_day', status: 'rejected' },
type: 'rate_limit_event',
});
const events = adapter.adapt({
api_error_status: 429,
is_error: true,
result: rawError,
type: 'result',
});
expect(events[1].data).toMatchObject({
code: 'rate_limit',
rateLimitInfo: { rateLimitType: 'seven_day' },
});
});
it('classifies rate-limit failures from paired rate_limit_event + result events', () => {
const adapter = new ClaudeCodeAdapter();
const rawError = "You've hit your limit · resets 9am (Asia/Shanghai)";
@@ -183,14 +183,54 @@ const CLI_AUTH_REQUIRED_PATTERNS = [
/\b401\b/,
] as const;
const CLI_RATE_LIMIT_PATTERNS = [/you'?ve hit your limit/i, /rate limit/i] as const;
/**
* Genuinely user-side limit wording. Used only as the text fallback for
* batch CLI / sandbox runs that don't emit a structured `rate_limit_event`
* (so {@link isUserQuotaRateLimit} can't fire). The ambiguous bare
* `rate limit` / `rate limited` substring is deliberately NOT here it also
* appears in Anthropic's transient server throttle, so leaning on it would
* reintroduce the very misclassification this set exists to avoid.
*/
const CLI_USER_RATE_LIMIT_PATTERNS = [
/you'?ve hit your limit/i,
/usage limit reached/i,
/\blimit reached\b/i,
] as const;
/**
* Anthropic's server-side transient throttle. CC surfaces this as a 429 with
* a message that explicitly disclaims the user's plan limit ("not your usage
* limit") e.g. `API Error: Server is temporarily limiting requests (not your
* usage limit) · Rate limited`. It clears on its own in moments, so it must be
* classified as `overloaded` (retry UX), NOT `rate_limit` (which renders a
* misleading "usage limit reached" reset-time guide).
*/
const CLI_SERVER_THROTTLE_PATTERNS = [
/not your usage limit/i,
/server is temporarily limiting requests/i,
] as const;
const CLI_OVERLOADED_PATTERNS = [
/overloaded_error/i,
/\boverloaded\b/i,
/api error:\s*529\b/i,
...CLI_SERVER_THROTTLE_PATTERNS,
] as const;
/**
* The one reliable discriminator between a user-side plan/quota limit and a
* transient server throttle: only the genuine user limit carries a concrete
* reset window in the structured `rate_limit_event` `resetsAt` (epoch
* seconds) and/or a named `rateLimitType` (e.g. `seven_day`). Anthropic's
* transient throttle emits a rate_limit_event too, but with just
* `status: 'rejected'` and no reset info. Status codes (429 / 529) alone are
* ambiguous, so this structured signal not the HTTP status, not the message
* text is what decides whether we show the "usage limit reached, resets at
* X" guide vs the "temporarily overloaded, retry" guide.
*/
const isUserQuotaRateLimit = (info?: HeterogeneousRateLimitInfo): boolean =>
!!info && (info.resetsAt != null || info.rateLimitType != null);
const getCliResultMessage = (result: unknown): string | undefined => {
if (typeof result === 'string') return result;
if (
@@ -248,10 +288,18 @@ const toRateLimitInfo = (value: unknown): HeterogeneousRateLimitInfo | undefined
const getOverloadedTerminalError = (
result: unknown,
apiErrorStatus?: unknown,
rateLimitInfo?: HeterogeneousRateLimitInfo,
): HeterogeneousTerminalErrorData | undefined => {
const rawMessage = getCliResultMessage(result);
// A real user-quota limit is the rate-limit classifier's job — never steal
// it here, even if it happened to ride in on a 429/529.
if (isUserQuotaRateLimit(rateLimitInfo)) return;
const looksOverloaded =
// Both 529 (upstream overloaded) and a 429 with no quota signal (transient
// server throttle) are momentary server-side conditions — same retry UX.
apiErrorStatus === 529 ||
apiErrorStatus === 429 ||
(!!rawMessage && CLI_OVERLOADED_PATTERNS.some((pattern) => pattern.test(rawMessage)));
if (!looksOverloaded || !rawMessage) return;
@@ -269,15 +317,24 @@ const getOverloadedTerminalError = (
const getRateLimitTerminalError = (
result: unknown,
rateLimitInfo?: HeterogeneousRateLimitInfo,
apiErrorStatus?: unknown,
): HeterogeneousTerminalErrorData | undefined => {
const rawMessage = getCliResultMessage(result);
const looksLikeRateLimit =
apiErrorStatus === 429 ||
!!rateLimitInfo ||
(!!rawMessage && CLI_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage)));
if (!looksLikeRateLimit || !rawMessage) return;
// Primary signal: the structured rate_limit_event carries a concrete reset
// window → this is the user's plan/quota limit. Fallback (batch runs with no
// rate_limit_event): clearly user-side wording that doesn't disclaim the
// limit. Everything else — bare 429, "rate limited", server throttle — is
// left to the overloaded classifier so it gets the retry UX, not a
// misleading "usage limit reached, resets at X" guide.
const looksLikeServerThrottle =
!!rawMessage && CLI_SERVER_THROTTLE_PATTERNS.some((pattern) => pattern.test(rawMessage));
const looksLikeUserLimit =
isUserQuotaRateLimit(rateLimitInfo) ||
(!!rawMessage &&
!looksLikeServerThrottle &&
CLI_USER_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage)));
if (!looksLikeUserLimit || !rawMessage) return;
return {
agentType: 'claude-code',
@@ -1166,16 +1223,16 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
}
const resultMessage = getCliResultMessage(raw.result) || 'Agent execution failed';
const rateLimitError = getRateLimitTerminalError(
raw.result,
this.pendingRateLimitInfo,
raw.api_error_status,
);
const rateLimitError = getRateLimitTerminalError(raw.result, this.pendingRateLimitInfo);
const finalEvent: HeterogeneousAgentEvent = raw.is_error
? this.makeEvent(
'error',
rateLimitError ||
getOverloadedTerminalError(raw.result, raw.api_error_status) ||
getOverloadedTerminalError(
raw.result,
raw.api_error_status,
this.pendingRateLimitInfo,
) ||
getAuthRequiredTerminalError(raw.result) || {
error: resultMessage,
message: resultMessage,
@@ -7,9 +7,7 @@ import { memo, type ReactNode } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
`,
header: css`
padding-inline: 4px;
@@ -19,7 +17,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
overflow: hidden;
padding: 8px;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
background: ${cssVar.colorFillTertiary};
`,
}));
+1 -1
View File
@@ -302,7 +302,7 @@ export interface BuiltinInspectorProps<Arguments = any, State = any> {
isLoading?: boolean;
partialArgs?: Arguments;
pluginState?: State;
result?: { content: string | null; error?: any };
result?: { content: string | null; error?: any; state?: any };
}
export type BuiltinInspector = <A = any, S = any>(props: BuiltinInspectorProps<A, S>) => ReactNode;
@@ -0,0 +1,258 @@
'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 { 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
* `recentCwds` (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` + `recentCwds`) 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 recentCwds = device?.recentCwds ?? [];
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 (path: string) => {
const newPath = 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 + recentCwds stay in sync.
if (activeTopicId) await updateTopicMetadata(activeTopicId, { workingDirectory: newPath });
await updateDeviceCwd(boundDeviceId, newPath, recentCwds);
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,
recentCwds,
t,
updateDeviceCwd,
updateTopicMetadata,
],
);
const content = (
<Flexbox gap={4} style={{ minWidth: 280 }}>
<div className={styles.sectionTitle}>{t('localSystem.workingDirectory.recent')}</div>
<div className={styles.scrollContainer}>
{recentCwds.length === 0 ? (
<Flexbox
align={'center'}
justify={'center'}
style={{ color: cssVar.colorTextQuaternary, fontSize: 12, padding: '12px 8px' }}
>
{t('localSystem.workingDirectory.noRecent')}
</Flexbox>
) : (
recentCwds.map((path) => {
const isActive = path === effectiveDir;
return (
<Flexbox
horizontal
align={'center'}
className={cx(styles.dirItem, isActive && styles.dirItemActive)}
gap={8}
key={path}
onClick={() => void commitDir(path)}
>
<Icon
icon={FolderIcon}
size={16}
style={{ color: cssVar.colorTextTertiary, flex: 'none' }}
/>
<Flexbox flex={1} style={{ minWidth: 0 }}>
<div className={styles.dirName}>{getDirName(path)}</div>
<div className={styles.dirPath}>{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(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;
@@ -7,14 +7,17 @@ import { CheckIcon, FolderIcon, FolderOpenIcon, GitBranchIcon, XIcon } from 'luc
import { memo, type ReactNode, 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 { addRecentDir, getRecentDirs, type RecentDirEntry, removeRecentDir } from './recentDirs';
import { useRepoType } from './useRepoType';
import { useUpdateDeviceCwd } from './useUpdateDeviceCwd';
const styles = createStaticStyles(({ css }) => ({
chooseFolderItem: css`
@@ -154,6 +157,20 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
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 `recentCwds` — keeps the settings detail view + future
// device-mode picker in sync. recentCwds 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 deviceRecentCwds =
allDevices?.find((d) => d.deviceId === currentDeviceId)?.recentCwds ?? [];
const updateDeviceCwd = useUpdateDeviceCwd();
const [recentDirs, setRecentDirs] = useState(getRecentDirs);
const displayDirs = useMemo(() => {
@@ -178,6 +195,10 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: newPath });
}
setRecentDirs(addRecentDir(entry));
// Record on this machine's device registry (recentCwds only).
if (currentDeviceId) {
void updateDeviceCwd(currentDeviceId, newPath, deviceRecentCwds, { setDefault: false });
}
onClose?.();
};
@@ -205,8 +226,11 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
activeTopicId,
activeTopic,
agentId,
currentDeviceId,
deviceRecentCwds,
t,
updateAgentRuntimeEnvConfig,
updateDeviceCwd,
updateTopicMetadata,
onClose,
],
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { nextRecentCwds, RECENT_CWDS_MAX } from './deviceCwd';
describe('nextRecentCwds', () => {
it('prepends a new path as most-recent', () => {
expect(nextRecentCwds('/b', ['/a'])).toEqual(['/b', '/a']);
});
it('moves an existing path to the front without duplicating it', () => {
expect(nextRecentCwds('/a', ['/a', '/b', '/c'])).toEqual(['/a', '/b', '/c']);
expect(nextRecentCwds('/c', ['/a', '/b', '/c'])).toEqual(['/c', '/a', '/b']);
});
it('caps the list length', () => {
const current = Array.from({ length: RECENT_CWDS_MAX }, (_, i) => `/p${i}`);
const result = nextRecentCwds('/new', current);
expect(result).toHaveLength(RECENT_CWDS_MAX);
expect(result[0]).toBe('/new');
expect(result).not.toContain(`/p${RECENT_CWDS_MAX - 1}`); // oldest dropped
});
it('trims the input and ignores a blank path', () => {
expect(nextRecentCwds(' /a ', ['/b'])).toEqual(['/a', '/b']);
expect(nextRecentCwds(' ', ['/b'])).toEqual(['/b']);
expect(nextRecentCwds('', ['/b'])).toEqual(['/b']);
});
it('defaults to an empty current list', () => {
expect(nextRecentCwds('/a')).toEqual(['/a']);
});
});
@@ -0,0 +1,21 @@
/** Max number of recent working directories persisted per device. Matches the
* `recentCwds` cap enforced by the `device.updateDevice` tRPC input. */
export const RECENT_CWDS_MAX = 20;
/**
* Compute the next `recentCwds` list after the user picks `cwd`: move it to the
* front (most-recent-first), drop any earlier duplicate, and cap the length.
* Blank paths are ignored (returns the list unchanged).
*
* The server stores `recentCwds` verbatim there is no server-side dedupe or
* cap so the client owns this logic.
*/
export const nextRecentCwds = (
cwd: string,
current: readonly string[] = [],
max: number = RECENT_CWDS_MAX,
): string[] => {
const trimmed = cwd.trim();
if (!trimmed) return [...current];
return [trimmed, ...current.filter((p) => p !== trimmed)].slice(0, max);
};
@@ -0,0 +1,63 @@
import { useCallback } from 'react';
import { lambdaQuery } from '@/libs/trpc/client';
import { nextRecentCwds } from './deviceCwd';
/**
* Persist a working-directory choice to a device's registry record
* (`defaultCwd` + `recentCwds`) 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, recentCwds }) => {
// 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,
recentCwds: recentCwds ?? device.recentCwds,
}
: device,
) as typeof old;
});
},
onSettled: () => utils.device.listDevices.invalidate(),
});
return useCallback(
(
deviceId: string,
cwd: string,
currentRecentCwds: readonly string[] = [],
// Local-mode runs only want to record the dir in the recent list, not
// repoint the device's default working directory.
options: { setDefault?: boolean } = {},
) => {
const trimmed = cwd.trim();
if (!trimmed) return;
const setDefault = options.setDefault ?? true;
return mutation.mutateAsync({
...(setDefault ? { defaultCwd: trimmed } : {}),
deviceId,
recentCwds: nextRecentCwds(trimmed, currentRecentCwds),
});
},
[mutation],
);
};
@@ -0,0 +1,177 @@
'use client';
import { Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useEffect, useRef } from 'react';
import type { ApiEntry } from './useDevtoolsEntries';
const styles = createStaticStyles(({ css, cssVar }) => ({
column: css`
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 240px;
height: 100%;
border-inline-end: 1px solid ${cssVar.colorBorderSecondary};
background: ${cssVar.colorBgContainer};
`,
dot: css`
flex-shrink: 0;
width: 5px;
height: 5px;
border-radius: 999px;
background: ${cssVar.colorTextQuaternary};
`,
dotActive: css`
background: ${cssVar.colorPrimary};
`,
header: css`
flex-shrink: 0;
padding-block: 14px 10px;
padding-inline: 16px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
item: css`
cursor: pointer;
overflow: hidden;
flex-shrink: 0;
gap: 8px;
align-items: center;
height: 30px;
padding-inline: 10px;
border-radius: 6px;
color: ${cssVar.colorTextSecondary};
transition:
background 0.15s,
color 0.15s;
&:hover {
color: ${cssVar.colorText};
background: ${cssVar.colorFillQuaternary};
}
`,
itemActive: css`
color: ${cssVar.colorText};
background: ${cssVar.colorFillSecondary};
&:hover {
background: ${cssVar.colorFillSecondary};
}
`,
/** Namespace prefix of an mcp__ name — muted, and the part that elides. */
labelHead: css`
overflow: hidden;
flex: 0 1 auto;
color: ${cssVar.colorTextQuaternary};
text-overflow: ellipsis;
white-space: nowrap;
`,
labelRow: css`
overflow: hidden;
display: flex;
flex: 1;
align-items: baseline;
min-width: 0;
font-family: ${cssVar.fontFamilyCode};
font-size: 13px;
`,
/** Trailing action segment — always kept visible. */
labelTail: css`
flex-shrink: 0;
white-space: nowrap;
`,
list: css`
overflow: auto;
flex: 1;
gap: 2px;
min-height: 0;
padding-block: 8px;
padding-inline: 8px;
`,
}));
/**
* Split a name at its last `__` so the long `mcp__<server>__` namespace can
* elide from the middle (`mcp__claude_ai_Li…get_diff`) keeping both the
* `mcp` signal up front and the distinguishing action at the end, instead of
* truncating one or the other away. Non-namespaced names are all tail.
*/
const splitName = (name: string): { head: string; tail: string } => {
const cut = name.lastIndexOf('__');
if (cut === -1) return { head: '', tail: name };
return { head: name.slice(0, cut + 2), tail: name.slice(cut + 2) };
};
interface ApiListProps {
activeApiName?: string;
apis: ApiEntry[];
onSelect: (apiName: string) => void;
}
/**
* Middle column for the render gallery: a dense jump-list of the current
* toolset's APIs. Clicking scrolls the matching `ToolPreview` card into view
* and pins a URL hash (`#api-<name>`) so a specific render is deep-linkable;
* the active item is driven by the scrollspy in `ToolPage`. The leading dot
* lights up when the API ships a Render.
*/
const ApiList = memo<ApiListProps>(({ apis, activeApiName, onSelect }) => {
const listRef = useRef<HTMLDivElement>(null);
// Keep the highlighted item visible as the scrollspy walks down the right
// pane — otherwise the list stays pinned at the top and you lose your place.
useEffect(() => {
if (!activeApiName) return;
const el = listRef.current?.querySelector(`[data-api="${CSS.escape(activeApiName)}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [activeApiName]);
return (
<aside className={styles.column}>
<div className={styles.header}>
<Text fontSize={12} type={'secondary'} weight={600}>
APIs · {apis.length}
</Text>
</div>
<Flexbox className={styles.list} ref={listRef}>
{apis.map((api) => {
const active = api.apiName === activeApiName;
const { head, tail } = splitName(api.apiName);
return (
<Flexbox
horizontal
className={cx(styles.item, active && styles.itemActive)}
data-api={api.apiName}
key={api.apiName}
title={api.apiName}
onClick={() => onSelect(api.apiName)}
>
<span className={cx(styles.dot, api.render && styles.dotActive)} />
<span className={styles.labelRow}>
{head && <span className={styles.labelHead}>{head}</span>}
<span className={styles.labelTail}>{tail}</span>
</span>
</Flexbox>
);
})}
</Flexbox>
</aside>
);
});
ApiList.displayName = 'DevtoolsApiList';
export default ApiList;
@@ -0,0 +1,185 @@
'use client';
import { LOADING_FLAT } from '@lobechat/const';
import type { ChatToolPayload, UIChatMessage } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, useMemo } from 'react';
import {
type ConversationContext,
ConversationProvider,
MessageItem,
} from '@/features/Conversation';
import { MessageActionProvider } from '@/features/Conversation/Messages/Contexts/MessageActionProvider';
import { dataSelectors, useConversationStore } from '@/features/Conversation/store';
import { DEVTOOLS_AGENT_ID } from './fixtures';
import { deriveFixtureProps, type LifecycleMode } from './lifecycleMode';
import type { ApiEntry } from './useDevtoolsEntries';
const styles = createStaticStyles(({ css, cssVar }) => ({
empty: css`
padding-block: 48px;
color: ${cssVar.colorTextTertiary};
text-align: center;
`,
thread: css`
width: 100%;
max-width: 820px;
margin-inline: auto;
padding-block: 8px 48px;
padding-inline: 12px;
border-radius: 14px;
background: ${cssVar.colorBgContainer};
`,
}));
const coerceContent = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
/**
* Build the **flat** DB-shaped messages a real conversation produces, then let
* `parse()` (conversation-flow, via `ConversationProvider.replaceMessages`)
* synthesize the `assistantGroup` / tool grouping exactly as it does in chat
* instead of hand-rolling the grouped shape. Each render-bearing API becomes:
*
* assistant { content, tools: [tool_use] } tool { tool_call_id, result }
*
* The whole sequence is one parentId chain so it reads as a single conversation.
* Lifecycle state is carried on the tool result message the same way the real
* pipeline carries it:
* - success tool message `content` + `pluginState`
* - error tool message `pluginError`
* - intervention tool message `pluginIntervention.status = 'pending'`
* - streaming `LOADING_FLAT` content + unterminated `arguments` JSON on the tool_use
* - loading / placeholder `LOADING_FLAT` content
*
* Every API emits a tool message even for the unfinished states (content
* `LOADING_FLAT`) the tool_use tool_result link is what lets
* conversation-flow chain the turns into ONE assistantGroup; without it the
* unfinished modes fall back to one orphaned group per tool.
*/
const buildMessages = (apis: ApiEntry[], mode: LifecycleMode, now: number): UIChatMessage[] => {
const renderable = apis.filter(
(api) => api.render || api.streaming || api.placeholder || api.intervention,
);
const messages: UIChatMessage[] = [];
for (const api of renderable) {
const variant = api.fixture.variants[0];
const derived = deriveFixtureProps(variant, mode);
const key = `${api.identifier}-${api.apiName}`;
const assistantId = `devtools-asst-${key}`;
const toolCallId = `devtools-tool-${key}`;
const toolUse: ChatToolPayload = {
apiName: api.apiName,
// Streaming: drop the closing brace so args fail to parse → "still typing".
arguments:
mode === 'streaming'
? JSON.stringify(derived.partialArgs ?? {}).replace(/\}$/, '')
: JSON.stringify(derived.args),
id: toolCallId,
identifier: api.identifier,
source: api.apiName.startsWith('mcp__') ? 'mcp' : 'builtin',
type: 'builtin',
};
// Chain onto the previous turn's last message so the whole thread is one
// conversation; the first assistant has no parent (conversation root).
messages.push({
content: api.description || variant.description || '',
createdAt: now,
id: assistantId,
parentId: messages.at(-1)?.id,
role: 'assistant',
tools: [toolUse],
updatedAt: now,
});
// Always emit the paired tool result — it's the tool_use → tool_result link
// that lets conversation-flow chain every turn into ONE assistantGroup.
// Unfinished states use LOADING_FLAT so the tool still reads as in-flight.
messages.push({
content: mode === 'success' ? coerceContent(derived.content) : LOADING_FLAT,
createdAt: now,
id: `devtools-toolmsg-${key}`,
parentId: assistantId,
pluginError: mode === 'error' ? derived.pluginError : undefined,
pluginIntervention: mode === 'intervention' ? { status: 'pending' } : undefined,
pluginState: mode === 'success' ? derived.pluginState : undefined,
role: 'tool',
tool_call_id: toolCallId,
updatedAt: now,
});
}
return messages;
};
const InnerList = memo(() => {
const ids = useConversationStore(dataSelectors.displayMessageIds);
return (
<MessageActionProvider withSingletonActionsBar={false}>
<div className={styles.thread}>
{ids.map((id, index) => (
<MessageItem
disableEditing
defaultWorkflowExpandLevel={'full'}
id={id}
index={index}
key={id}
/>
))}
</div>
</MessageActionProvider>
);
});
InnerList.displayName = 'DevtoolsAggregateInnerList';
interface MessageListProps {
apis: ApiEntry[];
mode: LifecycleMode;
}
/**
* Aggregate preview tab: renders every render-bearing API as a tool call inside
* the **real** `Conversation` renderer. Flat fixture messages are seeded through
* `ConversationProvider` (`skipFetch`) so conversation-flow's `parse()` performs
* the real `assistantGroup` grouping the preview is byte-for-byte what ships
* in chat. Inspector-only tools (most MCP entries) are dropped to keep the
* thread about the renders.
*/
const MessageList = memo<MessageListProps>(({ apis, mode }) => {
// One stable timestamp per (apis, mode) render so message identity is steady.
const messages = useMemo(() => buildMessages(apis, mode, Date.now()), [apis, mode]);
const context = useMemo<ConversationContext>(
() => ({ agentId: DEVTOOLS_AGENT_ID, topicId: 'devtools-aggregate' }),
[],
);
if (messages.length === 0) {
return <Text className={styles.empty}>No renderable APIs in this toolset.</Text>;
}
return (
<ConversationProvider hasInitMessages skipFetch context={context} messages={messages}>
<InnerList />
</ConversationProvider>
);
});
MessageList.displayName = 'DevtoolsMessageList';
export default MessageList;
+165 -42
View File
@@ -2,24 +2,46 @@
import { Flexbox, Segmented, Tag, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import ApiList from './ApiList';
import { LIFECYCLE_MODE_LABEL, LIFECYCLE_MODES, type LifecycleMode } from './lifecycleMode';
import MessageList from './MessageList';
import ToolPreview from './ToolPreview';
import { useDevtoolsEntries } from './useDevtoolsEntries';
import { toApiAnchor, useDevtoolsEntries } from './useDevtoolsEntries';
const STORAGE_KEY = 'devtools-render-gallery:lifecycle-mode';
const MODE_STORAGE_KEY = 'devtools-render-gallery:lifecycle-mode';
const VIEW_STORAGE_KEY = 'devtools-render-gallery:view';
type GalleryView = 'api' | 'aggregate';
const isLifecycleMode = (value: string | null): value is LifecycleMode =>
!!value && (LIFECYCLE_MODES as string[]).includes(value);
const isGalleryView = (value: string | null): value is GalleryView =>
value === 'api' || value === 'aggregate';
const styles = createStaticStyles(({ css, cssVar }) => ({
body: css`
gap: 24px;
max-width: 1200px;
padding: 28px;
`,
content: css`
position: relative;
overflow: auto;
flex: 1;
/* keep a jumped-to card clear of the sticky lifecycle bar */
& [id^='api-'] {
scroll-margin-block-start: 80px;
}
`,
controlGroup: css`
gap: 8px;
align-items: center;
`,
empty: css`
flex: 1;
gap: 6px;
@@ -37,7 +59,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
z-index: 2;
inset-block-start: 0;
gap: 12px;
gap: 16px;
align-items: center;
padding-block: 10px;
@@ -56,19 +78,94 @@ const DevtoolsToolPage = () => {
const toolset = identifier ? toolsetMap.get(identifier) : undefined;
const [mode, setMode] = useState<LifecycleMode>('success');
const [view, setView] = useState<GalleryView>('api');
const [activeApi, setActiveApi] = useState<string>();
const scrollRef = useRef<HTMLDivElement>(null);
// Hydrate from localStorage so the choice survives navigation between toolsets.
// Hydrate from localStorage so the choices survive navigation between toolsets.
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem(STORAGE_KEY);
if (isLifecycleMode(stored)) setMode(stored);
const storedMode = window.localStorage.getItem(MODE_STORAGE_KEY);
if (isLifecycleMode(storedMode)) setMode(storedMode);
const storedView = window.localStorage.getItem(VIEW_STORAGE_KEY);
if (isGalleryView(storedView)) setView(storedView);
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, mode);
window.localStorage.setItem(MODE_STORAGE_KEY, mode);
}, [mode]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(VIEW_STORAGE_KEY, view);
}, [view]);
// Scrollspy (per-API view only): highlight the API-list item for the card the
// reader is on — the last card whose top has crossed a trigger line just under
// the sticky bar. A plain scroll listener (rAF-throttled) is used instead of
// an IntersectionObserver so the boundary cases stay exact: at the very bottom
// the last card can't reach the trigger line, and at the very top the first
// card sits above it, so both ends are pinned explicitly.
useEffect(() => {
const root = scrollRef.current;
if (!root || !toolset || view !== 'api') return;
const apiNames = toolset.apis.map((api) => api.apiName);
// Honor a deep-link hash (#api-<name>) on load; otherwise start at the top.
const hash = window.location.hash.replace(/^#/, '');
const linked = apiNames.find((name) => toApiAnchor(name) === hash);
if (linked) {
setActiveApi(linked);
const card = root.querySelector(`#${CSS.escape(toApiAnchor(linked))}`);
requestAnimationFrame(() => card?.scrollIntoView({ block: 'start' }));
} else {
setActiveApi(apiNames[0]);
root.scrollTo({ top: 0 });
}
const TRIGGER = 96; // px below the scroll-area top — clears the sticky bar
let frame = 0;
const compute = () => {
frame = 0;
if (root.scrollTop <= 0) return setActiveApi(apiNames[0]);
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 2)
return setActiveApi(apiNames.at(-1));
const rootTop = root.getBoundingClientRect().top;
let current = apiNames[0];
for (const name of apiNames) {
const el = document.getElementById(toApiAnchor(name));
if (!el) continue;
if (el.getBoundingClientRect().top - rootTop <= TRIGGER) current = name;
else break;
}
setActiveApi(current);
};
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(compute);
};
root.addEventListener('scroll', onScroll, { passive: true });
return () => {
root.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [toolset, view]);
const handleSelect = (apiName: string) => {
setActiveApi(apiName);
const root = scrollRef.current;
const card = root?.querySelector(`#${CSS.escape(toApiAnchor(apiName))}`);
card?.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Pin a shareable anchor without spamming browser history.
window.history.replaceState(null, '', `#${toApiAnchor(apiName)}`);
};
if (!toolset) {
return (
<Flexbox className={styles.empty}>
@@ -83,42 +180,68 @@ const DevtoolsToolPage = () => {
}
return (
<Flexbox className={styles.body}>
<Flexbox className={styles.header}>
<Flexbox horizontal align={'center'} gap={10} wrap={'wrap'}>
<Text fontSize={22} weight={700}>
{toolset.toolsetName}
</Text>
<Tag>{toolset.identifier}</Tag>
<Text fontSize={12} type={'secondary'}>
{toolset.apis.length} API{toolset.apis.length === 1 ? '' : 's'}
</Text>
<Flexbox horizontal height={'100%'} style={{ overflow: 'hidden' }} width={'100%'}>
{view === 'api' && (
<ApiList activeApiName={activeApi} apis={toolset.apis} onSelect={handleSelect} />
)}
<div className={styles.content} ref={scrollRef}>
<Flexbox className={styles.body}>
<Flexbox className={styles.header}>
<Flexbox horizontal align={'center'} gap={10} wrap={'wrap'}>
<Text fontSize={22} weight={700}>
{toolset.toolsetName}
</Text>
<Tag>{toolset.identifier}</Tag>
<Text fontSize={12} type={'secondary'}>
{toolset.apis.length} API{toolset.apis.length === 1 ? '' : 's'}
</Text>
</Flexbox>
{toolset.toolsetDescription && (
<Text fontSize={13} type={'secondary'}>
{toolset.toolsetDescription}
</Text>
)}
</Flexbox>
<Flexbox horizontal className={styles.modeBar} wrap={'wrap'}>
<Flexbox horizontal className={styles.controlGroup}>
<Text fontSize={12} type={'secondary'} weight={600}>
View
</Text>
<Segmented
size={'small'}
value={view}
options={[
{ label: 'By API', value: 'api' },
{ label: 'Aggregate', value: 'aggregate' },
]}
onChange={(value) => setView(value as GalleryView)}
/>
</Flexbox>
<Flexbox horizontal className={styles.controlGroup}>
<Text fontSize={12} type={'secondary'} weight={600}>
Lifecycle
</Text>
<Segmented
size={'small'}
value={mode}
options={LIFECYCLE_MODES.map((value) => ({
label: LIFECYCLE_MODE_LABEL[value],
value,
}))}
onChange={(value) => setMode(value as LifecycleMode)}
/>
</Flexbox>
</Flexbox>
{view === 'api' &&
toolset.apis.map((api) => (
<ToolPreview api={api} key={`${api.identifier}:${api.apiName}`} mode={mode} />
))}
</Flexbox>
{toolset.toolsetDescription && (
<Text fontSize={13} type={'secondary'}>
{toolset.toolsetDescription}
</Text>
)}
</Flexbox>
<Flexbox horizontal className={styles.modeBar}>
<Text fontSize={12} type={'secondary'} weight={600}>
Lifecycle
</Text>
<Segmented
size={'small'}
value={mode}
options={LIFECYCLE_MODES.map((value) => ({
label: LIFECYCLE_MODE_LABEL[value],
value,
}))}
onChange={(value) => setMode(value as LifecycleMode)}
/>
</Flexbox>
{toolset.apis.map((api) => (
<ToolPreview api={api} key={`${api.identifier}:${api.apiName}`} mode={mode} />
))}
{view === 'aggregate' && <MessageList apis={toolset.apis} mode={mode} />}
</div>
</Flexbox>
);
};
@@ -1,16 +1,16 @@
'use client';
import { Block, Flexbox, Segmented, Tag, Text } from '@lobehub/ui';
import { Flexbox, Segmented, Tag, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Component, type ReactNode, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import {
bodyKindForMode,
deriveFixtureProps,
type FixtureBodyKind,
type LifecycleMode,
type ToolRenderFixtureVariant,
} from './lifecycleMode';
import { ToolBodySlot, ToolInspectorSlot } from './toolSurfaces';
import type { ApiEntry } from './useDevtoolsEntries';
import { toApiAnchor } from './useDevtoolsEntries';
@@ -24,7 +24,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
border-radius: 20px;
background: ${cssVar.colorBgContainer};
box-shadow: ${cssVar.boxShadowSecondary};
`,
cardBody: css`
padding: 20px;
@@ -62,15 +61,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
missingShell: css`
padding-block: 12px;
padding-inline: 16px;
border: 1px dashed ${cssVar.colorBorderSecondary};
border-radius: 12px;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
previewShell: css`
padding: 16px;
border-radius: 16px;
@@ -82,51 +72,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
}));
class RenderBoundary extends Component<
{ children: ReactNode; label: string },
{ error?: Error | undefined }
> {
constructor(props: { children: ReactNode; label: string }) {
super(props);
this.state = { error: undefined };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
override render() {
if (!this.state.error) return this.props.children;
return (
<Block padding={16} variant={'outlined'}>
<Flexbox gap={8}>
<Text fontSize={14} type={'danger'} weight={500}>
{this.props.label} crashed
</Text>
<Text fontSize={12} type={'secondary'}>
{this.state.error.message}
</Text>
</Flexbox>
</Block>
);
}
}
const coerceInspectorContent = (value: unknown): string | null => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const Missing = ({ kind }: { kind: string }) => (
<div className={styles.missingShell}>No {kind} component registered for this API.</div>
);
interface ToolPreviewProps {
api: ApiEntry;
mode: LifecycleMode;
@@ -136,12 +81,6 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
const messageId = `devtools-${api.identifier}-${api.apiName}`;
const toolCallId = `${messageId}-tool`;
const Inspector = api.inspector;
const Render = api.render;
const Streaming = api.streaming;
const Placeholder = api.placeholder;
const Intervention = api.intervention;
const variants = api.fixture.variants;
const [activeVariantId, setActiveVariantId] = useState<string>(variants[0]?.id ?? 'default');
const activeVariant: ToolRenderFixtureVariant =
@@ -149,94 +88,6 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
const derived = useMemo(() => deriveFixtureProps(activeVariant, mode), [activeVariant, mode]);
const targetBodyKind: FixtureBodyKind = bodyKindForMode(mode);
const inspectorResult = {
content: coerceInspectorContent(activeVariant.content),
error: derived.pluginError,
state: derived.pluginState,
};
const bodyContent = (() => {
switch (targetBodyKind) {
case 'streaming': {
if (Streaming) {
return (
<RenderBoundary label={'Streaming'}>
<Streaming
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
messageId={messageId}
toolCallId={toolCallId}
/>
</RenderBoundary>
);
}
// No dedicated Streaming slot — fall back to Render shown in streaming state.
if (Render) {
return (
<RenderBoundary label={'Render'}>
<Render
apiName={api.apiName}
args={derived.args}
content={derived.content}
identifier={api.identifier}
messageId={messageId}
pluginError={derived.pluginError}
pluginState={derived.pluginState}
toolCallId={toolCallId}
/>
</RenderBoundary>
);
}
return <Missing kind={'streaming'} />;
}
case 'placeholder': {
return Placeholder ? (
<RenderBoundary label={'Placeholder'}>
<Placeholder apiName={api.apiName} args={derived.args} identifier={api.identifier} />
</RenderBoundary>
) : (
<Missing kind={'placeholder'} />
);
}
case 'intervention': {
return Intervention ? (
<RenderBoundary label={'Intervention'}>
<Intervention
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
interactionMode={'approval'}
messageId={messageId}
/>
</RenderBoundary>
) : (
<Missing kind={'intervention'} />
);
}
default: {
return Render ? (
<RenderBoundary label={'Render'}>
<Render
apiName={api.apiName}
args={derived.args}
content={derived.content}
identifier={api.identifier}
messageId={messageId}
pluginError={derived.pluginError}
pluginState={derived.pluginState}
toolCallId={toolCallId}
/>
</RenderBoundary>
) : (
<Missing kind={'render'} />
);
}
}
})();
return (
<Flexbox className={styles.card} id={toApiAnchor(api.apiName)}>
<Flexbox className={styles.cardHeader}>
@@ -272,22 +123,7 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
</Text>
</Flexbox>
<div className={styles.previewShell}>
{Inspector ? (
<RenderBoundary label={'Inspector'}>
<Inspector
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
isArgumentsStreaming={derived.isArgumentsStreaming}
isLoading={derived.isLoading}
partialArgs={derived.partialArgs}
pluginState={derived.pluginState}
result={inspectorResult}
/>
</RenderBoundary>
) : (
<Missing kind={'inspector'} />
)}
<ToolInspectorSlot api={api} derived={derived} variant={activeVariant} />
</div>
</Flexbox>
@@ -296,9 +132,17 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
<Text fontSize={12} type={'secondary'} weight={600}>
Body
</Text>
<Tag>{targetBodyKind}</Tag>
<Tag>{bodyKindForMode(mode)}</Tag>
</Flexbox>
<div className={styles.previewShell}>{bodyContent}</div>
<div className={styles.previewShell}>
<ToolBodySlot
api={api}
derived={derived}
messageId={messageId}
mode={mode}
toolCallId={toolCallId}
/>
</div>
</Flexbox>
<details>
@@ -75,6 +75,14 @@ export default defineFixtures({
description: 'Look up deferred tools by name or keyword.',
name: 'ToolSearch',
},
{
description: 'Fetch a URL and answer a prompt about it.',
name: 'WebFetch',
},
{
description: 'Search the web.',
name: 'WebSearch',
},
{
description: 'Write a new file.',
name: 'Write',
@@ -366,6 +374,22 @@ export default defineFixtures({
args: { max_results: 5, query: 'select:Read,Edit,Grep' },
content: 'Loaded 3 deferred tool schemas: Read, Edit, Grep.',
}),
WebFetch: single({
args: {
prompt: 'Summarize the key changes in the latest release.',
url: 'https://github.com/lobehub/lobe-chat/releases/latest',
},
content:
'## LobeChat v1.0\n\n- New agent runtime with tool streaming\n- Faster cold start\n- Fixed a memory leak in the chat store',
}),
WebSearch: single({
args: {
allowed_domains: ['developer.mozilla.org'],
query: 'CSS container queries browser support',
},
content:
'1. Container queries — MDN — developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment\n2. Can I use: CSS Container Queries — caniuse.com/css-container-queries',
}),
Write: single({
args: {
content: "export const previewEnabled = process.env.NODE_ENV === 'development';\n",
@@ -1,6 +1,7 @@
'use client';
import { builtinTools } from '@lobechat/builtin-tools';
import { DEFAULT_INBOX_AVATAR } from '@lobechat/const';
import type { BuiltinToolManifest, LobeChatPluginApi } from '@lobechat/types';
import type { ToolRenderFixture } from '../lifecycleMode';
@@ -40,6 +41,19 @@ export interface ToolRenderMeta {
export const DEVTOOLS_GROUP_ID = 'devtools-preview-group';
/**
* Identity for the seeded Aggregate-preview conversation. The fixture messages
* resolve their avatar/name through this agentId, so seeding `agentMap` with
* this meta makes the preview turn read as "Lobe AI" instead of the
* unresolved-agent fallback ("Unnamed Assistant").
*/
export const DEVTOOLS_AGENT_ID = 'devtools-render-gallery';
export const DEVTOOLS_AGENT_META = {
avatar: DEFAULT_INBOX_AVATAR,
title: 'Lobe AI',
};
export const DEVTOOLS_GROUP_DETAIL = {
agents: [
{
@@ -7,6 +7,7 @@ export default defineFixtures({
fixtures: {
callAgent: single({
args: {
agentId: 'agent_workspace_helper',
instruction:
'Review the `/devtools` route and list any preview cards that still need richer fixtures.',
},
@@ -22,6 +23,10 @@ export default defineFixtures({
},
}),
duplicateAgent: single({
args: {
agentId: 'agent_workspace_helper',
newTitle: 'Workspace Helper Copy',
},
pluginState: {
newAgentId: 'agent_preview_clone',
sourceAgentId: 'agent_workspace_helper',
@@ -29,6 +34,9 @@ export default defineFixtures({
},
}),
getAgentDetail: single({
args: {
agentId: 'agent_preview_specialist',
},
pluginState: {
config: {
model: 'gpt-5.4',
@@ -46,6 +54,11 @@ export default defineFixtures({
},
}),
installPlugin: single({
args: {
agentId: 'agent_preview_specialist',
identifier: 'lobe-cloud-sandbox',
source: 'official',
},
pluginState: {
installed: true,
pluginId: 'lobe-cloud-sandbox',
@@ -53,6 +66,10 @@ export default defineFixtures({
},
}),
searchAgent: single({
args: {
keyword: 'preview',
source: 'all',
},
pluginState: {
agents: [
{
@@ -76,6 +93,7 @@ export default defineFixtures({
}),
updateAgent: single({
args: {
agentId: 'agent_preview_specialist',
config: JSON.stringify({
model: 'gpt-5.4',
systemRole: 'Prioritize maintainable developer tooling and preview coverage.',
@@ -88,6 +106,7 @@ export default defineFixtures({
}),
updatePrompt: single({
args: {
agentId: 'agent_preview_specialist',
prompt:
'When asked for a visual check, prefer building a reusable preview harness before taking a screenshot.',
},
@@ -5,23 +5,25 @@ import { defineFixtures, single, variants } from './_helpers';
export default defineFixtures({
identifier: 'lobe-local-system',
fixtures: {
editLocalFile: single({
editFile: single({
args: { path: '/workspace/src/spa/router/desktopRouter.config.tsx' },
pluginState: {
diffText:
"--- a/workspace/src/spa/router/desktopRouter.config.tsx\n+++ b/workspace/src/spa/router/desktopRouter.config.tsx\n@@ -1,3 +1,7 @@\n export const desktopRoutes = [\n+ {\n+ path: 'devtools',\n+ },\n ];\n",
},
}),
listLocalFiles: single({
listFiles: single({
args: { path: '/workspace' },
pluginState: {
files: [
{ isDirectory: true, name: 'src' },
{ isDirectory: false, name: 'package.json', size: 1320 },
{ isDirectory: false, name: 'README.md', size: 4096 },
{ isDirectory: true, name: 'src', path: '/workspace/src' },
{ isDirectory: false, name: 'package.json', path: '/workspace/package.json', size: 1320 },
{ isDirectory: false, name: 'README.md', path: '/workspace/README.md', size: 4096 },
],
totalCount: 3,
},
}),
moveLocalFiles: single({
moveFiles: single({
args: {
items: [
{
@@ -31,7 +33,7 @@ export default defineFixtures({
],
},
}),
readLocalFile: single({
readFile: single({
args: { path: '/workspace/src/routes/(main)/devtools/index.tsx' },
pluginState: {
content:
@@ -54,7 +56,7 @@ export default defineFixtures({
success: true,
},
}),
searchLocalFiles: variants([
searchFiles: variants([
{
args: { keywords: 'quarterly report sample' },
label: 'Multiple matches',
@@ -97,7 +99,7 @@ export default defineFixtures({
},
},
]),
writeLocalFile: single({
writeFile: single({
args: {
content: 'export const devtoolsEnabled = process.env.NODE_ENV === "development";\n',
path: '/workspace/src/routes/(main)/devtools/flags.ts',
+24 -3
View File
@@ -5,16 +5,26 @@ import { createStaticStyles } from 'antd-style';
import { useEffect } from 'react';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
import { DEVTOOLS_GROUP_DETAIL, DEVTOOLS_GROUP_ID } from './fixtures';
import {
DEVTOOLS_AGENT_ID,
DEVTOOLS_AGENT_META,
DEVTOOLS_GROUP_DETAIL,
DEVTOOLS_GROUP_ID,
} from './fixtures';
import Sidebar from './Sidebar';
import { toToolsetPath, useDevtoolsEntries } from './useDevtoolsEntries';
const styles = createStaticStyles(({ css, cssVar }) => ({
main: css`
overflow: auto;
overflow: hidden;
flex: 1;
min-width: 0;
min-height: 0;
background:
radial-gradient(circle at top, ${cssVar.colorFillTertiary} 0%, transparent 35%),
${cssVar.colorBgLayout};
@@ -22,7 +32,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
page: css`
overflow: hidden;
width: 100%;
height: 100%;
/* Bind to the viewport directly so the columns scroll internally regardless
of whether the mounting route provides a bounded height. */
height: 100dvh;
`,
}));
@@ -42,11 +55,19 @@ const DevtoolsLayout = () => {
},
});
// Seed the Aggregate-preview agent meta so its turns read as "Lobe AI"
// (avatar + name) instead of the unresolved-agent fallback.
const previousAgentMap = useAgentStore.getState().agentMap;
useAgentStore.setState({
agentMap: { ...previousAgentMap, [DEVTOOLS_AGENT_ID]: DEVTOOLS_AGENT_META as any },
});
return () => {
useAgentGroupStore.setState({
activeGroupId: previousGroupState.activeGroupId,
groupMap: previousGroupState.groupMap,
});
useAgentStore.setState({ agentMap: previousAgentMap });
};
}, []);
@@ -0,0 +1,197 @@
'use client';
import { Block, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Component, memo, type ReactNode } from 'react';
import {
bodyKindForMode,
type DerivedFixtureProps,
type LifecycleMode,
type ToolRenderFixtureVariant,
} from './lifecycleMode';
import type { ApiEntry } from './useDevtoolsEntries';
const styles = createStaticStyles(({ css, cssVar }) => ({
missingShell: css`
padding-block: 12px;
padding-inline: 16px;
border: 1px dashed ${cssVar.colorBorderSecondary};
border-radius: 12px;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
}));
/** Catches a render/inspector crash so one bad fixture can't blank the page. */
export class RenderBoundary extends Component<
{ children: ReactNode; label: string },
{ error?: Error | undefined }
> {
constructor(props: { children: ReactNode; label: string }) {
super(props);
this.state = { error: undefined };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
override render() {
if (!this.state.error) return this.props.children;
return (
<Block padding={16} variant={'outlined'}>
<Flexbox gap={8}>
<Text fontSize={14} type={'danger'} weight={500}>
{this.props.label} crashed
</Text>
<Text fontSize={12} type={'secondary'}>
{this.state.error.message}
</Text>
</Flexbox>
</Block>
);
}
}
const Missing = ({ kind }: { kind: string }) => (
<div className={styles.missingShell}>No {kind} component registered for this API.</div>
);
const coerceInspectorContent = (value: unknown): string | null => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
interface ToolInspectorSlotProps {
api: ApiEntry;
derived: DerivedFixtureProps;
variant: ToolRenderFixtureVariant;
}
/** Renders the API's Inspector with the lifecycle-derived props, or a Missing hint. */
export const ToolInspectorSlot = memo<ToolInspectorSlotProps>(({ api, derived, variant }) => {
const Inspector = api.inspector;
if (!Inspector) return <Missing kind={'inspector'} />;
return (
<RenderBoundary label={'Inspector'}>
<Inspector
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
isArgumentsStreaming={derived.isArgumentsStreaming}
isLoading={derived.isLoading}
partialArgs={derived.partialArgs}
pluginState={derived.pluginState}
result={{
content: coerceInspectorContent(variant.content),
error: derived.pluginError,
state: derived.pluginState,
}}
/>
</RenderBoundary>
);
});
ToolInspectorSlot.displayName = 'ToolInspectorSlot';
interface ToolBodySlotProps {
api: ApiEntry;
derived: DerivedFixtureProps;
/** Aggregate flow renders nothing for an absent slot instead of a Missing hint. */
hideMissing?: boolean;
messageId: string;
mode: LifecycleMode;
toolCallId: string;
}
/**
* Renders the API's body surface for the active lifecycle mode the dedicated
* Streaming / Placeholder / Intervention component when the mode targets one,
* otherwise the Render. Streaming falls back to the Render shown mid-stream when
* no Streaming slot exists.
*/
export const ToolBodySlot = memo<ToolBodySlotProps>(
({ api, derived, mode, messageId, toolCallId, hideMissing }) => {
const missing = (kind: string) => (hideMissing ? null : <Missing kind={kind} />);
const renderSlot = () =>
api.render ? (
<RenderBoundary label={'Render'}>
<api.render
apiName={api.apiName}
args={derived.args}
content={derived.content}
identifier={api.identifier}
messageId={messageId}
pluginError={derived.pluginError}
pluginState={derived.pluginState}
toolCallId={toolCallId}
/>
</RenderBoundary>
) : (
missing('render')
);
switch (bodyKindForMode(mode)) {
case 'streaming': {
if (api.streaming) {
return (
<RenderBoundary label={'Streaming'}>
<api.streaming
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
messageId={messageId}
toolCallId={toolCallId}
/>
</RenderBoundary>
);
}
// No dedicated Streaming slot — fall back to the Render shown mid-stream.
return api.render ? renderSlot() : missing('streaming');
}
case 'placeholder': {
return api.placeholder ? (
<RenderBoundary label={'Placeholder'}>
<api.placeholder
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
/>
</RenderBoundary>
) : (
missing('placeholder')
);
}
case 'intervention': {
return api.intervention ? (
<RenderBoundary label={'Intervention'}>
<api.intervention
apiName={api.apiName}
args={derived.args}
identifier={api.identifier}
interactionMode={'approval'}
messageId={messageId}
/>
</RenderBoundary>
) : (
missing('intervention')
);
}
default: {
return renderSlot();
}
}
},
);
ToolBodySlot.displayName = 'ToolBodySlot';
@@ -42,6 +42,18 @@ export interface DevtoolsEntries {
toolsetMap: Map<string, ToolsetEntry>;
}
/** Toolsets that still ship renders but are deprecated — hidden from the gallery. */
const DEPRECATED_TOOLSETS = new Set(['lobe-notebook']);
/**
* Legacy `*Local*` aliases (e.g. `grepLocalFiles`, `listLocalFiles`) only stay
* registered so historical DB messages keep rendering after the rename they
* have no manifest/fixture, so they show up as empty cards. Current local-system
* API names carry no `Local` marker, so hiding by that marker is safe.
*/
const isDeprecatedApi = (identifier: string, apiName: string) =>
identifier === 'lobe-local-system' && apiName.includes('Local');
export const toToolsetPath = (identifier: string) => `/devtools/${encodeURIComponent(identifier)}`;
export const toApiAnchor = (apiName: string) => `api-${apiName}`;
@@ -106,6 +118,9 @@ export const useDevtoolsEntries = (): DevtoolsEntries =>
render,
streaming,
} of byKey.values()) {
if (DEPRECATED_TOOLSETS.has(identifier)) continue;
if (isDeprecatedApi(identifier, apiName)) continue;
const meta = getToolRenderMeta(identifier, apiName);
const fixture = getToolRenderFixture(identifier, apiName, meta.api);
+5
View File
@@ -63,6 +63,11 @@ export default {
'builtins.lobe-agent.title': 'Lobe Agent',
'builtins.lobe-claude-code.agent.instruction': 'Instruction',
'builtins.lobe-claude-code.agent.result': 'Result',
'builtins.lobe-claude-code.askUserQuestion.noAnswer':
'No answer received — model continued without their input.',
'builtins.lobe-claude-code.askUserQuestion.question': 'Question',
'builtins.lobe-claude-code.askUserQuestion.reply': 'Reply',
'builtins.lobe-claude-code.askUserQuestion.selected': 'Selected',
'builtins.lobe-claude-code.task.create.completed': 'Task created: ',
'builtins.lobe-claude-code.task.create.loading': 'Creating task: ',
'builtins.lobe-claude-code.task.getLabel': 'View task #{{taskId}}',
+5
View File
@@ -328,6 +328,11 @@ export default {
'devices.actions.edit': 'Edit',
'devices.actions.remove': 'Remove',
'devices.channel.connected': 'Connected {{time}}',
'devices.currentBadge': 'This device',
'devices.detail.connections': 'Connections',
'devices.detail.noRecent': 'No recent directories',
'devices.detail.recentDirs': 'Recent directories',
'devices.edit.browse': 'Browse…',
'devices.edit.cancel': 'Cancel',
'devices.edit.defaultCwd': 'Default working directory',
'devices.edit.defaultCwdPlaceholder': 'e.g. /Users/me/projects',
@@ -16,6 +16,7 @@ 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';
@@ -84,6 +85,12 @@ const WorkingDirectoryBar = memo(() => {
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);
@@ -101,7 +108,11 @@ const WorkingDirectoryBar = memo(() => {
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
<Flexbox horizontal align={'center'} gap={4}>
{enableExecutionDeviceSwitcher && <HeteroDeviceSwitcher agentId={agentId} />}
<CloudRepoSwitcher agentId={agentId} />
{isDeviceMode ? (
<DeviceWorkingDirectory agentId={agentId} />
) : (
<CloudRepoSwitcher agentId={agentId} />
)}
</Flexbox>
</Flexbox>
);
@@ -139,28 +150,37 @@ const WorkingDirectoryBar = memo(() => {
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
<Flexbox horizontal align={'center'} gap={4}>
{enableExecutionDeviceSwitcher && <HeteroDeviceSwitcher 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>
{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} />
)}
</div>
</Popover>
{effectiveWorkingDirectory && repoType && (
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
</>
)}
</Flexbox>
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
@@ -44,20 +44,27 @@ const HeterogeneousChatInput = memo(() => {
const navigate = useNavigate();
const providerType = useAgentStore(agentSelectors.currentAgentHeterogeneousProviderType);
const executionTarget = useAgentStore(agentSelectors.currentAgentExecutionTarget);
const isRemoteAgent = !!providerType && isRemoteHeterogeneousType(providerType);
const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isRemoteAgent });
// A run goes to an `lh connect` device when the provider is a remote-only type
// (openclaw / hermes) OR a local-CLI type (claude-code / codex) explicitly
// targeted at a device. Either way the bound device must be online before we
// let the user send — guard it here instead of failing at dispatch time.
const isDeviceExecution = isRemoteAgent || executionTarget === 'device';
const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isDeviceExecution });
const goToAgentProfile = () => {
if (params.aid) navigate(urlJoin('/agent', params.aid, 'profile'));
};
const deviceBlocked =
isRemoteAgent &&
isDeviceExecution &&
(status === 'device-offline' || status === 'platform-unavailable' || status === 'no-device');
const renderDeviceGuard = () => {
if (!isRemoteAgent || !deviceBlocked) return null;
if (!deviceBlocked) return null;
let title: string;
let desc: string;
@@ -69,7 +76,9 @@ const HeterogeneousChatInput = memo(() => {
title = t('platformAgent.deviceGuard.deviceOffline.title');
desc = t('platformAgent.deviceGuard.deviceOffline.desc');
} else {
const name = HETEROGENEOUS_TYPE_LABELS[providerType] ?? providerType;
// `platform-unavailable` only arises for remote-typed agents (the guard's
// capability check), so providerType is always set here — fall back safely.
const name = (providerType && HETEROGENEOUS_TYPE_LABELS[providerType]) || providerType || '';
title = t('platformAgent.deviceGuard.platformUnavailable.title', { name });
desc = t('platformAgent.deviceGuard.platformUnavailable.desc', { name });
}
@@ -97,11 +106,13 @@ const HeterogeneousChatInput = memo(() => {
);
};
const inputDisabled = (!isConfigured && !isRemoteAgent) || deviceBlocked;
// Device execution doesn't use the cloud sandbox, so it doesn't need cloud
// credentials — only the sandbox path gates on `isConfigured`.
const inputDisabled = (!isConfigured && !isDeviceExecution) || deviceBlocked;
return (
<Flexbox>
{!isRemoteAgent && !isConfigured && (
{!isDeviceExecution && !isConfigured && (
<WideScreenContainer>
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert
@@ -0,0 +1,227 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { ActionIcon, Button, Flexbox, Icon, Input, Tag, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import dayjs from 'dayjs';
import { FolderOpenIcon, XIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { nextRecentCwds } from '@/features/ChatInput/RuntimeConfig/deviceCwd';
import { lambdaQuery } from '@/libs/trpc/client';
import { electronSystemService } from '@/services/electron/system';
import type { DeviceListItem } from './DeviceItem';
import { getDeviceIcon } from './getDeviceIcon';
const styles = createStaticStyles(({ css }) => ({
container: css`
padding-block: 16px;
padding-inline: 20px;
`,
dot: css`
width: 6px;
height: 6px;
border-radius: 50%;
`,
header: css`
padding-block-end: 16px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
label: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
path: css`
overflow: hidden;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
`,
recentRow: css`
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
removeBtn: css`
cursor: pointer;
flex: none;
color: ${cssVar.colorTextQuaternary};
&:hover {
color: ${cssVar.colorText};
}
`,
}));
interface DeviceDetailPanelProps {
device: DeviceListItem;
isCurrent?: boolean;
onClose: () => void;
}
const DeviceDetailPanel = memo<DeviceDetailPanelProps>(({ device, isCurrent, onClose }) => {
const { t } = useTranslation('setting');
const utils = lambdaQuery.useUtils();
const [name, setName] = useState(device.friendlyName ?? '');
const [cwd, setCwd] = useState(device.defaultCwd ?? '');
const update = lambdaQuery.device.updateDevice.useMutation({
onSuccess: () => utils.device.listDevices.invalidate(),
});
// Only the machine you're on can browse its own filesystem natively.
const canBrowse = !!isCurrent && isDesktop;
// Render the device's live connections straight from `device.channels` — one
// row per connection; an empty array means offline.
const channels = device.channels ?? [];
const isDirty = name !== (device.friendlyName ?? '') || cwd !== (device.defaultCwd ?? '');
const handleSave = async () => {
const trimmed = cwd.trim();
await update.mutateAsync({
defaultCwd: trimmed || null,
deviceId: device.deviceId,
friendlyName: name.trim() || null,
// Setting a default cwd also seeds the recent list.
recentCwds: trimmed ? nextRecentCwds(trimmed, device.recentCwds) : device.recentCwds,
});
};
const handleBrowse = async () => {
const result = await electronSystemService.selectFolder({
defaultPath: cwd.trim() || undefined,
title: t('devices.edit.defaultCwd'),
});
if (result?.path) setCwd(result.path);
};
const handleRemoveRecent = (path: string) => {
update.mutate({
deviceId: device.deviceId,
recentCwds: device.recentCwds.filter((p) => p !== path),
});
};
return (
<Flexbox className={styles.container} gap={20}>
{/* ─── Header ─── */}
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
{getDeviceIcon(device.platform)}
<Text ellipsis style={{ flex: 1, minWidth: 0 }} weight={600}>
{device.friendlyName || device.hostname || device.deviceId}
</Text>
{isCurrent && <Tag>{t('devices.currentBadge')}</Tag>}
<ActionIcon icon={XIcon} size={'small'} onClick={onClose} />
</Flexbox>
{/* ─── Connections ─── */}
<Flexbox gap={8}>
<span className={styles.label}>{t('devices.detail.connections')}</span>
{channels.length > 0 ? (
channels.map((channel, index) => (
<Flexbox horizontal align={'center'} gap={8} key={`${channel.connectedAt}-${index}`}>
<span
className={styles.dot}
style={{ background: cssVar.colorSuccess, flex: 'none' }}
/>
{channel.channel && <Tag size={'small'}>{channel.channel}</Tag>}
<Text style={{ fontSize: 12 }} type={'secondary'}>
{t('devices.channel.connected', { time: dayjs(channel.connectedAt).fromNow() })}
</Text>
</Flexbox>
))
) : (
<Flexbox horizontal align={'center'} gap={8}>
<span
className={styles.dot}
style={{ background: cssVar.colorTextQuaternary, flex: 'none' }}
/>
<Text style={{ fontSize: 12 }} type={'secondary'}>
{t('devices.status.offline')} ·{' '}
{t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() })}
</Text>
</Flexbox>
)}
</Flexbox>
{/* ─── Name ─── */}
<Flexbox gap={6}>
<span className={styles.label}>{t('devices.edit.friendlyName')}</span>
<Input
placeholder={t('devices.edit.friendlyNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Flexbox>
{/* ─── Default working directory ─── */}
<Flexbox gap={6}>
<span className={styles.label}>{t('devices.edit.defaultCwd')}</span>
<Flexbox horizontal gap={8}>
<Input
placeholder={t('devices.edit.defaultCwdPlaceholder')}
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
{canBrowse && (
<Button icon={<Icon icon={FolderOpenIcon} />} onClick={handleBrowse}>
{t('devices.edit.browse')}
</Button>
)}
</Flexbox>
</Flexbox>
{/* ─── Recent directories ─── */}
<Flexbox gap={6}>
<span className={styles.label}>{t('devices.detail.recentDirs')}</span>
{device.recentCwds.length === 0 ? (
<Text style={{ fontSize: 12 }} type={'secondary'}>
{t('devices.detail.noRecent')}
</Text>
) : (
device.recentCwds.map((path) => (
<Flexbox horizontal align={'center'} className={styles.recentRow} gap={8} key={path}>
<Text
className={styles.path}
style={{ color: cssVar.colorTextSecondary, cursor: 'pointer', flex: 1 }}
onClick={() => setCwd(path)}
>
{path}
</Text>
<Icon
className={styles.removeBtn}
icon={XIcon}
size={14}
onClick={() => handleRemoveRecent(path)}
/>
</Flexbox>
))
)}
</Flexbox>
{/* ─── Save ─── */}
{isDirty && (
<Flexbox horizontal justify={'flex-end'}>
<Button loading={update.isPending} type={'primary'} onClick={handleSave}>
{t('devices.edit.save')}
</Button>
</Flexbox>
)}
</Flexbox>
);
});
DeviceDetailPanel.displayName = 'DeviceDetailPanel';
export default DeviceDetailPanel;
@@ -1,17 +1,11 @@
'use client';
import { ActionIcon, DropdownMenu, Flexbox, Icon, Input, Tag, Text, Tooltip } from '@lobehub/ui';
import { confirmModal, Modal } from '@lobehub/ui/base-ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { ActionIcon, DropdownMenu, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
import { confirmModal } from '@lobehub/ui/base-ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import dayjs from 'dayjs';
import {
FolderIcon,
MoreVerticalIcon,
PencilLineIcon,
Trash2Icon,
TriangleAlertIcon,
} from 'lucide-react';
import { memo, useState } from 'react';
import { FolderIcon, MoreVerticalIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { lambdaQuery } from '@/libs/trpc/client';
@@ -40,10 +34,6 @@ export interface DeviceListItem {
}
const styles = createStaticStyles(({ css }) => ({
channels: css`
margin-block-start: 4px;
padding-inline-start: 30px;
`,
cwd: css`
overflow: hidden;
font-family: ${cssVar.fontFamilyCode};
@@ -67,55 +57,52 @@ const styles = createStaticStyles(({ css }) => ({
color: ${cssVar.colorTextSecondary};
`,
row: css`
cursor: pointer;
padding-block: 12px;
padding-inline: 12px;
border-radius: ${cssVar.borderRadiusLG};
transition: background 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
rowActive: css`
background: ${cssVar.colorFillSecondary};
&:hover {
background: ${cssVar.colorFillSecondary};
}
`,
}));
const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => {
interface DeviceItemProps {
device: DeviceListItem;
isCurrent?: boolean;
onSelect: () => void;
selected?: boolean;
}
const DeviceItem = memo<DeviceItemProps>(({ device, isCurrent, onSelect, selected }) => {
const { t } = useTranslation('setting');
const utils = lambdaQuery.useUtils();
const [editOpen, setEditOpen] = useState(false);
const [name, setName] = useState('');
const [cwd, setCwd] = useState('');
const updateDevice = lambdaQuery.device.updateDevice.useMutation({
onSuccess: () => utils.device.listDevices.invalidate(),
});
const removeDevice = lambdaQuery.device.removeDevice.useMutation({
onSuccess: () => utils.device.listDevices.invalidate(),
});
const displayName = device.friendlyName || device.hostname || device.deviceId;
const isFallback = device.identitySource === 'fallback';
// `channels` may be absent when the backend predates the channel-aware
// `listDevices` shape; fall back to a single synthetic channel when online.
const channels =
device.channels ??
(device.online
? [{ channel: null, connectedAt: device.lastSeen, hostname: null, platform: null }]
: []);
const openEdit = () => {
setName(device.friendlyName ?? '');
setCwd(device.defaultCwd ?? '');
setEditOpen(true);
};
const handleSave = async () => {
await updateDevice.mutateAsync({
defaultCwd: cwd.trim() || null,
deviceId: device.deviceId,
friendlyName: name.trim() || null,
});
setEditOpen(false);
};
// Online when the device has at least one live connection in `device.channels`.
const channels = device.channels ?? [];
const online = channels.length > 0;
const statusTooltip = online
? t('devices.channel.connected', {
time: dayjs(channels[0]?.connectedAt ?? device.lastSeen).fromNow(),
})
: t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() });
const handleRemove = () =>
confirmModal({
@@ -129,66 +116,43 @@ const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => {
});
return (
<>
<Flexbox horizontal align={'flex-start'} className={styles.row} gap={12}>
<span className={styles.icon} style={{ marginBlockStart: 2 }}>
{getDeviceIcon(device.platform)}
</span>
<Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
<Flexbox horizontal align={'center'} gap={8}>
<Text ellipsis weight={500}>
{displayName}
</Text>
{isFallback && (
<Tooltip title={t('devices.fallbackTooltip')}>
<Tag icon={<Icon icon={TriangleAlertIcon} />}>{t('devices.fallbackBadge')}</Tag>
</Tooltip>
)}
</Flexbox>
{device.defaultCwd && (
<Flexbox horizontal align={'center'} gap={6}>
<Icon icon={FolderIcon} size={12} style={{ color: cssVar.colorTextQuaternary }} />
<Text className={styles.cwd} style={{ fontSize: 12 }} type={'secondary'}>
{device.defaultCwd}
</Text>
</Flexbox>
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.row, selected && styles.rowActive)}
gap={12}
onClick={onSelect}
>
<span className={styles.icon} style={{ marginBlockStart: 2 }}>
{getDeviceIcon(device.platform)}
</span>
<Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
<Flexbox horizontal align={'center'} gap={8}>
<Text ellipsis weight={500}>
{displayName}
</Text>
<Tooltip title={statusTooltip}>
<span className={online ? styles.dotOnline : styles.dotOffline} />
</Tooltip>
{isCurrent && <Tag>{t('devices.currentBadge')}</Tag>}
{isFallback && (
<Tooltip title={t('devices.fallbackTooltip')}>
<Tag icon={<Icon icon={TriangleAlertIcon} />}>{t('devices.fallbackBadge')}</Tag>
</Tooltip>
)}
<Flexbox className={styles.channels} gap={6}>
{channels.length > 0 ? (
channels.map((channel, index) => (
<Flexbox
horizontal
align={'center'}
gap={6}
key={`${channel.connectedAt}-${index}`}
>
<span className={styles.dotOnline} />
<Text style={{ fontSize: 12 }} type={'secondary'}>
{channel.channel ? `${channel.channel} · ` : ''}
{t('devices.channel.connected', { time: dayjs(channel.connectedAt).fromNow() })}
</Text>
</Flexbox>
))
) : (
<Flexbox horizontal align={'center'} gap={6}>
<span className={styles.dotOffline} />
<Text style={{ fontSize: 12 }} type={'secondary'}>
{t('devices.status.offline')} ·{' '}
{t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() })}
</Text>
</Flexbox>
)}
</Flexbox>
</Flexbox>
{device.defaultCwd && (
<Flexbox horizontal align={'center'} gap={6}>
<Icon icon={FolderIcon} size={12} style={{ color: cssVar.colorTextQuaternary }} />
<Text className={styles.cwd} style={{ fontSize: 12 }} type={'secondary'}>
{device.defaultCwd}
</Text>
</Flexbox>
)}
</Flexbox>
<span onClick={(e) => e.stopPropagation()}>
<DropdownMenu
items={[
{
icon: <Icon icon={PencilLineIcon} />,
key: 'edit',
label: t('devices.actions.edit'),
onClick: openEdit,
},
{ type: 'divider' },
{
danger: true,
icon: <Icon icon={Trash2Icon} />,
@@ -200,37 +164,8 @@ const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => {
>
<ActionIcon icon={MoreVerticalIcon} />
</DropdownMenu>
</Flexbox>
<Modal
cancelText={t('devices.edit.cancel')}
confirmLoading={updateDevice.isPending}
okText={t('devices.edit.save')}
open={editOpen}
title={t('devices.edit.title')}
width={440}
onCancel={() => setEditOpen(false)}
onOk={handleSave}
>
<Flexbox gap={16} paddingBlock={8}>
<Flexbox gap={6}>
<Text weight={500}>{t('devices.edit.friendlyName')}</Text>
<Input
placeholder={t('devices.edit.friendlyNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Flexbox>
<Flexbox gap={6}>
<Text weight={500}>{t('devices.edit.defaultCwd')}</Text>
<Input
placeholder={t('devices.edit.defaultCwdPlaceholder')}
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
</Flexbox>
</Flexbox>
</Modal>
</>
</span>
</Flexbox>
);
});
@@ -1,16 +1,21 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox, Skeleton, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { memo } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { lambdaQuery } from '@/libs/trpc/client';
import { useElectronStore } from '@/store/electron';
import DeviceDetailPanel from './DeviceDetailPanel';
import DeviceItem from './DeviceItem';
const styles = createStaticStyles(({ css }) => ({
container: css`
detailCol: css`
align-self: stretch;
min-width: 0;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
`,
@@ -19,6 +24,11 @@ const styles = createStaticStyles(({ css }) => ({
color: ${cssVar.colorTextTertiary};
text-align: center;
`,
listCol: css`
min-width: 0;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
`,
}));
const DeviceList = memo(() => {
@@ -27,6 +37,18 @@ const DeviceList = memo(() => {
staleTime: 30_000,
});
// Identify which row is the machine the user is on right now (desktop only —
// the web client isn't itself a registered device), so it can be badged and
// offered a native folder picker for its working directory.
const useFetchDeviceInfo = useElectronStore((s) => s.useFetchGatewayDeviceInfo);
const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo);
useFetchDeviceInfo();
const currentDeviceId = isDesktop ? gatewayDeviceInfo?.deviceId : undefined;
// No device is selected by default — the detail panel only appears once the
// user clicks a row.
const [selectedId, setSelectedId] = useState<string>();
if (isLoading) return <Skeleton active paragraph={{ rows: 4 }} title={false} />;
if (!devices || devices.length === 0)
@@ -36,11 +58,35 @@ const DeviceList = memo(() => {
</Flexbox>
);
const selected = selectedId ? devices.find((d) => d.deviceId === selectedId) : undefined;
const isCurrent = (id: string) => !!currentDeviceId && id === currentDeviceId;
return (
<Flexbox className={styles.container} padding={4}>
{devices.map((device) => (
<DeviceItem device={device} key={device.deviceId} />
))}
<Flexbox horizontal align={'flex-start'} gap={16}>
<Flexbox className={styles.listCol} flex={1} padding={4}>
{devices.map((device) => (
<DeviceItem
device={device}
isCurrent={isCurrent(device.deviceId)}
key={device.deviceId}
selected={device.deviceId === selectedId}
onSelect={() =>
setSelectedId((prev) => (prev === device.deviceId ? undefined : device.deviceId))
}
/>
))}
</Flexbox>
{selected && (
<Flexbox className={styles.detailCol} flex={1}>
{/* keyed on deviceId so the form state resets when the selection changes */}
<DeviceDetailPanel
device={selected}
isCurrent={isCurrent(selected.deviceId)}
key={selected.deviceId}
onClose={() => setSelectedId(undefined)}
/>
</Flexbox>
)}
</Flexbox>
);
});
+31
View File
@@ -46,6 +46,7 @@ import { AgentModel } from '@/database/models/agent';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { AgentSkillModel } from '@/database/models/agentSkill';
import { AiModelModel } from '@/database/models/aiModel';
import { DeviceModel } from '@/database/models/device';
import { FileModel } from '@/database/models/file';
import { MessageModel } from '@/database/models/message';
import { PluginModel } from '@/database/models/plugin';
@@ -946,9 +947,39 @@ export class AiAgentService {
userMessageId: userMsg?.id ?? parentMessageId ?? '',
};
}
// Resolve the working directory for the run: a topic-level override
// wins, else the device's user-configured defaultCwd. The device row
// lives in the DB (the gateway only knows live connections), so read
// it directly rather than via deviceProxy.
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.
const deviceCwd =
topic?.metadata?.workingDirectory ||
appContext?.initialTopicMetadata?.workingDirectory ||
boundDevice?.defaultCwd ||
undefined;
// A device is the user's own persistent machine — build a
// device-specific context instead of reusing the cloud-sandbox one
// (which describes an ephemeral /workspace + pre-cloned repos and
// would mislead the agent).
const { buildRemoteDeviceHeteroContext } =
await import('@/server/services/heterogeneousAgent/remoteDeviceHeteroContext');
const deviceSystemContext = buildRemoteDeviceHeteroContext({
agentSystemContext: agentConfig.agencyConfig?.heterogeneousProvider?.systemContext,
conversationHistory,
cwd: deviceCwd,
});
const result = await deviceProxy.dispatchAgentRun({
...heteroParams,
cwd: deviceCwd,
deviceId: dispatchDeviceId,
systemContext: deviceSystemContext,
});
if (!result.success) {
log('execAgent: hetero device dispatch failed: %s', result.error);
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { buildRemoteDeviceHeteroContext } from './remoteDeviceHeteroContext';
describe('buildRemoteDeviceHeteroContext', () => {
it('returns undefined when there is nothing to inject', () => {
expect(buildRemoteDeviceHeteroContext({})).toBeUndefined();
expect(buildRemoteDeviceHeteroContext({ agentSystemContext: ' ' })).toBeUndefined();
expect(buildRemoteDeviceHeteroContext({ conversationHistory: [] })).toBeUndefined();
});
it('puts the agent static context first', () => {
const result = buildRemoteDeviceHeteroContext({ agentSystemContext: 'Follow the repo rules.' });
expect(result).toBe('Follow the repo rules.');
});
it('describes the working directory without cloud-sandbox boilerplate', () => {
const result = buildRemoteDeviceHeteroContext({ cwd: '/Users/alice/projects/app' });
expect(result).toContain('/Users/alice/projects/app');
expect(result).toContain("user's own machine");
// Must NOT leak the cloud-sandbox context.
expect(result).not.toContain('/workspace');
expect(result).not.toContain('ephemeral');
expect(result).not.toContain('cloud sandbox');
});
it('trims a blank cwd instead of emitting an empty workspace note', () => {
expect(buildRemoteDeviceHeteroContext({ cwd: ' ' })).toBeUndefined();
});
it('appends and truncates prior conversation turns', () => {
const result = buildRemoteDeviceHeteroContext({
conversationHistory: [
{ content: 'a'.repeat(2000), role: 'user' },
{ content: 'short reply', role: 'assistant' },
],
});
expect(result).toContain('<previous_conversation>');
expect(result).toContain('… [truncated]'); // user turn exceeds the 1 KB cap
expect(result).toContain('short reply');
});
it('orders sections: agent context → workspace → history', () => {
const result = buildRemoteDeviceHeteroContext({
agentSystemContext: 'AGENT_CTX',
conversationHistory: [{ content: 'HIST', role: 'user' }],
cwd: '/repo',
})!;
expect(result.indexOf('AGENT_CTX')).toBeLessThan(result.indexOf('/repo'));
expect(result.indexOf('/repo')).toBeLessThan(result.indexOf('<previous_conversation>'));
});
});
@@ -0,0 +1,71 @@
import type { ConversationHistoryEntry } from './cloudHeteroContext';
/**
* Builds the system context injected before every user prompt for hetero runs
* dispatched to a **remote device** (`lh connect`), as opposed to a cloud
* sandbox.
*
* Unlike {@link buildCloudHeteroContext}, this deliberately strips all the
* cloud-sandbox boilerplate (ephemeral `/workspace`, pre-cloned repo list,
* "commit-or-lose-your-work" warnings, injected GITHUB_TOKEN). A device is the
* user's own persistent machine with their real filesystem and credentials, so
* none of that applies injecting it would actively mislead the agent.
*
* What remains is only what's genuinely useful on a device:
* - the agent-level static context (workspace conventions / rules), and
* - prior conversation turns when a session is resumed without a native session
* file.
*
* Returns `undefined` when there's nothing meaningful to inject, so the caller
* can omit the extra content block entirely.
*/
export function buildRemoteDeviceHeteroContext(params: {
/** Static systemContext from HeterogeneousProviderConfig.systemContext (agent-level). */
agentSystemContext?: string;
/**
* Recent conversation turns to inject when resuming a session whose native
* context is unavailable (e.g. a fresh CLI process on the device).
*/
conversationHistory?: ConversationHistoryEntry[];
/** Working directory the agent will run in, surfaced so it can orient itself. */
cwd?: string;
}): string | undefined {
const { agentSystemContext, conversationHistory, cwd } = params;
const parts: string[] = [];
// --- Agent-level static context (highest priority, goes first) ---
if (agentSystemContext?.trim()) {
parts.push(agentSystemContext.trim());
}
// --- Device workspace note (minimal — it's the user's real machine) ---
if (cwd?.trim()) {
parts.push(
[
'## Workspace',
`You are running on the user's own machine. Your working directory is \`${cwd.trim()}\`.`,
'This is a persistent local filesystem — changes are not lost when the task ends, so',
'there is no need to commit or push purely to preserve your work.',
].join('\n'),
);
}
// --- Previous conversation context (injected when session was reset) ---
// Mirrors buildCloudHeteroContext truncation: user 1 KB, assistant 2 KB.
if (conversationHistory && conversationHistory.length > 0) {
const USER_MAX = 1024;
const ASST_MAX = 2048;
const entries = conversationHistory.map((entry) => {
const limit = entry.role === 'user' ? USER_MAX : ASST_MAX;
const body =
entry.content.length > limit
? `${entry.content.slice(0, limit)}… [truncated]`
: entry.content;
return `<${entry.role}>\n${body}\n</${entry.role}>`;
});
parts.push(`<previous_conversation>\n${entries.join('\n')}\n</previous_conversation>`);
}
return parts.length > 0 ? parts.join('\n\n') : undefined;
}
@@ -85,6 +85,7 @@ export class DeviceProxy {
operationId: string;
prompt: string;
resumeSessionId?: string;
systemContext?: string;
topicId: string;
userId: string;
}): Promise<{ error?: string; success: boolean }> {
+4
View File
@@ -291,11 +291,15 @@ const canCurrentAgentPublishToCommunity = (s: AgentStoreState): boolean =>
const currentAgentHeterogeneousProviderType = (s: AgentStoreState) =>
currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider?.type;
const currentAgentExecutionTarget = (s: AgentStoreState) =>
currentAgentConfig(s)?.agencyConfig?.executionTarget;
const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) =>
s.agentDocumentsMap[agentId];
export const agentSelectors = {
canCurrentAgentPublishToCommunity,
currentAgentExecutionTarget,
currentAgentHeterogeneousProviderType,
currentAgentAvatar,
currentAgentBackgroundColor,