Improve session chat display and desktop workspace links#420
Conversation
📝 WalkthroughWalkthroughShell-open now prefers the controller API and derives allowed/workspace roots from Changes
Sequence Diagram(s)sequenceDiagram
participant Web as Web Client
participant Ctrl as Controller
participant Bridge as Desktop Bridge
Web->>Web: user clicks folder link (file://)
Web->>Web: decode -> folderPath?
alt folderPath decoded
Web->>Ctrl: POST /api/internal/desktop/shell-open {"path": folderPath}
alt response.ok
Ctrl-->>Web: 200 OK
Web->>Web: return (no bridge invoked)
else fetch error or non-OK
Ctrl-->>Web: error/non-OK
Web->>Bridge: invoke('shell:open-external', original URL)
Bridge-->>Web: ack
end
else non-file or cannot decode
Web->>Bridge: invoke('shell:open-external', original URL)
Bridge-->>Web: ack
end
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/pages/sessions.tsx (1)
426-442:⚠️ Potential issue | 🟠 MajorHandle tool-only assistant messages end-to-end.
Pure
toolCall/tool_useframes still never render correctly here: the transcript filter removes them becausetextis empty, and even if you keep them,ChatBubblealways emits an empty message bubble before the chip. That means a valid agent transcript shape either disappears entirely or shows blank chrome.Suggested fix
- <div - className={cn( - "inline-block max-w-full rounded-[20px] px-4 py-3 text-[13px] break-words shadow-[0_10px_24px_rgba(15,23,42,0.04)]", - isBot - ? "border border-border bg-surface-1 text-text-primary rounded-tl-sm" - : "bg-surface-3 text-text-primary rounded-tr-sm", - )} - > - <ChatMarkdown content={text} /> - </div> + {text.trim().length > 0 && ( + <div + className={cn( + "inline-block max-w-full rounded-[20px] px-4 py-3 text-[13px] break-words shadow-[0_10px_24px_rgba(15,23,42,0.04)]", + isBot + ? "border border-border bg-surface-1 text-text-primary rounded-tl-sm" + : "bg-surface-3 text-text-primary rounded-tr-sm", + )} + > + <ChatMarkdown content={text} /> + </div> + )} @@ {messages .filter((msg) => { - const { text } = extractMessage( + const { text, hasToolCall } = extractMessage( msg as unknown as Record<string, unknown>, ); - return text.trim().length > 0; + return text.trim().length > 0 || hasToolCall; }) .map((msg) => (Also applies to: 670-684
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/pages/sessions.tsx` around lines 426 - 442, The UI currently renders an empty message bubble for agent tool-only frames because the ChatMarkdown block is always emitted even when text === "" and the transcript filter drops those frames; update the rendering in this component so that if hasToolCall is true and text is empty you skip emitting the ChatMarkdown message div (do not render the rounded message block) and instead render the ArtifactCard (using toolCallSummary) in its place while preserving the surrounding layout and isBot alignment; reference ChatMarkdown, ArtifactCard, and the local flags isBot, hasToolCall, text, and toolCallSummary to locate and change the conditional rendering logic so tool-only assistant messages show only the chip/card and no empty bubble.
🧹 Nitpick comments (3)
apps/web/src/lib/desktop-links.ts (1)
90-108: Consider using the generated SDK instead of rawfetch.The coding guidelines state that frontend code must use the generated SDK from
apps/web/lib/api/rather than rawfetch. If a client for/api/internal/desktop/shell-opendoesn't exist yet, consider generating one or adding this endpoint to the SDK.Additionally, the current logic falls back to the host bridge when the controller returns a non-OK response (e.g., 403 for disallowed paths). This means a security restriction from the controller is silently bypassed via the host bridge. If this is intentional (to support paths outside the allowlist on the desktop), consider adding a comment clarifying the design decision.
As per coding guidelines: "Frontend must use generated SDK from
apps/web/lib/api/, never rawfetch"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/lib/desktop-links.ts` around lines 90 - 108, Replace the raw fetch call to "/api/internal/desktop/shell-open" with the generated SDK client from apps/web/lib/api (use the SDK method that maps to this POST endpoint) instead of fetch; keep the same JSON payload ({ path: folderPath }) and await the SDK response. After calling the SDK, explicitly handle non-OK responses: do not silently fall back to hostBridge.invoke("shell:open-external", { url }) when the controller returns a non-OK status (e.g., 403); either return/throw the error or add a clear comment above the hostBridge fallback that this bypass is intentional and why. Ensure you reference and update the code paths that use folderPath, hostBridge, and url so the SDK call replaces the existing fetch and the fallback behavior is made explicit.apps/controller/src/routes/desktop-routes.ts (1)
101-112: Redundant allowlist check –allowedWorkspaceRootis already insideallowedRoot.Since
allowedWorkspaceRootis defined aspath.join(container.env.openclawStateDir, "agents"), any path that passes the workspace root check will also pass theallowedRootcheck. The workspace-specific conditions at lines 110-111 are always subsumed by lines 108-109.If the intent is to restrict shell-open to only the agents subdirectory (not the broader state dir), consider using only
allowedWorkspaceRoot. If both should be allowed, the current code works but the workspace checks can be removed for clarity.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/src/routes/desktop-routes.ts` around lines 101 - 112, The checks are redundant because allowedWorkspaceRoot (path.join(container.env.openclawStateDir, "agents")) is inside allowedRoot; either remove the workspace-specific conditions (the resolved.startsWith(allowedWorkspaceRoot + path.sep) and resolved === allowedWorkspaceRoot checks) to keep the broader allowlist, or if you intend to restrict to only the agents subdirectory, change allowedRoot to point to allowedWorkspaceRoot and remove the separate allowedWorkspaceRoot variable and checks; update the conditional that checks resolved accordingly (references: allowedRoot, allowedWorkspaceRoot, resolved, container.env.openclawStateDir).apps/controller/tests/route-compat.test.ts (1)
294-309: Consider adding a test for the no-bots fallback path.The test covers the happy path where a bot exists. To fully exercise the updated
/api/internal/desktop/readyendpoint, consider adding a second test case that omits thecreateBotcall and assertsworkspacePathequalspath.join(rootDir, ".openclaw", "agents").🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/tests/route-compat.test.ts` around lines 294 - 309, Add a new test for the no-bots fallback of the /api/internal/desktop/ready endpoint: copy the existing it(...) block but do NOT call container.configStore.createBot; create the app with createApp(container), request "/api/internal/desktop/ready", assert status 200, parse payload and assert payload.workspacePath === path.join(rootDir, ".openclaw", "agents") so the handler returns the default agents directory when no bot exists (use the same test utilities and names as the existing test for consistency).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/pages/sessions.tsx`:
- Around line 154-183: formatToolCallSummary currently returns empty strings or
"tool" for separator-only or unnamed tool blocks, preventing ArtifactCard from
falling back to sessions.chat.toolActivity; update formatToolCallSummary to
normalize these cases by returning null when the input yields only separators or
when the computed summary equals (case-insensitive) "tool" or is empty after
trimming. Locate formatToolCallSummary and ensure its logic checks for
separator-only inputs and placeholder tokens (e.g., "tool") and returns null in
those cases; apply the same change to the other instance of this logic mentioned
in the review (the similar block around the second occurrence).
---
Outside diff comments:
In `@apps/web/src/pages/sessions.tsx`:
- Around line 426-442: The UI currently renders an empty message bubble for
agent tool-only frames because the ChatMarkdown block is always emitted even
when text === "" and the transcript filter drops those frames; update the
rendering in this component so that if hasToolCall is true and text is empty you
skip emitting the ChatMarkdown message div (do not render the rounded message
block) and instead render the ArtifactCard (using toolCallSummary) in its place
while preserving the surrounding layout and isBot alignment; reference
ChatMarkdown, ArtifactCard, and the local flags isBot, hasToolCall, text, and
toolCallSummary to locate and change the conditional rendering logic so
tool-only assistant messages show only the chip/card and no empty bubble.
---
Nitpick comments:
In `@apps/controller/src/routes/desktop-routes.ts`:
- Around line 101-112: The checks are redundant because allowedWorkspaceRoot
(path.join(container.env.openclawStateDir, "agents")) is inside allowedRoot;
either remove the workspace-specific conditions (the
resolved.startsWith(allowedWorkspaceRoot + path.sep) and resolved ===
allowedWorkspaceRoot checks) to keep the broader allowlist, or if you intend to
restrict to only the agents subdirectory, change allowedRoot to point to
allowedWorkspaceRoot and remove the separate allowedWorkspaceRoot variable and
checks; update the conditional that checks resolved accordingly (references:
allowedRoot, allowedWorkspaceRoot, resolved, container.env.openclawStateDir).
In `@apps/controller/tests/route-compat.test.ts`:
- Around line 294-309: Add a new test for the no-bots fallback of the
/api/internal/desktop/ready endpoint: copy the existing it(...) block but do NOT
call container.configStore.createBot; create the app with createApp(container),
request "/api/internal/desktop/ready", assert status 200, parse payload and
assert payload.workspacePath === path.join(rootDir, ".openclaw", "agents") so
the handler returns the default agents directory when no bot exists (use the
same test utilities and names as the existing test for consistency).
In `@apps/web/src/lib/desktop-links.ts`:
- Around line 90-108: Replace the raw fetch call to
"/api/internal/desktop/shell-open" with the generated SDK client from
apps/web/lib/api (use the SDK method that maps to this POST endpoint) instead of
fetch; keep the same JSON payload ({ path: folderPath }) and await the SDK
response. After calling the SDK, explicitly handle non-OK responses: do not
silently fall back to hostBridge.invoke("shell:open-external", { url }) when the
controller returns a non-OK status (e.g., 403); either return/throw the error or
add a clear comment above the hostBridge fallback that this bypass is
intentional and why. Ensure you reference and update the code paths that use
folderPath, hostBridge, and url so the SDK call replaces the existing fetch and
the fallback behavior is made explicit.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: aea0a524-5e1a-43fb-87f7-c30c9efecb23
📒 Files selected for processing (8)
apps/controller/src/routes/desktop-routes.tsapps/controller/tests/route-compat.test.tsapps/web/src/i18n/locales/en.tsapps/web/src/i18n/locales/zh-CN.tsapps/web/src/lib/desktop-links.tsapps/web/src/pages/sessions.tsxapps/web/tests/desktop-links.test.tsapps/web/tests/sessions.test.tsx
There was a problem hiding this comment.
🧹 Nitpick comments (2)
apps/controller/src/runtime/sessions-runtime.ts (2)
416-425: Clarify intent: unknown block types are pushed but don't contribute to visibility.Lines 422-423 push unknown block types to
normalizedBlocksbut don't sethasVisibleContent = true. This means a message containing only unknown block types returnsnull, even though those blocks are collected.If this is intentional (only text and tool blocks count as "visible"), consider adding a brief comment. If unknown blocks should also count as visible, add
hasVisibleContent = trueafter line 422.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/src/runtime/sessions-runtime.ts` around lines 416 - 425, The loop currently pushes blocks with unknown blockType into normalizedBlocks but doesn't set hasVisibleContent, causing a collection of only-unknown blocks to return null; decide whether unknown blocks should count as visible and either (A) add hasVisibleContent = true immediately after the normalizedBlocks.push(block) for unknown types so they mark the message as visible, or (B) leave behavior and add an inline comment near the normalizedBlocks.push(block) explaining that only "text" and tool types are considered visible and unknown types are intentionally excluded from hasVisibleContent; refer to blockType, normalizedBlocks, and hasVisibleContent in your change.
483-496: Consider case-insensitive comparison for channelType.The comparison
channelType !== "feishu"is case-sensitive. WhilechannelTypeSchemaenforces lowercase "feishu" for channels,createSessionSchemaaccepts any string forchannelType(as noted in the relevant code snippets). If a session somehow has "Feishu" or "FEISHU", the Feishu-specific stripping won't apply.Since all current Feishu channels use lowercase (per
connectFeishu()), this is a minor robustness concern rather than an active bug.🛡️ Optional: normalize channelType comparison
private stripChannelSystemSuffix( text: string, channelType?: string | null, ): string { - if (channelType !== "feishu") { + if (channelType?.toLowerCase() !== "feishu") { return text; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/src/runtime/sessions-runtime.ts` around lines 483 - 496, In stripChannelSystemSuffix, the channelType comparison is case-sensitive; update the check to handle case-insensitive values (and null/undefined) by normalizing channelType before comparison (e.g., use channelType?.toLowerCase() === "feishu" or similar) so Feishu-specific stripping in stripChannelSystemSuffix applies even if channelType is "Feishu" or "FEISHU".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@apps/controller/src/runtime/sessions-runtime.ts`:
- Around line 416-425: The loop currently pushes blocks with unknown blockType
into normalizedBlocks but doesn't set hasVisibleContent, causing a collection of
only-unknown blocks to return null; decide whether unknown blocks should count
as visible and either (A) add hasVisibleContent = true immediately after the
normalizedBlocks.push(block) for unknown types so they mark the message as
visible, or (B) leave behavior and add an inline comment near the
normalizedBlocks.push(block) explaining that only "text" and tool types are
considered visible and unknown types are intentionally excluded from
hasVisibleContent; refer to blockType, normalizedBlocks, and hasVisibleContent
in your change.
- Around line 483-496: In stripChannelSystemSuffix, the channelType comparison
is case-sensitive; update the check to handle case-insensitive values (and
null/undefined) by normalizing channelType before comparison (e.g., use
channelType?.toLowerCase() === "feishu" or similar) so Feishu-specific stripping
in stripChannelSystemSuffix applies even if channelType is "Feishu" or "FEISHU".
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: abc55fae-7f7d-4e8c-a294-56a903cf274e
📒 Files selected for processing (5)
apps/controller/src/runtime/sessions-runtime.tsapps/controller/tests/session-routes.test.tsapps/controller/tests/sessions-runtime.test.tsapps/web/src/pages/sessions.tsxapps/web/tests/sessions.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/src/pages/sessions.tsx
- apps/web/tests/sessions.test.tsx
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
apps/web/src/pages/sessions.tsx (1)
773-781:extractMessageis invoked twice per displayed message.The filter calls
extractMessagefor each message, thenChatBubblecalls it again during render. Consider memoizing or pre-extracting once to avoid redundant parsing.Potential optimization
- {messages - .filter((msg) => { - const { text, replyContextText, hasToolCall } = - extractMessage(msg as unknown as Record<string, unknown>); - return ( - text.trim().length > 0 || - (replyContextText?.trim().length ?? 0) > 0 || - hasToolCall - ); - }) - .map((msg) => ( - <ChatBubble key={msg.id} msg={msg} /> - ))} + {messages + .map((msg) => ({ + msg, + extracted: extractMessage(msg as unknown as Record<string, unknown>), + })) + .filter(({ extracted }) => + extracted.text.trim().length > 0 || + (extracted.replyContextText?.trim().length ?? 0) > 0 || + extracted.hasToolCall + ) + .map(({ msg, extracted }) => ( + <ChatBubble key={msg.id} msg={msg} extracted={extracted} /> + ))}This would require updating
ChatBubbleto accept an optional pre-computedextractedprop.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/pages/sessions.tsx` around lines 773 - 781, The filter currently calls extractMessage for each message and ChatBubble calls extractMessage again during render, causing duplicate work; fix by pre-extracting once in the mapping step (e.g., create an array of {msg, extracted: extractMessage(msg)} before filtering) and update ChatBubble to accept an optional extracted prop (e.g., extracted) so you can pass the precomputed result into ChatBubble instead of letting it call extractMessage again; ensure all call sites of ChatBubble are updated to prefer the extracted prop when present and keep extractMessage usage only where no precomputed value exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/pages/sessions.tsx`:
- Around line 449-451: The "Reply" label in sessions.tsx is hardcoded; add a new
i18n key (e.g., sessions.chat.replyLabel) to your locale files and replace the
literal with a translated string using useTranslation(); locate the div with
className "text-[10px] font-semibold..." in the Sessions component and either
call useTranslation() there to render t('sessions.chat.replyLabel') or pass the
translated label into ReplyContextCard (or the relevant child) as a prop so the
component no longer contains the hardcoded "Reply".
---
Nitpick comments:
In `@apps/web/src/pages/sessions.tsx`:
- Around line 773-781: The filter currently calls extractMessage for each
message and ChatBubble calls extractMessage again during render, causing
duplicate work; fix by pre-extracting once in the mapping step (e.g., create an
array of {msg, extracted: extractMessage(msg)} before filtering) and update
ChatBubble to accept an optional extracted prop (e.g., extracted) so you can
pass the precomputed result into ChatBubble instead of letting it call
extractMessage again; ensure all call sites of ChatBubble are updated to prefer
the extracted prop when present and keep extractMessage usage only where no
precomputed value exists.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8c695006-121e-4984-af42-abf3b022d640
📒 Files selected for processing (5)
apps/controller/src/runtime/sessions-runtime.tsapps/controller/tests/session-routes.test.tsapps/controller/tests/sessions-runtime.test.tsapps/web/src/pages/sessions.tsxapps/web/tests/sessions.test.tsx
✅ Files skipped from review due to trivial changes (1)
- apps/controller/tests/session-routes.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/controller/src/runtime/sessions-runtime.ts
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/web/tests/sessions.test.tsx (1)
405-417: Consider extracting a shared test harness for repeatedQueryClientsetup.The new cases repeat the same initialization boilerplate; a small helper would reduce noise and future maintenance overhead.
Also applies to: 462-476, 528-543
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/tests/sessions.test.tsx` around lines 405 - 417, Extract the repeated QueryClient initialization and pre-population into a small test helper (e.g., createTestQueryClient or setupQueryClientWithSessionMeta) that constructs a QueryClient with defaultOptions.queries.retry = false and calls queryClient.setQueryData(["session-meta", sessionId], { id: sessionId, title, channelType, messageCount, ... }) before returning the client; replace the duplicated blocks that call new QueryClient(...) and setQueryData in tests (the code using QueryClient and setQueryData) with calls to this helper to reduce boilerplate and ensure consistent setup across test cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@apps/web/tests/sessions.test.tsx`:
- Around line 405-417: Extract the repeated QueryClient initialization and
pre-population into a small test helper (e.g., createTestQueryClient or
setupQueryClientWithSessionMeta) that constructs a QueryClient with
defaultOptions.queries.retry = false and calls
queryClient.setQueryData(["session-meta", sessionId], { id: sessionId, title,
channelType, messageCount, ... }) before returning the client; replace the
duplicated blocks that call new QueryClient(...) and setQueryData in tests (the
code using QueryClient and setQueryData) with calls to this helper to reduce
boilerplate and ensure consistent setup across test cases.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 33bfc6f9-7923-40f9-92f0-2343cdab271a
📒 Files selected for processing (4)
apps/web/src/i18n/locales/en.tsapps/web/src/i18n/locales/zh-CN.tsapps/web/src/pages/sessions.tsxapps/web/tests/sessions.test.tsx
✅ Files skipped from review due to trivial changes (2)
- apps/web/src/i18n/locales/zh-CN.ts
- apps/web/src/i18n/locales/en.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/src/pages/sessions.tsx
Relates to #512
Summary
/api/internal/desktop/readyand prefer controller shell-open for local file links with a desktop bridge fallbackVerification
Summary by CodeRabbit
New Features
Bug Fixes
Tests
Localization