mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): always-on chat title bar with inline rename and workspace actions (#109422)
* feat(gateway): add sessions.files.reveal to open a session workspace on the gateway host
Resolves the session's workspace root server-side (same resolution as
sessions.files.list), refuses exec-node and cloud-worker placements, and
opens the directory with the platform opener. The opener helpers move out
of config.ts into a shared open-path module that config.openFile now uses.
* feat(ui): always-on chat title bar with inline rename and workspace menu
Every chat pane now renders its header: click the session title to rename
it inline (Enter commits via sessions.patch, Escape cancels), a workspace
chip names the session's checkout with reveal/copy-path/copy-branch
actions, and cloud-worker sessions show a globe. The single-pane floating
toggle cluster and the transcript's reserved titlebar drag band fold into
the header, which keeps native macOS window dragging.
* fix(ui): resolve title-bar session aliases and clear the collapsed-nav overlay
Live verification found two gaps: route aliases like ?session=main showed
the generic key-derived title instead of the session's label (resolve via
hello defaults + key equivalence, matching the pane), and the floating
sidebar-expand pill overlapped the header title on plain web while the nav
was collapsed (52px inset, mirroring the native-shell clearances).
* fix(ui): keep the protocol schema layer out of the Control UI startup bundle
The title-bar change made session-row-badges runtime-import the
gateway-protocol barrel for one classifier, dragging typebox and every
schema into startup JS (395 KiB gzip vs the 370 KiB budget). The placement
vocabulary now lives in a dependency-free session-placement-state module:
the schema union derives from it, the barrel re-exports it, and the UI
deep-imports it (startup back to 361.8 KiB).
* fix(gateway-protocol): keep SessionPlacementStateSchema statically typed
Type.Union over a mapped array loses tuple inference and collapses Static
to never; the literal list stays explicit with a compile-time guard tying
it to the shared SESSION_PLACEMENT_STATES vocabulary.
* chore(protocol): regenerate Swift/Kotlin bindings for sessions.files.reveal
* chore: narrow new exports flagged by the deadcode gate
* chore(i18n): leave generated locale artifacts to the locale-refresh workflow
The generated-artifact isolation gate (067635cb51) landed mid-flight;
source PRs now carry en.ts only.
This commit is contained in:
committed by
GitHub
parent
082ffd442a
commit
00a6ecb906
@@ -203,6 +203,7 @@ enum class GatewayMethod(
|
||||
SessionsFilesList("sessions.files.list"),
|
||||
SessionsFilesGet("sessions.files.get"),
|
||||
SessionsFilesSet("sessions.files.set"),
|
||||
SessionsFilesReveal("sessions.files.reveal"),
|
||||
ArtifactsList("artifacts.list"),
|
||||
ArtifactsGet("artifacts.get"),
|
||||
ArtifactsDownload("artifacts.download"),
|
||||
|
||||
@@ -4554,6 +4554,46 @@ public struct SessionsFilesGetResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesRevealParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesRevealResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let path: String?
|
||||
public let error: String?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
path: String? = nil,
|
||||
error: String? = nil)
|
||||
{
|
||||
self.ok = ok
|
||||
self.path = path
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case path
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesSetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let path: String
|
||||
|
||||
@@ -368,15 +368,16 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
|
||||
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
|
||||
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
|
||||
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. Detection runs server-side through `controlUi.sessionPullRequests`, which reuses the Gateway's `GH_TOKEN`/`GITHUB_TOKEN` when set. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). Once the pushed branch has commits to compare, the row adds a Create PR button that opens GitHub's new-pull-request page; before that, a session with changed files (committed, uncommitted, or untracked) still gets the row without the button. The row hides itself while an open or draft PR exists. The branch row comes from local git only, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
|
||||
- The session diff panel shows what a session's checkout actually changed: the branch button (in the workspace rail header, the split-pane header, or the floating button in single-pane chat) opens the detail panel with a per-file diff of branch, uncommitted, and untracked work against the checkout's default-branch merge base — status dot, rename arrow, per-file +/− counts, collapsible files, and "N unmodified lines" markers between hunks. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
|
||||
- The session workspace rail in each Chat pane lists session files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B, the files toggle in the split-pane header, or the floating files button in single-pane chat (both carry a changed-file count badge). The separate file, tool, and Canvas detail panel is unaffected.
|
||||
- The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens the detail panel with a per-file diff of branch, uncommitted, and untracked work against the checkout's default-branch merge base — status dot, rename arrow, per-file +/− counts, collapsible files, and "N unmodified lines" markers between hunks. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
|
||||
- Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal.
|
||||
- The session workspace rail in each Chat pane lists session files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B or the files toggle in the title bar, which carries a changed-file count badge. The separate file, tool, and Canvas detail panel is unaffected.
|
||||
- Clicking a file reference in chat, a file path in an expanded read/edit/write tool card, or a file row in the workspace rail opens the file detail panel: a CodeMirror-based code view with syntax highlighting, line numbers, jump-to-line, in-file search, copy actions, and an open-in-external-editor menu. When the Gateway advertises `sessions.files.set` to an `operator.admin` connection, the panel adds an Edit mode with dirty tracking and Cmd/Ctrl-S save; unsaved drafts survive file, panel, and session navigation in the current browser tab until explicitly saved or discarded. Saves are compare-and-swap on a content hash returned by `sessions.files.get`: if the file changed on disk since it was loaded (for example because the agent kept working), the panel shows a conflict notice with Reload (take the latest content) and Overwrite (keep the local edit) actions. Writes go through the same fs-safe workspace guards as reads — path containment, symlink/hardlink rejection, and a 256 KB UTF-8 cap — and only overwrite existing files; the editor never creates or deletes them.
|
||||
- The background tasks rail in each Chat pane lists the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control; the collapsible finished section adds run durations; and a View transcript link opens the task's child session in the pane. Open it with the activity toggle in the split-pane header or the floating activity button in single-pane chat — the task snapshot loads eagerly, so both carry a running-count badge without opening the rail first. The Tasks page remains the full cross-agent ledger.
|
||||
- The background tasks rail in each Chat pane lists the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control; the collapsible finished section adds run durations; and a View transcript link opens the task's child session in the pane. Open it with the title-bar activity toggle; the task snapshot loads eagerly, so it carries a running-count badge without opening the rail first. The Tasks page remains the full cross-agent ledger.
|
||||
- The workspace rail, background tasks rail, and detail panel adapt to each pane's own width rather than the window: in a narrow pane or compact window both rails present as bottom strips (side-dock controls hide until the pane widens; the workspace rail keeps first claim on the side slot when only one column fits), and the detail panel stacks below the thread with a horizontal resize handle instead of sharing the row with it. Phone-sized viewports still open the detail panel full-screen.
|
||||
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
|
||||
- **Split view:** open it from the top-right floating toggle row (beside the session diff, background tasks, and session files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own session, transcript, composer, and tool stream.
|
||||
- **Split view:** open it from the chat title bar (beside the session diff, background tasks, and session files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own session, transcript, composer, and tool stream.
|
||||
- Drag a session from the sidebar into chat to open it in a pane. An animated drop preview glides between zones and labels the outcome — "Split" over the exact half a new pane will occupy, "Open here" over a whole pane — and drops also work from single-pane mode.
|
||||
- The active split pane drives the sidebar selection and URL. Each pane carries its own header row with the session title plus workspace-rail, split, and close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
|
||||
- The active split pane drives the sidebar selection and URL. Its title bar adds split and close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
|
||||
- On narrow screens, split view keeps the layout but renders only the active pane, including its header with the close control.
|
||||
- If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model.
|
||||
- Typing `/new` creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; then it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
|
||||
|
||||
@@ -341,6 +341,7 @@ import {
|
||||
SessionFileRelevanceSchema,
|
||||
SessionPlacementSchema,
|
||||
SessionPlacementStateSchema,
|
||||
isCloudWorkerPlacementState,
|
||||
SessionWorktreeInfoSchema,
|
||||
SessionsCreateParamsSchema,
|
||||
SessionsCreateResultSchema,
|
||||
@@ -367,6 +368,8 @@ import {
|
||||
SessionsFilesSetResultSchema,
|
||||
SessionsFilesListParamsSchema,
|
||||
SessionsFilesListResultSchema,
|
||||
SessionsFilesRevealParamsSchema,
|
||||
SessionsFilesRevealResultSchema,
|
||||
SessionsListParamsSchema,
|
||||
SessionCatalogSchema,
|
||||
SessionCatalogCapabilitiesSchema,
|
||||
@@ -645,6 +648,7 @@ export const validateSessionsResolveParams = lazyCompile(SessionsResolveParamsSc
|
||||
export const validateSessionsFilesListParams = lazyCompile(SessionsFilesListParamsSchema);
|
||||
export const validateSessionsFilesGetParams = lazyCompile(SessionsFilesGetParamsSchema);
|
||||
export const validateSessionsFilesSetParams = lazyCompile(SessionsFilesSetParamsSchema);
|
||||
export const validateSessionsFilesRevealParams = lazyCompile(SessionsFilesRevealParamsSchema);
|
||||
export const validateSessionsDiffParams = lazyCompile(SessionsDiffParamsSchema);
|
||||
export const validateSessionsCreateParams = lazyCompile(SessionsCreateParamsSchema);
|
||||
export const validateSessionsSendParams = lazyCompile(SessionsSendParamsSchema);
|
||||
@@ -847,6 +851,7 @@ export const validateWebLoginWaitParams = lazyCompile(WebLoginWaitParamsSchema);
|
||||
|
||||
// Explicit schema exports keep public protocol changes reviewable.
|
||||
export {
|
||||
isCloudWorkerPlacementState,
|
||||
ConnectParamsSchema,
|
||||
GatewaySuspendTaskBlockerSchema,
|
||||
GatewaySuspendBlockerSchema,
|
||||
@@ -991,6 +996,8 @@ export {
|
||||
SessionsFilesSetResultSchema,
|
||||
SessionsFilesListParamsSchema,
|
||||
SessionsFilesListResultSchema,
|
||||
SessionsFilesRevealParamsSchema,
|
||||
SessionsFilesRevealResultSchema,
|
||||
SessionDiffFileSchema,
|
||||
SessionDiffFileStatusSchema,
|
||||
SessionsDiffParamsSchema,
|
||||
@@ -1442,6 +1449,8 @@ export type {
|
||||
SessionsFilesGetResult,
|
||||
SessionsFilesSetParams,
|
||||
SessionsFilesSetResult,
|
||||
SessionsFilesRevealParams,
|
||||
SessionsFilesRevealResult,
|
||||
SessionDiffFile,
|
||||
SessionDiffFileStatus,
|
||||
SessionsDiffParams,
|
||||
|
||||
@@ -419,6 +419,8 @@ import {
|
||||
SessionsFilesGetResultSchema,
|
||||
SessionsFilesListParamsSchema,
|
||||
SessionsFilesListResultSchema,
|
||||
SessionsFilesRevealParamsSchema,
|
||||
SessionsFilesRevealResultSchema,
|
||||
SessionsFilesSetParamsSchema,
|
||||
SessionsFilesSetResultSchema,
|
||||
SessionsListParamsSchema,
|
||||
@@ -652,6 +654,8 @@ export const ProtocolSchemas = {
|
||||
SessionsFilesListResult: SessionsFilesListResultSchema,
|
||||
SessionsFilesGetParams: SessionsFilesGetParamsSchema,
|
||||
SessionsFilesGetResult: SessionsFilesGetResultSchema,
|
||||
SessionsFilesRevealParams: SessionsFilesRevealParamsSchema,
|
||||
SessionsFilesRevealResult: SessionsFilesRevealResultSchema,
|
||||
SessionsFilesSetParams: SessionsFilesSetParamsSchema,
|
||||
SessionsFilesSetResult: SessionsFilesSetResultSchema,
|
||||
SessionDiffFileStatus: SessionDiffFileStatusSchema,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Placement-state vocabulary shared by the schema layer, the gateway, and
|
||||
// the Control UI. Keep this module dependency-free: the browser imports it
|
||||
// at runtime, and a typebox import here would pull the entire schema layer
|
||||
// into the Control UI startup bundle (which has a hard size budget).
|
||||
export const SESSION_PLACEMENT_STATES = [
|
||||
"local",
|
||||
"requested",
|
||||
"provisioning",
|
||||
"syncing",
|
||||
"starting",
|
||||
"active",
|
||||
"draining",
|
||||
"reconciling",
|
||||
"reclaimed",
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
export type SessionPlacementState = (typeof SESSION_PLACEMENT_STATES)[number];
|
||||
|
||||
export function isCloudWorkerPlacementState(
|
||||
state: SessionPlacementState | undefined,
|
||||
): state is Exclude<SessionPlacementState, "local" | "reclaimed"> {
|
||||
return state !== undefined && state !== "local" && state !== "reclaimed";
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
import { SESSION_PLACEMENT_STATES } from "./session-placement-state.js";
|
||||
|
||||
/** Durable gateway ownership states for one session execution placement. */
|
||||
export {
|
||||
isCloudWorkerPlacementState,
|
||||
SESSION_PLACEMENT_STATES,
|
||||
type SessionPlacementState,
|
||||
} from "./session-placement-state.js";
|
||||
|
||||
/** Durable gateway ownership states for one session execution placement.
|
||||
* The literal list stays explicit because Type.Union needs a tuple for
|
||||
* Static inference (a mapped array collapses Static to never); the guard
|
||||
* below keeps it in lockstep with SESSION_PLACEMENT_STATES. */
|
||||
export const SessionPlacementStateSchema = Type.Union([
|
||||
Type.Literal("local"),
|
||||
Type.Literal("requested"),
|
||||
@@ -16,6 +26,13 @@ export const SessionPlacementStateSchema = Type.Union([
|
||||
Type.Literal("failed"),
|
||||
]);
|
||||
|
||||
type MutuallyAssignable<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
|
||||
const placementStateVocabularyInSync: MutuallyAssignable<
|
||||
Static<typeof SessionPlacementStateSchema>,
|
||||
(typeof SESSION_PLACEMENT_STATES)[number]
|
||||
> = true;
|
||||
void placementStateVocabularyInSync;
|
||||
|
||||
const SessionPlacementTimingProperties = {
|
||||
generation: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
createdAtMs: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
@@ -214,7 +231,6 @@ export const SessionPlacementProtocolSchemas = {
|
||||
SessionsReclaimResult: SessionsReclaimResultSchema,
|
||||
} as const;
|
||||
|
||||
export type SessionPlacementState = Static<typeof SessionPlacementStateSchema>;
|
||||
export type SessionPlacement = Static<typeof SessionPlacementSchema>;
|
||||
export type SessionsDispatchParams = Static<typeof SessionsDispatchParamsSchema>;
|
||||
export type SessionsDispatchResult = Static<typeof SessionsDispatchResultSchema>;
|
||||
|
||||
@@ -154,6 +154,19 @@ export const SessionsFilesSetResultSchema = closedObject({
|
||||
file: SessionFileEntrySchema,
|
||||
});
|
||||
|
||||
/** Opens a session workspace on the Gateway host without accepting a client path. */
|
||||
export const SessionsFilesRevealParamsSchema = closedObject({
|
||||
key: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
/** Result for revealing a session workspace on the Gateway host. */
|
||||
export const SessionsFilesRevealResultSchema = closedObject({
|
||||
ok: Type.Boolean(),
|
||||
path: Type.Optional(NonEmptyString),
|
||||
error: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
/** Change status for one file in a session checkout diff. */
|
||||
export const SessionDiffFileStatusSchema = Type.Union([
|
||||
Type.Literal("added"),
|
||||
@@ -645,6 +658,8 @@ export type SessionsFilesGetParams = Static<typeof SessionsFilesGetParamsSchema>
|
||||
export type SessionsFilesGetResult = Static<typeof SessionsFilesGetResultSchema>;
|
||||
export type SessionsFilesSetParams = Static<typeof SessionsFilesSetParamsSchema>;
|
||||
export type SessionsFilesSetResult = Static<typeof SessionsFilesSetResultSchema>;
|
||||
export type SessionsFilesRevealParams = Static<typeof SessionsFilesRevealParamsSchema>;
|
||||
export type SessionsFilesRevealResult = Static<typeof SessionsFilesRevealResultSchema>;
|
||||
export type SessionDiffFileStatus = Static<typeof SessionDiffFileStatusSchema>;
|
||||
export type SessionDiffFile = Static<typeof SessionDiffFileSchema>;
|
||||
export type SessionsDiffParams = Static<typeof SessionsDiffParamsSchema>;
|
||||
|
||||
@@ -141,6 +141,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "sessions.files.get", scope: "operator.read" },
|
||||
// Workspace file writes require the same admin scope as agents.files.set.
|
||||
{ name: "sessions.files.set", scope: "operator.admin" },
|
||||
{ name: "sessions.files.reveal", scope: "operator.admin" },
|
||||
{ name: "artifacts.list", scope: "operator.read" },
|
||||
{ name: "artifacts.get", scope: "operator.read" },
|
||||
{ name: "artifacts.download", scope: "operator.read" },
|
||||
|
||||
@@ -75,6 +75,11 @@ describe("listGatewayMethods", () => {
|
||||
expect(listGatewayMethods()).toContain("controlUi.sessionPullRequests");
|
||||
});
|
||||
|
||||
it("advertises session workspace reveal", () => {
|
||||
expect(listGatewayMethods()).toContain("sessions.files.reveal");
|
||||
expect(coreGatewayHandlers["sessions.files.reveal"]).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("advertises the versioned activity audit method", () => {
|
||||
expect(listGatewayMethods()).toContain("audit.activity.list");
|
||||
expect(coreGatewayHandlers["audit.activity.list"]).toBeTypeOf("function");
|
||||
|
||||
@@ -751,7 +751,12 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
loadHandlers: loadArtifactsHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["sessions.files.list", "sessions.files.get", "sessions.files.set"],
|
||||
methods: [
|
||||
"sessions.files.list",
|
||||
"sessions.files.get",
|
||||
"sessions.files.set",
|
||||
"sessions.files.reveal",
|
||||
],
|
||||
loadHandlers: loadSessionsFilesHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
clearConfigSchemaResponseCacheForTests,
|
||||
configHandlers,
|
||||
loadConfigSchemaResponseForTests,
|
||||
resolveConfigOpenCommand,
|
||||
} from "./config.js";
|
||||
import { createConfigHandlerHarness } from "./config.test-helpers.js";
|
||||
import { resolveOpenPathCommand } from "./open-path.js";
|
||||
|
||||
const { runExecMock, loadGatewayRuntimeConfigSchemaMock } = vi.hoisted(() => ({
|
||||
runExecMock: vi.fn(),
|
||||
@@ -47,23 +47,23 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveConfigOpenCommand", () => {
|
||||
describe("resolveOpenPathCommand", () => {
|
||||
it("uses open on macOS", () => {
|
||||
expect(resolveConfigOpenCommand("/tmp/openclaw.json", "darwin")).toEqual({
|
||||
expect(resolveOpenPathCommand("/tmp/openclaw.json", "darwin")).toEqual({
|
||||
command: "open",
|
||||
args: ["/tmp/openclaw.json"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses xdg-open on Linux", () => {
|
||||
expect(resolveConfigOpenCommand("/tmp/openclaw.json", "linux")).toEqual({
|
||||
expect(resolveOpenPathCommand("/tmp/openclaw.json", "linux")).toEqual({
|
||||
command: "xdg-open",
|
||||
args: ["/tmp/openclaw.json"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a quoted PowerShell FilePath on Windows", () => {
|
||||
expect(resolveConfigOpenCommand(String.raw`C:\tmp\o'hai & calc.json`, "win32")).toEqual({
|
||||
expect(resolveOpenPathCommand(String.raw`C:\tmp\o'hai & calc.json`, "win32")).toEqual({
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-NoProfile",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
import { isBuiltInModelProviderOverlayId } from "../../config/zod-schema.core.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { isPlainObject } from "../../infra/plain-object.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import {
|
||||
prepareSecretsRuntimeSnapshot,
|
||||
type PreparedSecretsRuntimeSnapshot,
|
||||
@@ -62,6 +60,13 @@ import {
|
||||
resolveGatewayConfigPath,
|
||||
resolveGatewayConfigRestartWriteResult,
|
||||
} from "./config-write-flow.js";
|
||||
import {
|
||||
execOpenPath,
|
||||
formatOpenPathError,
|
||||
isHeadlessOpenPathError,
|
||||
resolveOpenPathCommand,
|
||||
sanitizePathForLog,
|
||||
} from "./open-path.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
@@ -73,10 +78,6 @@ let configSchemaResponseCache: {
|
||||
response: ConfigSchemaResponse;
|
||||
} | null = null;
|
||||
|
||||
type ConfigOpenCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
type ConfigRedactionHints = Parameters<typeof redactConfigObject>[1];
|
||||
type ConfigWriteCommitResult = Awaited<ReturnType<typeof commitGatewayConfigWrite>>;
|
||||
type ConfigRestartWriteKind = Parameters<typeof resolveGatewayConfigRestartWriteResult>[0]["kind"];
|
||||
@@ -348,56 +349,6 @@ function parseRawConfigOrRespond(
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
function sanitizeLookupPathForLog(path: string): string {
|
||||
const sanitized = Array.from(path, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code < 0x20 || code === 0x7f ? "?" : char;
|
||||
}).join("");
|
||||
return sanitized.length > 120 ? `${truncateUtf16Safe(sanitized, 117)}...` : sanitized;
|
||||
}
|
||||
|
||||
function escapePowerShellSingleQuotedString(value: string): string {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
export function resolveConfigOpenCommand(
|
||||
configPath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ConfigOpenCommand {
|
||||
if (platform === "win32") {
|
||||
// Use a PowerShell string literal so the path stays data, not code.
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`Start-Process -FilePath '${escapePowerShellSingleQuotedString(configPath)}'`,
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: platform === "darwin" ? "open" : "xdg-open",
|
||||
args: [configPath],
|
||||
};
|
||||
}
|
||||
|
||||
async function execConfigOpenCommand(command: ConfigOpenCommand): Promise<void> {
|
||||
await runExec(command.command, command.args, { logOutput: false });
|
||||
}
|
||||
|
||||
function formatConfigOpenError(error: unknown): string {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function hasOwnRecordValue(value: unknown, key: string): boolean {
|
||||
return isRecord(value) && Object.hasOwn(value, key);
|
||||
}
|
||||
@@ -715,7 +666,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
if (!validateConfigSchemaLookupResult(result)) {
|
||||
const errors = validateConfigSchemaLookupResult.errors ?? [];
|
||||
context.logGateway.warn(
|
||||
`config.schema.lookup produced invalid payload for ${sanitizeLookupPathForLog(path)}: ${formatValidationErrors(errors)}`,
|
||||
`config.schema.lookup produced invalid payload for ${sanitizePathForLog(path)}: ${formatValidationErrors(errors)}`,
|
||||
);
|
||||
respond(
|
||||
false,
|
||||
@@ -996,17 +947,16 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
const configPath = createConfigIO().configPath;
|
||||
try {
|
||||
await execConfigOpenCommand(resolveConfigOpenCommand(configPath));
|
||||
await execOpenPath(resolveOpenPathCommand(configPath));
|
||||
respond(true, { ok: true, path: configPath }, undefined);
|
||||
} catch (error) {
|
||||
const errorMessage = formatConfigOpenError(error);
|
||||
const isHeadlessError =
|
||||
errorMessage.includes("xdg-open") && errorMessage.includes("no method available");
|
||||
const errorMessage = formatOpenPathError(error);
|
||||
const isHeadlessError = isHeadlessOpenPathError(errorMessage);
|
||||
const detailedError = isHeadlessError
|
||||
? `Cannot open file in headless environment. File path: ${configPath}. This environment appears to lack a graphical or terminal browser handler.`
|
||||
: `Failed to open config file: ${errorMessage}`;
|
||||
context?.logGateway?.warn(
|
||||
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${errorMessage}`,
|
||||
`config.openFile failed path=${sanitizePathForLog(configPath)}: ${errorMessage}`,
|
||||
);
|
||||
respond(true, { ok: false, path: configPath, error: detailedError }, undefined);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
|
||||
type OpenPathCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
function escapePowerShellSingleQuotedString(value: string): string {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
export function resolveOpenPathCommand(
|
||||
targetPath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): OpenPathCommand {
|
||||
if (platform === "win32") {
|
||||
// Use a PowerShell string literal so the path stays data, not code.
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`Start-Process -FilePath '${escapePowerShellSingleQuotedString(targetPath)}'`,
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: platform === "darwin" ? "open" : "xdg-open",
|
||||
args: [targetPath],
|
||||
};
|
||||
}
|
||||
|
||||
export async function execOpenPath(command: OpenPathCommand): Promise<void> {
|
||||
await runExec(command.command, command.args, { logOutput: false });
|
||||
}
|
||||
|
||||
export function formatOpenPathError(error: unknown): string {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function isHeadlessOpenPathError(message: string): boolean {
|
||||
return message.includes("xdg-open") && message.includes("no method available");
|
||||
}
|
||||
|
||||
export function sanitizePathForLog(targetPath: string): string {
|
||||
const sanitized = Array.from(targetPath, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code < 0x20 || code === 0x7f ? "?" : char;
|
||||
}).join("");
|
||||
return sanitized.length > 120 ? `${truncateUtf16Safe(sanitized, 117)}...` : sanitized;
|
||||
}
|
||||
@@ -5,16 +5,23 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveOpenPathCommand } from "./open-path.js";
|
||||
import { sessionsFilesHandlers } from "./sessions-files.js";
|
||||
import { updateWorkspaceFile } from "./workspace-fs.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
execOpenPath: vi.fn(),
|
||||
loadSessionEntry: vi.fn(),
|
||||
resolveAgentWorkspaceDir: vi.fn(),
|
||||
resolveDefaultAgentId: vi.fn(),
|
||||
visitSessionMessagesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./open-path.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./open-path.js")>("./open-path.js");
|
||||
return { ...actual, execOpenPath: hoisted.execOpenPath };
|
||||
});
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentWorkspaceDir: hoisted.resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId: hoisted.resolveDefaultAgentId,
|
||||
@@ -48,11 +55,16 @@ function createResponder() {
|
||||
};
|
||||
}
|
||||
|
||||
type SessionFilesMethod = "sessions.files.list" | "sessions.files.get" | "sessions.files.set";
|
||||
type SessionFilesMethod =
|
||||
| "sessions.files.list"
|
||||
| "sessions.files.get"
|
||||
| "sessions.files.set"
|
||||
| "sessions.files.reveal";
|
||||
|
||||
async function invokeSessionFilesHandler(
|
||||
method: SessionFilesMethod,
|
||||
params: Record<string, unknown>,
|
||||
context: Record<string, unknown> = {},
|
||||
) {
|
||||
const responder = createResponder();
|
||||
await sessionsFilesHandlers[method]?.({
|
||||
@@ -61,7 +73,7 @@ async function invokeSessionFilesHandler(
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: responder.respond,
|
||||
context: {} as never,
|
||||
context: context as never,
|
||||
});
|
||||
return responder.calls;
|
||||
}
|
||||
@@ -110,6 +122,7 @@ describe("sessions.files RPC handlers", () => {
|
||||
workspaceRoot = fs.mkdtempSync(path.join(tempRoot, "openclaw-session-files-test-"));
|
||||
hoisted.resolveDefaultAgentId.mockReturnValue("main");
|
||||
hoisted.resolveAgentWorkspaceDir.mockReturnValue(workspaceRoot);
|
||||
hoisted.execOpenPath.mockResolvedValue(undefined);
|
||||
writeWorkspaceFile(workspaceRoot, "package.json", '{"name":"openclaw-test"}\n');
|
||||
writeWorkspaceFile(workspaceRoot, "src/readme.md", "# Read me\n");
|
||||
writeWorkspaceFile(workspaceRoot, "ui/chat.ts", "export const chat = true;\n");
|
||||
@@ -168,6 +181,105 @@ describe("sessions.files RPC handlers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reveals the same workspace root returned by sessions.files.list", async () => {
|
||||
const listPayload = expectOkPayload(
|
||||
await invokeSessionFilesHandler("sessions.files.list", {
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
const revealPayload = expectOkPayload(
|
||||
await invokeSessionFilesHandler("sessions.files.reveal", {
|
||||
key: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(revealPayload).toEqual({ ok: true, path: listPayload.root });
|
||||
// Compare against the resolver's own output so the assertion holds on
|
||||
// every supported platform (open / xdg-open / PowerShell Start-Process).
|
||||
expect(hoisted.execOpenPath).toHaveBeenCalledWith(
|
||||
resolveOpenPathCommand(listPayload.root as string),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to reveal a remote session workspace", async () => {
|
||||
const payload = expectOkPayload(
|
||||
await invokeSessionFilesHandler(
|
||||
"sessions.files.reveal",
|
||||
{ key: "agent:main:main" },
|
||||
{
|
||||
workerSessionPlacementService: {
|
||||
getMany: () => new Map([["sess-main", { state: "active" }]]),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(payload).toMatchObject({ ok: false, path: workspaceRoot });
|
||||
expect(payload.error).toContain("runs remotely");
|
||||
expect(hoisted.execOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to reveal an exec-node session workspace", async () => {
|
||||
hoisted.loadSessionEntry.mockReturnValue({
|
||||
canonicalKey: "agent:main:main",
|
||||
cfg: {},
|
||||
storePath: path.join(workspaceRoot, ".sessions.json"),
|
||||
entry: {
|
||||
sessionId: "sess-main",
|
||||
sessionFile: "sess-main.jsonl",
|
||||
spawnedCwd: workspaceRoot,
|
||||
execNode: "build-mac",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = expectOkPayload(
|
||||
await invokeSessionFilesHandler("sessions.files.reveal", { key: "agent:main:main" }),
|
||||
);
|
||||
|
||||
expect(payload).toMatchObject({ ok: false, path: workspaceRoot });
|
||||
expect(payload.error).toContain("exec node");
|
||||
expect(hoisted.execOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to reveal when the session has no workspace root", async () => {
|
||||
hoisted.resolveAgentWorkspaceDir.mockReturnValue(undefined);
|
||||
hoisted.loadSessionEntry.mockReturnValue({
|
||||
canonicalKey: "agent:main:main",
|
||||
cfg: {},
|
||||
storePath: path.join(workspaceRoot, ".sessions.json"),
|
||||
entry: { sessionId: "sess-main", sessionFile: "sess-main.jsonl" },
|
||||
});
|
||||
|
||||
const payload = expectOkPayload(
|
||||
await invokeSessionFilesHandler("sessions.files.reveal", { key: "agent:main:main" }),
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
ok: false,
|
||||
error: "No workspace root is available for this session.",
|
||||
});
|
||||
expect(hoisted.execOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns opener failures as successful RPC results", async () => {
|
||||
const warn = vi.fn();
|
||||
hoisted.execOpenPath.mockRejectedValueOnce(new Error("xdg-open: no method available"));
|
||||
|
||||
const payload = expectOkPayload(
|
||||
await invokeSessionFilesHandler(
|
||||
"sessions.files.reveal",
|
||||
{ key: "agent:main:main" },
|
||||
{ logGateway: { warn } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(payload).toMatchObject({ ok: false, path: workspaceRoot });
|
||||
expect(payload.error).toContain("headless environment");
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("sessions.files.reveal failed path="),
|
||||
);
|
||||
});
|
||||
|
||||
it("collects touched files from existing transcript tool-call spellings", async () => {
|
||||
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
|
||||
visit(
|
||||
|
||||
@@ -6,11 +6,13 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
isCloudWorkerPlacementState,
|
||||
type SessionFileBrowserEntry,
|
||||
type SessionFileBrowserResult,
|
||||
type SessionFileEntry,
|
||||
type SessionFileRelevance,
|
||||
type SessionsFilesGetParams,
|
||||
validateSessionsFilesRevealParams,
|
||||
validateSessionsFilesGetParams,
|
||||
validateSessionsFilesListParams,
|
||||
validateSessionsFilesSetParams,
|
||||
@@ -21,6 +23,13 @@ import { FsSafeError } from "../../infra/fs-safe.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { visitSessionMessagesAsync } from "../session-transcript-readers.js";
|
||||
import { loadSessionEntry } from "../session-utils.js";
|
||||
import {
|
||||
execOpenPath,
|
||||
formatOpenPathError,
|
||||
isHeadlessOpenPathError,
|
||||
resolveOpenPathCommand,
|
||||
sanitizePathForLog,
|
||||
} from "./open-path.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
import {
|
||||
@@ -777,5 +786,60 @@ export const sessionsFilesHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
});
|
||||
},
|
||||
"sessions.files.reveal": async ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateSessionsFilesRevealParams,
|
||||
"sessions.files.reveal",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const loaded = loadSessionFileRoot({ sessionKey: params.key, agentId: params.agentId });
|
||||
const workspaceRoot = loaded.root;
|
||||
if (!workspaceRoot) {
|
||||
respond(true, {
|
||||
ok: false,
|
||||
error: "No workspace root is available for this session.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (loaded.entry?.execNode) {
|
||||
respond(true, {
|
||||
ok: false,
|
||||
path: workspaceRoot,
|
||||
error: "Cannot reveal this workspace because the session runs on an exec node.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const placement = loaded.entry?.sessionId
|
||||
? context.workerSessionPlacementService
|
||||
?.getMany([loaded.entry.sessionId])
|
||||
.get(loaded.entry.sessionId)
|
||||
: undefined;
|
||||
if (isCloudWorkerPlacementState(placement?.state)) {
|
||||
respond(true, {
|
||||
ok: false,
|
||||
path: workspaceRoot,
|
||||
error: `Cannot reveal this workspace because the session runs remotely (${placement.state}).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await execOpenPath(resolveOpenPathCommand(workspaceRoot));
|
||||
respond(true, { ok: true, path: workspaceRoot });
|
||||
} catch (error) {
|
||||
const errorMessage = formatOpenPathError(error);
|
||||
const detailedError = isHeadlessOpenPathError(errorMessage)
|
||||
? `Cannot open path in headless environment. Path: ${workspaceRoot}. This environment appears to lack a graphical or terminal browser handler.`
|
||||
: `Failed to reveal session workspace: ${errorMessage}`;
|
||||
context.logGateway.warn(
|
||||
`sessions.files.reveal failed path=${sanitizePathForLog(workspaceRoot)}: ${errorMessage}`,
|
||||
);
|
||||
respond(true, { ok: false, path: workspaceRoot, error: detailedError });
|
||||
}
|
||||
},
|
||||
};
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -480,6 +480,9 @@ export type GatewaySessionRow = {
|
||||
worktree?: { id: string; branch: string; repoRoot: string };
|
||||
/** Session-scoped exec node binding (exec host=node routing). */
|
||||
execNode?: string;
|
||||
spawnedWorkspaceDir?: string;
|
||||
spawnedCwd?: string;
|
||||
execCwd?: string;
|
||||
placement?: import("../../../packages/gateway-protocol/src/index.js").SessionPlacement;
|
||||
kind: "cron" | "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { html, nothing } from "lit";
|
||||
// Deep import on purpose: the protocol barrel carries typebox and every
|
||||
// schema, which must stay out of the Control UI startup bundle.
|
||||
import { isCloudWorkerPlacementState } from "../../../packages/gateway-protocol/src/schema/session-placement-state.js";
|
||||
import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
|
||||
export type SessionPlacementState = NonNullable<GatewaySessionRow["placement"]>["state"];
|
||||
|
||||
function isCloudWorkerPlacementState(
|
||||
state: SessionPlacementState | undefined,
|
||||
): state is Exclude<SessionPlacementState, "local" | "reclaimed"> {
|
||||
return state !== undefined && state !== "local" && state !== "reclaimed";
|
||||
}
|
||||
export { isCloudWorkerPlacementState } from "../../../packages/gateway-protocol/src/schema/session-placement-state.js";
|
||||
|
||||
export function isStoppableCloudWorkerPlacement(
|
||||
placement: GatewaySessionRow["placement"],
|
||||
|
||||
@@ -137,7 +137,7 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
await expect.poll(() => settings.isVisible()).toBe(true);
|
||||
await expect.poll(() => splitView.isVisible()).toBe(true);
|
||||
await expect
|
||||
.poll(() => splitView.evaluate((node) => node.closest(".chat-floating-toggles") != null))
|
||||
.poll(() => splitView.evaluate((node) => node.closest(".chat-pane__header") != null))
|
||||
.toBe(true);
|
||||
await expect.poll(() => attach.isVisible()).toBe(true);
|
||||
await expect.poll(() => camera.isVisible()).toBe(false);
|
||||
@@ -423,19 +423,15 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
expect(activeModelBox.x).toBeGreaterThanOrEqual(
|
||||
activeSettingsBox.x + activeSettingsBox.width - 1,
|
||||
);
|
||||
// The opener lives in the floating toggle cluster pinned to the
|
||||
// top-right corner of the chat area. The cluster's right edge hugs the
|
||||
// corner; the opener itself is the leftmost button in the row.
|
||||
const toggleClusterBox = await page.locator(".chat-floating-toggles").boundingBox();
|
||||
expect(toggleClusterBox).not.toBeNull();
|
||||
if (!toggleClusterBox) {
|
||||
throw new Error("expected the floating toggle cluster to have a layout box");
|
||||
// The opener lives in the always-on pane header at the chat area's top edge.
|
||||
const headerBox = await page.locator(".chat-pane__header").boundingBox();
|
||||
expect(headerBox).not.toBeNull();
|
||||
if (!headerBox) {
|
||||
throw new Error("expected the pane header to have a layout box");
|
||||
}
|
||||
expect(
|
||||
Math.abs(
|
||||
activeChatContentBox.x +
|
||||
activeChatContentBox.width -
|
||||
(toggleClusterBox.x + toggleClusterBox.width),
|
||||
activeChatContentBox.x + activeChatContentBox.width - (headerBox.x + headerBox.width),
|
||||
),
|
||||
).toBeLessThanOrEqual(24);
|
||||
expect(Math.abs(activeSplitViewBox.y - activeChatContentBox.y)).toBeLessThanOrEqual(24);
|
||||
|
||||
@@ -271,7 +271,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await closeOpenBrowserContexts();
|
||||
});
|
||||
|
||||
it("renders per-pane headers in split view without desktop topbar chrome", async () => {
|
||||
it("renders always-on pane headers without desktop topbar chrome", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
@@ -299,6 +299,22 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
|
||||
const splitEntry = page.getByRole("button", { name: "Open split view" });
|
||||
await expect.poll(() => splitEntry.isVisible()).toBe(true);
|
||||
await expect.poll(() => page.locator(".chat-pane__header").count()).toBe(1);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add("openclaw-native-macos");
|
||||
document.querySelector(".shell")?.classList.add("shell--nav-collapsed");
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator(".chat-pane__header")
|
||||
.evaluate((header) => getComputedStyle(header).paddingLeft),
|
||||
)
|
||||
.toBe("90px");
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.remove("openclaw-native-macos");
|
||||
document.querySelector(".shell")?.classList.remove("shell--nav-collapsed");
|
||||
});
|
||||
await page.setViewportSize({ height: 900, width: 1100 });
|
||||
await expect.poll(() => splitEntry.isVisible()).toBe(true);
|
||||
await page.setViewportSize({ height: 900, width: 1440 });
|
||||
@@ -321,8 +337,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
.poll(async () => (await gateway.getRequests("chat.startup")).length)
|
||||
.toBeGreaterThan(startupRequestsBeforeSplit);
|
||||
|
||||
// Each pane owns an in-flow header (title + workspace/split/close
|
||||
// actions); no fixed toolbar layer mirrors the split geometry.
|
||||
// Each pane owns the same in-flow header in classic and split layouts.
|
||||
const panes = page.locator("openclaw-chat-pane.chat-split-view__pane");
|
||||
const headers = page.locator(".chat-pane__header");
|
||||
await expect.poll(() => panes.count()).toBe(2);
|
||||
@@ -354,8 +369,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await expect.poll(() => headers.first().locator(".chat-workspace-toggle").count()).toBe(1);
|
||||
await expect.poll(() => page.locator(".chat-workspace-rail").count()).toBe(0);
|
||||
|
||||
// Pane headers render a static session title; keyboard focus lands on
|
||||
// the pane buttons and marks the pane active.
|
||||
// Keyboard focus on a header action marks the pane active.
|
||||
await headers.first().getByRole("button", { name: "Split down" }).focus();
|
||||
await expect.poll(() => headers.first().getAttribute("class")).toContain("--active");
|
||||
|
||||
@@ -418,7 +432,11 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
|
||||
await expect.poll(() => panes.count()).toBe(3);
|
||||
await expect
|
||||
.poll(() => page.locator(".chat-pane__session-title").allTextContents())
|
||||
.poll(async () =>
|
||||
(await page.locator(".chat-pane__session-title").allTextContents()).map((title) =>
|
||||
title.trim(),
|
||||
),
|
||||
)
|
||||
.toContain("Session B");
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("session"))
|
||||
@@ -1330,18 +1348,16 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
// Collapsed rails render nothing; the floating opener (with the
|
||||
// changed-file badge) is the only pointer affordance.
|
||||
// Collapsed rails render nothing; the title-bar toggle carries the
|
||||
// changed-file badge.
|
||||
const opener = page.locator(".chat-workspace-toggle");
|
||||
await opener.waitFor({ timeout: 10_000 });
|
||||
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(0);
|
||||
expect(await page.locator(".chat-workspace-rail").count()).toBe(0);
|
||||
|
||||
await opener.click();
|
||||
await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await opener.count()).toBe(0);
|
||||
await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 });
|
||||
await expect.poll(() => opener.getAttribute("aria-expanded")).toBe("true");
|
||||
await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
@@ -1361,14 +1377,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
await page.getByRole("button", { name: "Collapse session workspace" }).click();
|
||||
await page.locator(".chat-workspace-rail__collapse-toggle").click();
|
||||
await opener.waitFor({ timeout: 10_000 });
|
||||
expect(await page.locator(".chat-workspace-rail").count()).toBe(0);
|
||||
|
||||
await opener.click();
|
||||
await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 });
|
||||
await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
@@ -3466,6 +3466,19 @@ export const en: TranslationMap = {
|
||||
disconnected: "Disconnected from gateway.",
|
||||
archivedSessionDisabled: "Restore this session to send messages.",
|
||||
loadOlder: "Load older",
|
||||
sessionHeader: {
|
||||
renameTooltip: "Rename session",
|
||||
renameAria: "Rename session {title}",
|
||||
renameInputAria: "Session title",
|
||||
renameInputPlaceholder: "Session title",
|
||||
workspaceAria: "Workspace actions for {workspace}",
|
||||
revealFinder: "Reveal in Finder",
|
||||
revealFileExplorer: "Reveal in File Explorer",
|
||||
revealFileManager: "Open in file manager",
|
||||
copyPath: "Copy path",
|
||||
copyBranch: "Copy branch name",
|
||||
copied: "Copied",
|
||||
},
|
||||
catalog: {
|
||||
remoteViewOnly: "This session is on a paired node and is view-only.",
|
||||
unsupportedViewOnly: "This external session source is view-only.",
|
||||
|
||||
@@ -23,7 +23,6 @@ type RenderedPane = HTMLElement & {
|
||||
chatMessagesBySession: ChatMessageCache;
|
||||
sessionKey: string;
|
||||
active: boolean;
|
||||
showPaneHeader: boolean;
|
||||
paneTitle: string;
|
||||
narrow: boolean;
|
||||
onOpenSplitView?: () => void;
|
||||
@@ -142,13 +141,11 @@ describe("chat page split layout host", () => {
|
||||
expect(itemAt(panes, 0, "rendered pane").paneId).toBe("p1");
|
||||
expect(itemAt(panes, 0, "rendered pane").sessionKey).toBe("main");
|
||||
expect(itemAt(panes, 0, "rendered pane").active).toBe(true);
|
||||
expect(itemAt(panes, 0, "rendered pane").showPaneHeader).toBe(false);
|
||||
expect(itemAt(panes, 0, "rendered pane").classList.contains("chat-split-view__pane")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(page.querySelector("resizable-divider")).toBeNull();
|
||||
// The pane renders the opener in its floating toggle cluster; the page
|
||||
// only hands down the callback on wide single-pane layouts.
|
||||
// The always-on pane header owns the classic split-view opener.
|
||||
expect(typeof itemAt(panes, 0, "rendered pane").onOpenSplitView).toBe("function");
|
||||
});
|
||||
|
||||
@@ -180,7 +177,6 @@ describe("chat page split layout host", () => {
|
||||
"surviving pane",
|
||||
);
|
||||
expect(survivingPane).toBe(classicPane);
|
||||
expect(survivingPane.showPaneHeader).toBe(false);
|
||||
expect(survivingPane.classList.contains("chat-split-view__pane")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -244,13 +240,15 @@ describe("chat page split layout host", () => {
|
||||
expect(getLayout(page)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withholds the split-view opener on narrow single-pane viewports", async () => {
|
||||
it("withholds the header split-view opener on narrow single-pane viewports", async () => {
|
||||
stubMatchMedia(true);
|
||||
const page = new ChatPage();
|
||||
page.data = { sessionKey: "main" };
|
||||
document.body.append(page);
|
||||
await page.updateComplete;
|
||||
|
||||
// Narrow split view renders only the active pane, so offering the opener
|
||||
// there would silently hide the second pane it creates.
|
||||
const pane = page.querySelector<RenderedPane>("openclaw-chat-pane");
|
||||
expect(pane?.onOpenSplitView).toBeUndefined();
|
||||
});
|
||||
@@ -301,7 +299,6 @@ describe("chat page split layout host", () => {
|
||||
.querySelector(".chat-split-view__cell--active")
|
||||
?.contains(itemAt(panes, 1, "rendered pane")),
|
||||
).toBe(true);
|
||||
expect(panes.map((pane) => pane.showPaneHeader)).toEqual([true, true]);
|
||||
expect(panes.every((pane) => pane.onOpenSplitView === undefined)).toBe(true);
|
||||
expect(panes[0]?.chatMessagesBySession).toBe(panes[1]?.chatMessagesBySession);
|
||||
});
|
||||
@@ -317,7 +314,6 @@ describe("chat page split layout host", () => {
|
||||
const panes = [...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")];
|
||||
expect(panes.map((pane) => pane.paneId)).toEqual(["p2"]);
|
||||
expect(itemAt(panes, 0, "rendered pane").active).toBe(true);
|
||||
expect(itemAt(panes, 0, "rendered pane").showPaneHeader).toBe(true);
|
||||
expect(itemAt(panes, 0, "rendered pane").narrow).toBe(true);
|
||||
expect(page.querySelector("resizable-divider")).toBeNull();
|
||||
});
|
||||
@@ -379,8 +375,24 @@ describe("chat page split layout host", () => {
|
||||
[...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")].map((pane) => pane.paneTitle);
|
||||
expect(paneTitles()).toEqual(["Main Session", "Main Session"]);
|
||||
|
||||
// Rows arrive under the canonical agent key while the route still says
|
||||
// "main"; hello-default resolution plus equivalence matching must find
|
||||
// the label anyway — including non-default agent ids.
|
||||
(page as unknown as { context: { gateway?: unknown; sessions: unknown } }).context.gateway = {
|
||||
snapshot: {
|
||||
hello: {
|
||||
snapshot: {
|
||||
sessionDefaults: {
|
||||
defaultAgentId: "dev",
|
||||
mainKey: "main",
|
||||
mainSessionKey: "agent:dev:main",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
sessionsState.result = {
|
||||
sessions: [{ key: "main", displayName: "Main desk" }],
|
||||
sessions: [{ key: "agent:dev:main", displayName: "Main desk" }],
|
||||
};
|
||||
notify();
|
||||
await page.updateComplete;
|
||||
|
||||
@@ -10,7 +10,8 @@ import { UI_COMMAND_EVENT, type UiCommandDetail } from "../../components/panel-t
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
|
||||
import { readSessionDragData, sessionDragActive } from "../../lib/sessions/drag.ts";
|
||||
import { searchForSession } from "../../lib/sessions/index.ts";
|
||||
import { resolveSessionKey, searchForSession } from "../../lib/sessions/index.ts";
|
||||
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import "../../styles/chat.css";
|
||||
@@ -493,9 +494,14 @@ export class ChatPage extends OpenClawLightDomElement {
|
||||
* own header so the workspace toggle can read per-pane workspace state. */
|
||||
private renderPaneCell(pane: ChatSplitPane, active: boolean, weight: number, splitMode: boolean) {
|
||||
const sessions = this.context?.sessions?.state.result?.sessions ?? [];
|
||||
// Route keys can be unresolved aliases ("main"); resolve against the
|
||||
// hello defaults and match rows by equivalence like the pane itself
|
||||
// does, or renamed sessions fall back to the generic key-derived title.
|
||||
const resolvedKey =
|
||||
resolveSessionKey(pane.sessionKey, this.context?.gateway?.snapshot?.hello) || pane.sessionKey;
|
||||
const title = resolveSessionDisplayName(
|
||||
pane.sessionKey,
|
||||
sessions.find((row) => row.key === pane.sessionKey),
|
||||
resolvedKey,
|
||||
sessions.find((row) => areUiSessionKeysEquivalent(row.key, resolvedKey)),
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
@@ -511,7 +517,6 @@ export class ChatPage extends OpenClawLightDomElement {
|
||||
.sessionKey=${pane.sessionKey}
|
||||
.active=${active}
|
||||
.draft=${active ? this.routeDraftForActivePane(pane.sessionKey) : undefined}
|
||||
.showPaneHeader=${splitMode}
|
||||
.paneTitle=${title}
|
||||
.narrow=${this.narrow}
|
||||
.onOpenSplitView=${splitMode || this.narrow ? undefined : this.openSplitView}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TaskSuggestionsListResult,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { buildCatalogSessionKey, type CatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
@@ -47,6 +48,7 @@ type TestChatPane = HTMLElement & {
|
||||
taskSuggestions: TaskSuggestion[];
|
||||
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
||||
sessionKey: string;
|
||||
paneTitle: string;
|
||||
catalogSession: SessionCatalogSession | null;
|
||||
catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record<string, unknown> | null;
|
||||
handleTranscriptScroll: (event: Event) => void;
|
||||
@@ -64,9 +66,30 @@ type TestChatPane = HTMLElement & {
|
||||
catalogCursor: string | undefined;
|
||||
olderCursorsSeen: Set<string>;
|
||||
olderOffsetsSeen: Set<number>;
|
||||
headerEditing: boolean;
|
||||
headerRenameValue: string;
|
||||
beginHeaderRename: (row: GatewaySessionRow) => void;
|
||||
cancelHeaderRename: () => void;
|
||||
commitHeaderRename: () => void;
|
||||
handleHeaderMenuAction: (
|
||||
action: "reveal" | "copy-path" | "copy-branch",
|
||||
row: GatewaySessionRow,
|
||||
workspaceRoot: string | null,
|
||||
branch: string | null,
|
||||
copy?: (value: string) => Promise<boolean>,
|
||||
) => void;
|
||||
loadHeaderMenuData: (
|
||||
row: GatewaySessionRow,
|
||||
agentWorkspace: string | undefined,
|
||||
workspaceGit: boolean,
|
||||
) => Promise<void>;
|
||||
renderPaneHeader: (
|
||||
workspace: ReturnType<typeof createSessionWorkspaceProps>,
|
||||
tasks: ReturnType<typeof createBackgroundTasksProps>,
|
||||
row: undefined,
|
||||
catalog: boolean,
|
||||
agentWorkspace: undefined,
|
||||
workspaceGit: boolean,
|
||||
) => TemplateResult;
|
||||
};
|
||||
|
||||
@@ -196,6 +219,237 @@ function nativeHistoryMessage(seq: number, text = `message ${seq}`) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("chat pane header state", () => {
|
||||
it("commits a trimmed label and clears with null", async () => {
|
||||
const patch = vi.fn(async () => ({}));
|
||||
const sessions = { patch } as unknown as SessionCapability;
|
||||
const { pane } = createTestChatPane({ client: {} as GatewayBrowserClient, sessions });
|
||||
const session = {
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
pane.beginHeaderRename(session);
|
||||
pane.headerRenameValue = " Renamed session ";
|
||||
pane.commitHeaderRename();
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
session.key,
|
||||
{ label: "Renamed session" },
|
||||
{ agentId: "main" },
|
||||
);
|
||||
|
||||
const labeled = { ...session, label: "Renamed session" };
|
||||
pane.beginHeaderRename(labeled);
|
||||
pane.headerRenameValue = " ";
|
||||
pane.commitHeaderRename();
|
||||
expect(patch).toHaveBeenLastCalledWith(session.key, { label: null }, { agentId: "main" });
|
||||
});
|
||||
|
||||
it("cancels and skips unchanged labels", () => {
|
||||
const patch = vi.fn(async () => ({}));
|
||||
const sessions = { patch } as unknown as SessionCapability;
|
||||
const { pane } = createTestChatPane({ client: {} as GatewayBrowserClient, sessions });
|
||||
pane.paneTitle = "Derived title";
|
||||
const session = {
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
pane.beginHeaderRename(session);
|
||||
pane.commitHeaderRename();
|
||||
pane.beginHeaderRename(session);
|
||||
pane.cancelHeaderRename();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies the resolved workspace path and branch", async () => {
|
||||
const { pane } = createTestChatPane({
|
||||
client: {} as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const session = {
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
const copy = vi.fn(async () => true);
|
||||
pane.handleHeaderMenuAction("copy-path", session, "/src/openclaw", "feature/header", copy);
|
||||
pane.handleHeaderMenuAction("copy-branch", session, "/src/openclaw", "feature/header", copy);
|
||||
await Promise.resolve();
|
||||
expect(copy).toHaveBeenNthCalledWith(1, "/src/openclaw");
|
||||
expect(copy).toHaveBeenNthCalledWith(2, "feature/header");
|
||||
});
|
||||
|
||||
it("does not query gateway-local branches for exec-node sessions", async () => {
|
||||
const request = vi.fn();
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
await pane.loadHeaderMenuData(
|
||||
{
|
||||
key: "agent:main:remote",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
execNode: "build-mac",
|
||||
execCwd: "/remote/repo",
|
||||
},
|
||||
"/local/default",
|
||||
true,
|
||||
);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries failed worktree metadata lookups on the next menu open", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||
.mockResolvedValueOnce({
|
||||
worktrees: [{ id: "wt-1", path: "/src/worktree" }],
|
||||
});
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const session = {
|
||||
key: "agent:main:worktree",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
worktree: { id: "wt-1", branch: "feature", repoRoot: "/src/openclaw" },
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(session, "/src/default", true);
|
||||
await pane.loadHeaderMenuData(session, "/src/default", true);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries failed branch metadata lookups on the next menu open", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||
.mockResolvedValueOnce({ headBranch: "feature/header" });
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const session = {
|
||||
key: "agent:main:plain",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(session, "/src/openclaw", true);
|
||||
await pane.loadHeaderMenuData(session, "/src/openclaw", true);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("probes session-specific roots for a branch even when the agent workspace is not Git", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ headBranch: "spawned/topic" });
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const session = {
|
||||
key: "agent:main:spawned",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
spawnedWorkspaceDir: "/src/spawned-repo",
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(session, "/plain/agent-workspace", false);
|
||||
expect(request).toHaveBeenCalledWith("worktrees.branches", { repoRoot: "/src/spawned-repo" });
|
||||
|
||||
// The agent-workspace root keeps honoring the agent's workspaceGit flag.
|
||||
request.mockClear();
|
||||
const plain = {
|
||||
key: "agent:main:plain2",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(plain, "/plain/agent-workspace", false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse worktree workspace facts after an in-place session reset", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "worktrees.list") {
|
||||
return { worktrees: [{ id: "wt-1", path: "/src/worktree-checkout" }] };
|
||||
}
|
||||
return { headBranch: "main" };
|
||||
});
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const worktreeRow = {
|
||||
key: "agent:main:reused",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
worktree: { id: "wt-1", branch: "feature", repoRoot: "/src/openclaw" },
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(worktreeRow, "/src/agent-workspace", true);
|
||||
|
||||
// New Chat resets the same key in place and detaches the worktree; the
|
||||
// branch probe must target the agent workspace, not the stale checkout.
|
||||
const resetRow = {
|
||||
key: "agent:main:reused",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(resetRow, "/src/agent-workspace", true);
|
||||
expect(request).toHaveBeenLastCalledWith("worktrees.branches", {
|
||||
repoRoot: "/src/agent-workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips branch lookups while the session runs remotely", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ headBranch: "main" });
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const dispatched = {
|
||||
key: "agent:main:moves",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
placement: { state: "active" } as GatewaySessionRow["placement"],
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(dispatched, "/src/openclaw", true);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes the head branch on every menu open so checkouts do not go stale", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ headBranch: "main" })
|
||||
.mockResolvedValueOnce({ headBranch: "feature/next" });
|
||||
const { pane } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const session = {
|
||||
key: "agent:main:plain",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
await pane.loadHeaderMenuData(session, "/src/openclaw", true);
|
||||
await pane.loadHeaderMenuData(session, "/src/openclaw", true);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces resolved reveal failures in the chat error", async () => {
|
||||
const request = vi.fn(async () => ({ ok: false, error: "No desktop available." }));
|
||||
const { pane, state } = createTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions: {} as SessionCapability,
|
||||
});
|
||||
const session = {
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
} satisfies GatewaySessionRow;
|
||||
pane.handleHeaderMenuAction("reveal", session, "/src/openclaw", null);
|
||||
await vi.waitFor(() => expect(state.chatError).toBe("No desktop available."));
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat pane initialization", () => {
|
||||
it("sets the pane route before attaching outbox projection", () => {
|
||||
const pane = document.createElement("openclaw-chat-pane") as unknown as TestChatPane;
|
||||
@@ -454,6 +708,10 @@ describe("chat pane catalog session lifecycle", () => {
|
||||
pane.renderPaneHeader(
|
||||
createSessionWorkspaceProps(state),
|
||||
createBackgroundTasksProps(state, { onOpenSession: () => {} }),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
false,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
+336
-65
@@ -8,10 +8,14 @@ import type {
|
||||
SessionCatalogTranscriptItem,
|
||||
SessionsCatalogContinueResult,
|
||||
SessionsCatalogReadResult,
|
||||
SessionsFilesRevealResult,
|
||||
SystemInfoResult,
|
||||
TaskSuggestion,
|
||||
TaskSuggestionEvent,
|
||||
TaskSuggestionsAcceptResult,
|
||||
TaskSuggestionsListResult,
|
||||
WorktreesBranchesResult,
|
||||
WorktreesListResult,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
ControlUiSessionBranch,
|
||||
@@ -25,7 +29,6 @@ import {
|
||||
type ApplicationContext,
|
||||
type ApplicationGatewaySnapshot,
|
||||
} from "../../app/context.ts";
|
||||
import { beginNativeWindowDrag } from "../../app/native-window-drag.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import {
|
||||
BROWSER_ANNOTATION_EVENT,
|
||||
@@ -35,10 +38,10 @@ import {
|
||||
COMMAND_PALETTE_TARGET_EVENT,
|
||||
type CommandPaletteTargetDetail,
|
||||
} from "../../components/command-palette-contract.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
|
||||
import { copyToClipboard } from "../../lib/clipboard.ts";
|
||||
import { clampText } from "../../lib/format.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
|
||||
@@ -108,6 +111,12 @@ import {
|
||||
type BackgroundTasksProps,
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import { renderChatControls } from "./components/chat-controls.ts";
|
||||
import {
|
||||
canRevealSessionWorkspace,
|
||||
renderChatPaneHeader,
|
||||
resolveChatPaneWorkspace,
|
||||
type ChatPaneHeaderAction,
|
||||
} from "./components/chat-pane-header.ts";
|
||||
import {
|
||||
chatPullRequestId,
|
||||
createPullRequestBranch,
|
||||
@@ -156,6 +165,7 @@ const CHAT_HISTORY_INTENT_EDGE_PX = 300;
|
||||
const CHAT_HISTORY_INTENT_IDLE_MS = 200;
|
||||
const CHAT_HISTORY_TOUCH_INTENT_PX = 8;
|
||||
const CHAT_HISTORY_UPWARD_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
|
||||
const headerPlatformByClient = new WeakMap<GatewayBrowserClient, Promise<string | null>>();
|
||||
|
||||
function catalogRawString(raw: unknown, keys: readonly string[]): string | null {
|
||||
const record = catalogRawRecord(raw);
|
||||
@@ -272,9 +282,6 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
nextSessionKey: string,
|
||||
options?: PaneSessionChangeOptions,
|
||||
) => void;
|
||||
/** Split mode renders an in-pane header row (title + workspace/split/close
|
||||
* controls); classic single-pane mode renders none. */
|
||||
@property({ attribute: false }) showPaneHeader = false;
|
||||
@property({ attribute: false }) paneTitle = "";
|
||||
@property({ attribute: false }) narrow = false;
|
||||
@property({ attribute: false }) onOpenSplitView?: () => void;
|
||||
@@ -291,6 +298,23 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
private paneResizeObserver: ResizeObserver | null = null;
|
||||
private connectedClient: GatewayBrowserClient | null = null;
|
||||
private connectionGeneration = 0;
|
||||
@litState() private headerEditing = false;
|
||||
@litState() private headerRenameValue = "";
|
||||
@litState() private headerPlatform: string | null = null;
|
||||
@litState() private headerCopiedAction: ChatPaneHeaderAction | null = null;
|
||||
private headerRenameInitialLabel: string | null = null;
|
||||
private headerRenameInitialValue = "";
|
||||
private headerRenameSessionKey = "";
|
||||
private headerCopiedTimer: number | null = null;
|
||||
/** Checkout paths keyed by worktree id — stable for a worktree's lifetime,
|
||||
* so reused session keys can never inherit another checkout's path. */
|
||||
private readonly headerWorktreePaths = new Map<
|
||||
string,
|
||||
{ loaded?: boolean; loading?: boolean; path?: string | null }
|
||||
>();
|
||||
/** HEAD keyed by the resolved root directory it was read from — a branch is
|
||||
* a fact about a checkout, so root transitions miss instead of going stale. */
|
||||
private readonly headerBranches = new Map<string, { loading?: boolean; value?: string | null }>();
|
||||
private nativeDraftCleanup: (() => void) | null = null;
|
||||
private readonly unreadPatchGuard = new SessionUnreadPatchGuard();
|
||||
private taskSuggestions: TaskSuggestion[] = [];
|
||||
@@ -652,6 +676,9 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const previousSessionKey = state.sessionKey;
|
||||
// An in-progress title edit belongs to the previous session; committing
|
||||
// it against the newly routed row would rename the wrong session.
|
||||
this.cancelHeaderRename();
|
||||
this.resetOlderMessagesViewport();
|
||||
const catalogKey = parseCatalogSessionKey(nextSessionKey);
|
||||
const previousSessionsResult = state.sessionsResult;
|
||||
@@ -1640,6 +1667,12 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.resetOlderMessagesViewport();
|
||||
this.nativeDraftCleanup?.();
|
||||
this.nativeDraftCleanup = null;
|
||||
if (this.headerCopiedTimer !== null) {
|
||||
window.clearTimeout(this.headerCopiedTimer);
|
||||
this.headerCopiedTimer = null;
|
||||
}
|
||||
this.headerWorktreePaths.clear();
|
||||
this.headerBranches.clear();
|
||||
this.announceCommandPaletteTarget(null);
|
||||
resetChatViewState(this.paneId);
|
||||
this.state = undefined;
|
||||
@@ -1834,6 +1867,10 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
}
|
||||
};
|
||||
this.connectedClient = startupClient;
|
||||
this.headerWorktreePaths.clear();
|
||||
this.headerBranches.clear();
|
||||
this.headerPlatform = null;
|
||||
void this.loadHeaderPlatform(startupClient, startupGeneration);
|
||||
if (catalogRouteKey) {
|
||||
void this.loadCatalogSession(catalogRouteKey, false);
|
||||
state.requestUpdate?.();
|
||||
@@ -1852,61 +1889,292 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
state.requestUpdate?.();
|
||||
}
|
||||
|
||||
private async loadHeaderPlatform(
|
||||
client: GatewayBrowserClient,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
if (!isGatewayMethodAdvertised(this.context.gateway.snapshot, "system.info")) {
|
||||
return;
|
||||
}
|
||||
let platformRequest = headerPlatformByClient.get(client);
|
||||
if (!platformRequest) {
|
||||
platformRequest = client
|
||||
.request<SystemInfoResult>("system.info", {})
|
||||
.then((result) => result.platform)
|
||||
.catch(() => null);
|
||||
headerPlatformByClient.set(client, platformRequest);
|
||||
}
|
||||
try {
|
||||
const platform = await platformRequest;
|
||||
if (this.connectedClient === client && this.connectionGeneration === generation) {
|
||||
this.headerPlatform = platform;
|
||||
}
|
||||
} catch {
|
||||
// Optional label refinement. Generic file-manager copy remains correct.
|
||||
}
|
||||
}
|
||||
|
||||
private beginHeaderRename(row: GatewaySessionRow): void {
|
||||
const customLabel = row.label?.trim() || null;
|
||||
this.headerRenameSessionKey = row.key;
|
||||
this.headerRenameInitialLabel = customLabel;
|
||||
this.headerRenameInitialValue = customLabel ?? this.paneTitle;
|
||||
this.headerRenameValue = this.headerRenameInitialValue;
|
||||
this.headerEditing = true;
|
||||
void this.updateComplete.then(() => {
|
||||
const input = this.querySelector<HTMLInputElement>(".chat-pane__session-title-input");
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
private cancelHeaderRename(): void {
|
||||
this.headerEditing = false;
|
||||
this.headerRenameSessionKey = "";
|
||||
}
|
||||
|
||||
private commitHeaderRename(): void {
|
||||
if (!this.headerEditing) {
|
||||
return;
|
||||
}
|
||||
const key = this.headerRenameSessionKey;
|
||||
const trimmed = this.headerRenameValue.trim();
|
||||
const label = trimmed || null;
|
||||
const unchangedDerivedTitle =
|
||||
this.headerRenameInitialLabel === null && trimmed === this.headerRenameInitialValue.trim();
|
||||
const unchangedLabel = label === this.headerRenameInitialLabel;
|
||||
this.headerEditing = false;
|
||||
this.headerRenameSessionKey = "";
|
||||
if (!key || unchangedDerivedTitle || unchangedLabel) {
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(key)?.agentId;
|
||||
void this.context.sessions
|
||||
.patch(key, { label }, agentId ? { agentId } : undefined)
|
||||
.catch((error: unknown) => this.publishHeaderError(error));
|
||||
}
|
||||
|
||||
private async loadHeaderMenuData(
|
||||
row: GatewaySessionRow,
|
||||
agentWorkspace: string | undefined,
|
||||
workspaceGit: boolean,
|
||||
): Promise<void> {
|
||||
const client = this.connectedClient;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const loads: Promise<void>[] = [];
|
||||
// Same precedence as resolveChatPaneWorkspace/loadSessionFileRoot.
|
||||
const immediateRoot =
|
||||
(row.execNode ? row.execCwd?.trim() : undefined) ||
|
||||
row.spawnedWorkspaceDir?.trim() ||
|
||||
row.spawnedCwd?.trim() ||
|
||||
null;
|
||||
const worktreeId = row.worktree?.id;
|
||||
if (worktreeId && !immediateRoot) {
|
||||
const entry = this.headerWorktreePaths.get(worktreeId) ?? {};
|
||||
this.headerWorktreePaths.set(worktreeId, entry);
|
||||
if (!entry.loaded && !entry.loading) {
|
||||
entry.loading = true;
|
||||
loads.push(
|
||||
client
|
||||
.request<WorktreesListResult>("worktrees.list", {})
|
||||
.then((result) => {
|
||||
entry.path =
|
||||
result.worktrees.find(
|
||||
(candidate) => candidate.id === worktreeId && candidate.removedAt === undefined,
|
||||
)?.path ?? null;
|
||||
entry.loaded = true;
|
||||
})
|
||||
.catch(() => {
|
||||
entry.path = null;
|
||||
entry.loaded = false;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.loading = false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
const agentRoot = !row.worktree ? agentWorkspace?.trim() : undefined;
|
||||
const knownRoot =
|
||||
immediateRoot ||
|
||||
(worktreeId ? this.headerWorktreePaths.get(worktreeId)?.path : undefined) ||
|
||||
agentRoot;
|
||||
const remote = Boolean(row.execNode) || isCloudWorkerPlacementState(row.placement?.state);
|
||||
// workspaceGit describes the agent workspace only; a session-specific
|
||||
// root (spawned dir) may be a Git checkout regardless, so probe it and
|
||||
// let a failed lookup hide the branch action instead.
|
||||
const rootMayHaveBranch = knownRoot === agentRoot ? workspaceGit : Boolean(knownRoot);
|
||||
// Unlike the worktree path, HEAD moves whenever the agent checks out a
|
||||
// branch mid-session, so every menu open refetches. Deliberate
|
||||
// stale-while-revalidate: the last-known branch stays actionable during
|
||||
// the sub-second local refresh — hiding it would flicker the menu on
|
||||
// every open to guard a race narrower than the user's click.
|
||||
if (!row.worktree && !remote && knownRoot && rootMayHaveBranch) {
|
||||
const entry = this.headerBranches.get(knownRoot) ?? {};
|
||||
this.headerBranches.set(knownRoot, entry);
|
||||
if (!entry.loading) {
|
||||
entry.loading = true;
|
||||
loads.push(
|
||||
client
|
||||
.request<WorktreesBranchesResult>("worktrees.branches", { repoRoot: knownRoot })
|
||||
.then((result) => {
|
||||
entry.value = result.headBranch ?? null;
|
||||
})
|
||||
.catch(() => {
|
||||
entry.value = null;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.loading = false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(loads);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private showHeaderCopied(action: ChatPaneHeaderAction): void {
|
||||
this.headerCopiedAction = action;
|
||||
if (this.headerCopiedTimer !== null) {
|
||||
window.clearTimeout(this.headerCopiedTimer);
|
||||
}
|
||||
this.headerCopiedTimer = window.setTimeout(() => {
|
||||
this.headerCopiedAction = null;
|
||||
this.headerCopiedTimer = null;
|
||||
}, 1_500);
|
||||
}
|
||||
|
||||
private handleHeaderMenuAction(
|
||||
action: ChatPaneHeaderAction,
|
||||
row: GatewaySessionRow,
|
||||
workspaceRoot: string | null,
|
||||
branch: string | null,
|
||||
copy: (value: string) => Promise<boolean> = copyToClipboard,
|
||||
): void {
|
||||
if (action === "copy-path" && workspaceRoot) {
|
||||
void copy(workspaceRoot).then((copied) => {
|
||||
if (copied) {
|
||||
this.showHeaderCopied(action);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "copy-branch" && branch) {
|
||||
void copy(branch).then((copied) => {
|
||||
if (copied) {
|
||||
this.showHeaderCopied(action);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "reveal" && workspaceRoot) {
|
||||
void this.revealHeaderWorkspace(row);
|
||||
}
|
||||
}
|
||||
|
||||
private publishHeaderError(error: unknown): void {
|
||||
if (!this.state) {
|
||||
return;
|
||||
}
|
||||
this.state.chatError = error instanceof Error ? error.message : String(error);
|
||||
this.state.requestUpdate?.();
|
||||
}
|
||||
|
||||
private async revealHeaderWorkspace(row: GatewaySessionRow): Promise<void> {
|
||||
const client = this.connectedClient;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(row.key)?.agentId;
|
||||
try {
|
||||
const result = await client.request<SessionsFilesRevealResult>("sessions.files.reveal", {
|
||||
key: row.key,
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
this.publishHeaderError(result.error ?? "Failed to reveal session workspace.");
|
||||
}
|
||||
} catch (error) {
|
||||
this.publishHeaderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private renderPaneHeader(
|
||||
sessionWorkspace: SessionWorkspaceProps,
|
||||
backgroundTasks: BackgroundTasksProps,
|
||||
row: GatewaySessionRow | undefined,
|
||||
catalog: boolean,
|
||||
agentWorkspace: string | undefined,
|
||||
workspaceGit: boolean,
|
||||
) {
|
||||
return html`
|
||||
<div
|
||||
class="chat-pane__header ${this.active ? "chat-pane__header--active" : ""}"
|
||||
@mousedown=${beginNativeWindowDrag}
|
||||
>
|
||||
<!-- Static text on purpose: an interactive session picker here would
|
||||
fight pane focus. Panes change sessions via the sidebar or
|
||||
drag-and-drop. -->
|
||||
<span class="chat-pane__session-title" title=${this.paneTitle}>${this.paneTitle}</span>
|
||||
<div class="chat-pane__actions">
|
||||
${renderCatalogTerminalButton(this.state, this.catalogSession)}
|
||||
${renderSessionDiffToggle(sessionWorkspace)}
|
||||
${renderBackgroundTasksToggle(backgroundTasks)}
|
||||
${renderSessionWorkspaceToggle(sessionWorkspace)}
|
||||
${!this.narrow
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.splitDown")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitDown")}
|
||||
@click=${() => this.onSplitDown?.(this.paneId)}
|
||||
>
|
||||
${icons.panelBottomOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<openclaw-tooltip .content=${t("chat.splitView.splitRight")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitRight")}
|
||||
@click=${() => this.onSplitRight?.(this.paneId)}
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
<openclaw-tooltip .content=${t("chat.splitView.closePane")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.closePane")}
|
||||
@click=${() => this.onClosePane?.(this.paneId)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const workspace = resolveChatPaneWorkspace({
|
||||
session: row,
|
||||
agentWorkspace: row?.worktree ? undefined : agentWorkspace,
|
||||
worktreePath: row?.worktree ? this.headerWorktreePaths.get(row.worktree.id)?.path : undefined,
|
||||
});
|
||||
// Managed worktree sessions copy the worktree record's branch — the same
|
||||
// source the sidebar subtitle and preserved-worktree prompts use. Live
|
||||
// HEAD is only resolved for plain checkouts, where no record exists.
|
||||
// Cached HEAD is keyed by the resolved root and masked while the session
|
||||
// runs remotely, so reused keys, root transitions, open menus, and
|
||||
// in-flight lookups racing a dispatch can never surface a wrong branch.
|
||||
const rowRemote = Boolean(row?.execNode) || isCloudWorkerPlacementState(row?.placement?.state);
|
||||
const branch =
|
||||
row?.worktree?.branch ||
|
||||
(rowRemote || !workspace.root ? null : this.headerBranches.get(workspace.root)?.value) ||
|
||||
null;
|
||||
const canReveal = canRevealSessionWorkspace({
|
||||
session: row,
|
||||
workspaceRoot: workspace.root,
|
||||
methodAdvertised:
|
||||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "sessions.files.reveal") === true,
|
||||
hasAdminAccess: hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
});
|
||||
return renderChatPaneHeader({
|
||||
paneId: this.paneId,
|
||||
active: this.active,
|
||||
narrow: this.narrow,
|
||||
title: this.paneTitle,
|
||||
session: row,
|
||||
catalog,
|
||||
editing: this.headerEditing && this.headerRenameSessionKey === row?.key,
|
||||
renameValue: this.headerRenameValue,
|
||||
workspaceRoot: workspace.root,
|
||||
workspaceLabel: workspace.label,
|
||||
branch,
|
||||
platform: this.headerPlatform,
|
||||
canReveal,
|
||||
copiedAction: this.headerCopiedAction,
|
||||
canRename:
|
||||
this.state?.connected === true &&
|
||||
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession),
|
||||
diffAction: renderSessionDiffToggle(sessionWorkspace),
|
||||
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
|
||||
workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace),
|
||||
onBeginRename: () => row && this.beginHeaderRename(row),
|
||||
onRenameInput: (value) => {
|
||||
this.headerRenameValue = value;
|
||||
},
|
||||
onCommitRename: () => this.commitHeaderRename(),
|
||||
onCancelRename: () => this.cancelHeaderRename(),
|
||||
onMenuOpenChange: (open) => {
|
||||
if (open && row) {
|
||||
void this.loadHeaderMenuData(row, agentWorkspace, workspaceGit);
|
||||
}
|
||||
},
|
||||
onMenuAction: (action) => {
|
||||
if (row) {
|
||||
this.handleHeaderMenuAction(action, row, workspace.root, branch);
|
||||
}
|
||||
},
|
||||
onOpenSplitView: this.onOpenSplitView,
|
||||
onSplitDown: this.onSplitDown,
|
||||
onSplitRight: this.onSplitRight,
|
||||
onClosePane: this.onClosePane,
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
@@ -1925,9 +2193,10 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
agentId: currentAgentId || null,
|
||||
onTitlesChanged: () => state.requestUpdate?.(),
|
||||
});
|
||||
const agentDefaultModel = this.context.agents.state.agentsList?.agents.find(
|
||||
const selectedAgent = this.context.agents.state.agentsList?.agents.find(
|
||||
(agent) => agent.id === currentAgentId,
|
||||
)?.model?.primary;
|
||||
);
|
||||
const agentDefaultModel = selectedAgent?.model?.primary;
|
||||
const selectedSession = state.sessionsResult?.sessions.find((row) =>
|
||||
areUiSessionKeysEquivalent(row.key, state.sessionKey),
|
||||
);
|
||||
@@ -2063,8 +2332,6 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
}),
|
||||
sessionWorkspace: catalogKey ? undefined : sessionWorkspace,
|
||||
backgroundTasks: catalogKey ? undefined : backgroundTasks,
|
||||
paneHeaderActive: this.showPaneHeader,
|
||||
onOpenSplitView: this.onOpenSplitView,
|
||||
taskSuggestions: this.taskSuggestions,
|
||||
pullRequests: this.sessionPullRequests.filter(
|
||||
(pullRequest) => !this.dismissedSessionPullRequestIds.has(chatPullRequestId(pullRequest)),
|
||||
@@ -2228,10 +2495,14 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
|
||||
basePath: state.basePath,
|
||||
};
|
||||
if (!this.showPaneHeader) {
|
||||
return renderChat(props);
|
||||
}
|
||||
return html`${this.renderPaneHeader(sessionWorkspace, backgroundTasks)}${renderChat(props)}`;
|
||||
return html`${this.renderPaneHeader(
|
||||
sessionWorkspace,
|
||||
backgroundTasks,
|
||||
selectedSession,
|
||||
Boolean(catalogKey),
|
||||
selectedAgent?.workspace,
|
||||
selectedAgent?.workspaceGit === true,
|
||||
)}${renderChat(props)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1449,112 +1449,6 @@ describe("chat composer workbench", () => {
|
||||
expect(container.querySelector('button[aria-label="Session workspace"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no rail strip while collapsed and reopens via the floating toggle", () => {
|
||||
const onToggleCollapsed = vi.fn();
|
||||
const container = renderChatView({
|
||||
sessionWorkspace: {
|
||||
collapsed: true,
|
||||
sessionKey: "agent:main",
|
||||
list: {
|
||||
sessionKey: "agent:main",
|
||||
root: "/workspace",
|
||||
files: [
|
||||
{
|
||||
name: "AGENTS.md",
|
||||
path: "/workspace/AGENTS.md",
|
||||
kind: "modified",
|
||||
missing: false,
|
||||
size: 2048,
|
||||
},
|
||||
],
|
||||
browser: { path: "", entries: [] },
|
||||
artifacts: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
activeId: null,
|
||||
dock: "right",
|
||||
narrowLayout: false,
|
||||
dockDragging: false,
|
||||
dockDragZone: null,
|
||||
onToggleCollapsed,
|
||||
onSetDock: () => undefined,
|
||||
onDockDragStart: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
onBrowsePath: () => undefined,
|
||||
onCopyPath: () => undefined,
|
||||
onOpenFile: () => undefined,
|
||||
onSearch: () => undefined,
|
||||
onOpenArtifact: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// A collapsed rail renders nothing — no icon strip in the layout.
|
||||
expect(container.querySelector(".chat-workspace-rail")).toBeNull();
|
||||
const toggle = container.querySelector<HTMLButtonElement>(".chat-workspace-toggle");
|
||||
expect(toggle?.getAttribute("aria-label")).toBe("Show session files");
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(toggle?.getAttribute("aria-keyshortcuts")).toBe("Meta+Shift+B");
|
||||
expect(toggle?.querySelector(".chat-workspace-toggle__badge")?.textContent?.trim()).toBe("1");
|
||||
|
||||
toggle?.click();
|
||||
|
||||
expect(onToggleCollapsed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the split-view opener in the floating toggle cluster", () => {
|
||||
const onOpenSplitView = vi.fn();
|
||||
const container = renderChatView({ onOpenSplitView });
|
||||
|
||||
const cluster = container.querySelector(".chat-floating-toggles");
|
||||
const opener = cluster?.querySelector<HTMLButtonElement>(".chat-open-split-view");
|
||||
expect(opener?.getAttribute("aria-label")).toBe("Open split view");
|
||||
|
||||
opener?.click();
|
||||
expect(onOpenSplitView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the split-view opener while the detail sidebar is open", () => {
|
||||
const container = renderChatView({
|
||||
onOpenSplitView: () => undefined,
|
||||
sidebarOpen: true,
|
||||
sidebarContent: { kind: "markdown", content: "detail" },
|
||||
onCloseSidebar: () => undefined,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-open-split-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the floating workspace toggle when a pane header hosts it", () => {
|
||||
const container = renderChatView({
|
||||
paneHeaderActive: true,
|
||||
sessionWorkspace: {
|
||||
collapsed: true,
|
||||
sessionKey: "agent:main",
|
||||
list: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
activeId: null,
|
||||
dock: "right",
|
||||
narrowLayout: false,
|
||||
dockDragging: false,
|
||||
dockDragZone: null,
|
||||
onToggleCollapsed: () => undefined,
|
||||
onSetDock: () => undefined,
|
||||
onDockDragStart: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
onBrowsePath: () => undefined,
|
||||
onCopyPath: () => undefined,
|
||||
onOpenFile: () => undefined,
|
||||
onSearch: () => undefined,
|
||||
onOpenArtifact: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-workspace-toggle")).toBeNull();
|
||||
expect(container.querySelector(".chat-workspace-rail")).toBeNull();
|
||||
});
|
||||
|
||||
it("stacks the detail sidebar under the thread with a horizontal divider on narrow panes", () => {
|
||||
const sidebarProps = {
|
||||
sidebarOpen: true,
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import type { ChatFollowUpMode, ChatSendShortcut } from "../../app/settings.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type {
|
||||
ChatAttachment,
|
||||
@@ -24,7 +23,6 @@ import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
|
||||
import { handleChatAttachmentDrop } from "./components/chat-attachments.ts";
|
||||
import {
|
||||
renderBackgroundTasksRail,
|
||||
renderBackgroundTasksToggle,
|
||||
type BackgroundTasksProps,
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import {
|
||||
@@ -34,9 +32,7 @@ import {
|
||||
} from "./components/chat-composer.ts";
|
||||
import { renderChatPullRequests } from "./components/chat-pull-requests.ts";
|
||||
import {
|
||||
renderSessionDiffToggle,
|
||||
renderSessionWorkspaceRail,
|
||||
renderSessionWorkspaceToggle,
|
||||
type SessionWorkspaceProps,
|
||||
} from "./components/chat-session-workspace.ts";
|
||||
import { isSideChatPanelVisible, renderSideChatPanel } from "./components/chat-side-chat.ts";
|
||||
@@ -192,12 +188,6 @@ export type ChatProps = {
|
||||
onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void;
|
||||
sessionWorkspace?: SessionWorkspaceProps;
|
||||
backgroundTasks?: BackgroundTasksProps;
|
||||
/** True when a split pane header hosts the workspace toggle; suppresses the
|
||||
* single-pane floating opener so only one affordance renders. */
|
||||
paneHeaderActive?: boolean;
|
||||
/** Split-view opener shown in the floating toggle cluster. Only set for the
|
||||
* single wide pane — split mode owns its controls in pane headers. */
|
||||
onOpenSplitView?: () => void;
|
||||
taskSuggestions?: TaskSuggestion[];
|
||||
taskSuggestionBusyIds?: ReadonlySet<string>;
|
||||
canAcceptTaskSuggestions?: boolean;
|
||||
@@ -512,41 +502,6 @@ export function renderChat(props: ChatProps) {
|
||||
`
|
||||
: nothing}
|
||||
<div class="chat-workbench__main">
|
||||
<!-- Floating openers share the top-right corner with the detail
|
||||
panel's header controls; hide them while the sidebar is open. -->
|
||||
${!props.paneHeaderActive &&
|
||||
!sidebarOpen &&
|
||||
(props.onOpenSplitView ||
|
||||
props.sessionWorkspace?.collapsed ||
|
||||
props.backgroundTasks?.collapsed)
|
||||
? html`
|
||||
<div class="chat-floating-toggles">
|
||||
${props.onOpenSplitView
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.open")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-open-split-view"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.open")}
|
||||
@click=${props.onOpenSplitView}
|
||||
>
|
||||
${icons.columns2}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
${props.sessionWorkspace?.collapsed
|
||||
? renderSessionDiffToggle(props.sessionWorkspace)
|
||||
: nothing}
|
||||
${props.backgroundTasks?.collapsed
|
||||
? renderBackgroundTasksToggle(props.backgroundTasks)
|
||||
: nothing}
|
||||
${props.sessionWorkspace?.collapsed
|
||||
? renderSessionWorkspaceToggle(props.sessionWorkspace)
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div
|
||||
class="chat-split-container ${sidebarOpen
|
||||
? "chat-split-container--open"
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html, nothing, render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import {
|
||||
canRevealSessionWorkspace,
|
||||
renderChatPaneHeader,
|
||||
resolveChatPaneWorkspace,
|
||||
} from "./chat-pane-header.ts";
|
||||
|
||||
type ChatPaneHeaderProps = Parameters<typeof renderChatPaneHeader>[0];
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
containers.splice(0).forEach((container) => container.remove());
|
||||
});
|
||||
|
||||
function row(patch: Partial<GatewaySessionRow> = {}): GatewaySessionRow {
|
||||
return { key: "agent:main:test", kind: "direct", updatedAt: 0, ...patch };
|
||||
}
|
||||
|
||||
function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
containers.push(container);
|
||||
const props: ChatPaneHeaderProps = {
|
||||
paneId: "pane-1",
|
||||
active: true,
|
||||
narrow: false,
|
||||
title: "Session title",
|
||||
session: row(),
|
||||
catalog: false,
|
||||
editing: false,
|
||||
renameValue: "Session title",
|
||||
workspaceRoot: "/repo/openclaw",
|
||||
workspaceLabel: "openclaw",
|
||||
branch: "feature/header",
|
||||
platform: "darwin",
|
||||
canReveal: true,
|
||||
copiedAction: null,
|
||||
canRename: true,
|
||||
terminalAction: nothing,
|
||||
diffAction: nothing,
|
||||
backgroundTasksAction: nothing,
|
||||
workspaceAction: nothing,
|
||||
onBeginRename: vi.fn(),
|
||||
onRenameInput: vi.fn(),
|
||||
onCommitRename: vi.fn(),
|
||||
onCancelRename: vi.fn(),
|
||||
onMenuOpenChange: vi.fn(),
|
||||
onMenuAction: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
render(html`${renderChatPaneHeader(props)}`, container);
|
||||
return { container, props };
|
||||
}
|
||||
|
||||
describe("chat pane header", () => {
|
||||
it("renders an editable title and workspace chip", () => {
|
||||
const { container, props } = mount();
|
||||
const title = container.querySelector<HTMLButtonElement>(".chat-pane__session-title-button");
|
||||
const chip = container.querySelector<HTMLButtonElement>(".chat-pane__workspace-chip");
|
||||
expect(title?.textContent?.trim()).toBe("Session title");
|
||||
expect(chip?.textContent?.trim()).toContain("openclaw");
|
||||
title?.click();
|
||||
expect(props.onBeginRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes Enter and Escape from the rename input", () => {
|
||||
const enter = mount({ editing: true, renameValue: " Updated " });
|
||||
const enterInput = enter.container.querySelector<HTMLInputElement>("input");
|
||||
enterInput?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
expect(enter.props.onCommitRename).toHaveBeenCalledOnce();
|
||||
|
||||
const escape = mount({ editing: true });
|
||||
escape.container
|
||||
.querySelector("input")
|
||||
?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
expect(escape.props.onCancelRename).toHaveBeenCalledOnce();
|
||||
expect(escape.props.onCommitRename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps catalog sessions static and without a workspace chip", () => {
|
||||
const { container } = mount({
|
||||
catalog: true,
|
||||
session: undefined,
|
||||
terminalAction: html`<span data-action="terminal"></span>`,
|
||||
diffAction: html`<span data-action="diff"></span>`,
|
||||
backgroundTasksAction: html`<span data-action="tasks"></span>`,
|
||||
workspaceAction: html`<span data-action="workspace"></span>`,
|
||||
});
|
||||
expect(container.querySelector(".chat-pane__session-title-button")).toBeNull();
|
||||
expect(container.querySelector(".chat-pane__session-title")?.textContent).toContain(
|
||||
"Session title",
|
||||
);
|
||||
expect(container.querySelector(".chat-pane__workspace-chip")).toBeNull();
|
||||
expect(container.querySelector('[data-action="terminal"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-action="diff"]')).toBeNull();
|
||||
expect(container.querySelector('[data-action="tasks"]')).toBeNull();
|
||||
expect(container.querySelector('[data-action="workspace"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps read-only gateway session titles static", () => {
|
||||
const { container } = mount({ canRename: false });
|
||||
expect(container.querySelector(".chat-pane__session-title-button")).toBeNull();
|
||||
expect(container.querySelector(".chat-pane__session-title")?.textContent).toContain(
|
||||
"Session title",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows copied feedback on the workspace chip", () => {
|
||||
const { container } = mount({ copiedAction: "copy-path" });
|
||||
expect(container.querySelector(".chat-pane__workspace-chip")?.textContent).toContain("Copied");
|
||||
});
|
||||
|
||||
it("shows cloud placement and hides reveal when disabled", () => {
|
||||
const { container } = mount({
|
||||
session: row({
|
||||
placement: { state: "active" } as GatewaySessionRow["placement"],
|
||||
}),
|
||||
canReveal: false,
|
||||
});
|
||||
expect(container.querySelector(".chat-pane__cloud")).not.toBeNull();
|
||||
expect(container.querySelector('wa-dropdown-item[value="reveal"]')).toBeNull();
|
||||
expect(container.querySelector('wa-dropdown-item[value="copy-path"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat pane workspace resolution", () => {
|
||||
it("uses worktree repo vocabulary with spawned cwd", () => {
|
||||
expect(
|
||||
resolveChatPaneWorkspace({
|
||||
session: row({
|
||||
spawnedCwd: "/tmp/worktrees/title-bar",
|
||||
worktree: { id: "wt-1", branch: "title-bar", repoRoot: "/src/openclaw" },
|
||||
}),
|
||||
}),
|
||||
).toEqual({ root: "/tmp/worktrees/title-bar", label: "openclaw" });
|
||||
});
|
||||
|
||||
it("does not substitute the agent workspace for a missing worktree checkout", () => {
|
||||
expect(
|
||||
resolveChatPaneWorkspace({
|
||||
session: row({
|
||||
worktree: { id: "wt-missing", branch: "feature", repoRoot: "/src/openclaw" },
|
||||
}),
|
||||
agentWorkspace: "/src/default-agent-workspace",
|
||||
worktreePath: null,
|
||||
}),
|
||||
).toEqual({ root: null, label: "openclaw" });
|
||||
});
|
||||
|
||||
it("matches the gateway root order: spawned workspace before spawned cwd", () => {
|
||||
expect(
|
||||
resolveChatPaneWorkspace({
|
||||
session: row({
|
||||
spawnedWorkspaceDir: "/src/openclaw",
|
||||
spawnedCwd: "/src/openclaw/packages/nested",
|
||||
}),
|
||||
}),
|
||||
).toEqual({ root: "/src/openclaw", label: "openclaw" });
|
||||
// execCwd is exec-node routing state; it never overrides local facts.
|
||||
expect(
|
||||
resolveChatPaneWorkspace({
|
||||
session: row({ execCwd: "/remote/stale", spawnedCwd: "/src/openclaw" }),
|
||||
}),
|
||||
).toEqual({ root: "/src/openclaw", label: "openclaw" });
|
||||
});
|
||||
|
||||
it("prefers exec cwd and falls back to the agent workspace", () => {
|
||||
expect(
|
||||
resolveChatPaneWorkspace({
|
||||
session: row({ execNode: "build-mac", execCwd: "/remote/build" }),
|
||||
agentWorkspace: "/local/default",
|
||||
}),
|
||||
).toEqual({ root: "/remote/build", label: "build" });
|
||||
// Without execCwd, gateway-local facts must not stand in for a path that
|
||||
// lives on another machine.
|
||||
expect(
|
||||
resolveChatPaneWorkspace({
|
||||
session: row({ execNode: "build-mac", spawnedCwd: "/local/spawned" }),
|
||||
agentWorkspace: "/local/default",
|
||||
worktreePath: "/local/worktree",
|
||||
}),
|
||||
).toEqual({ root: null, label: null });
|
||||
expect(resolveChatPaneWorkspace({ session: row(), agentWorkspace: "/src/openclaw" })).toEqual({
|
||||
root: "/src/openclaw",
|
||||
label: "openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables reveal for exec nodes, remote placement, and missing advertisement", () => {
|
||||
expect(
|
||||
canRevealSessionWorkspace({
|
||||
session: row({ execNode: "build-mac", execCwd: "/remote/build" }),
|
||||
workspaceRoot: "/remote/build",
|
||||
methodAdvertised: true,
|
||||
hasAdminAccess: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canRevealSessionWorkspace({
|
||||
session: row({ placement: { state: "requested" } as GatewaySessionRow["placement"] }),
|
||||
workspaceRoot: "/cloud/work",
|
||||
methodAdvertised: true,
|
||||
hasAdminAccess: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canRevealSessionWorkspace({
|
||||
session: row(),
|
||||
workspaceRoot: "/src/openclaw",
|
||||
methodAdvertised: false,
|
||||
hasAdminAccess: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canRevealSessionWorkspace({
|
||||
session: row(),
|
||||
workspaceRoot: "/src/openclaw",
|
||||
methodAdvertised: true,
|
||||
hasAdminAccess: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import { beginNativeWindowDrag } from "../../../app/native-window-drag.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { isCloudWorkerPlacementState } from "../../../components/session-row-badges.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
|
||||
export type ChatPaneHeaderAction = "reveal" | "copy-path" | "copy-branch";
|
||||
|
||||
type ChatPaneHeaderProps = {
|
||||
paneId: string;
|
||||
active: boolean;
|
||||
narrow: boolean;
|
||||
title: string;
|
||||
session: GatewaySessionRow | undefined;
|
||||
catalog: boolean;
|
||||
editing: boolean;
|
||||
renameValue: string;
|
||||
workspaceRoot: string | null;
|
||||
workspaceLabel: string | null;
|
||||
branch: string | null;
|
||||
platform: string | null;
|
||||
canReveal: boolean;
|
||||
copiedAction: ChatPaneHeaderAction | null;
|
||||
canRename: boolean;
|
||||
terminalAction: TemplateResult | typeof nothing;
|
||||
diffAction: TemplateResult | typeof nothing;
|
||||
backgroundTasksAction: TemplateResult | typeof nothing;
|
||||
workspaceAction: TemplateResult | typeof nothing;
|
||||
onBeginRename: () => void;
|
||||
onRenameInput: (value: string) => void;
|
||||
onCommitRename: () => void;
|
||||
onCancelRename: () => void;
|
||||
onMenuOpenChange: (open: boolean) => void;
|
||||
onMenuAction: (action: ChatPaneHeaderAction) => void;
|
||||
onOpenSplitView?: () => void;
|
||||
onSplitDown?: (paneId: string) => void;
|
||||
onSplitRight?: (paneId: string) => void;
|
||||
onClosePane?: (paneId: string) => void;
|
||||
};
|
||||
|
||||
function revealLabel(platform: string | null): string {
|
||||
if (platform === "darwin") {
|
||||
return t("chat.sessionHeader.revealFinder");
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return t("chat.sessionHeader.revealFileExplorer");
|
||||
}
|
||||
return t("chat.sessionHeader.revealFileManager");
|
||||
}
|
||||
|
||||
function pathBasename(value: string): string {
|
||||
const trimmed = value.replace(/[\\/]+$/, "");
|
||||
return trimmed.split(/[\\/]/).pop() || trimmed;
|
||||
}
|
||||
|
||||
export function resolveChatPaneWorkspace(params: {
|
||||
session: GatewaySessionRow | undefined;
|
||||
agentWorkspace?: string;
|
||||
worktreePath?: string | null;
|
||||
}): { root: string | null; label: string | null } {
|
||||
const row = params.session;
|
||||
if (!row) {
|
||||
return { root: null, label: null };
|
||||
}
|
||||
// Exec-node sessions live on another machine: gateway-local facts would
|
||||
// hand the user a path for the wrong host, so only execCwd may name them.
|
||||
// Cloud-worker sessions keep their gateway-local checkout (workers sync
|
||||
// against it), so local facts stay correct there.
|
||||
// Mirror the gateway's loadSessionFileRoot order (spawned workspace before
|
||||
// spawned cwd) so copy-path and the chip tooltip name the same directory
|
||||
// sessions.files.reveal opens.
|
||||
const root = row.execNode
|
||||
? row.execCwd?.trim() || null
|
||||
: row.spawnedWorkspaceDir?.trim() ||
|
||||
row.spawnedCwd?.trim() ||
|
||||
params.worktreePath?.trim() ||
|
||||
(!row.worktree ? params.agentWorkspace?.trim() : "") ||
|
||||
null;
|
||||
const label = row.worktree?.repoRoot
|
||||
? pathBasename(row.worktree.repoRoot)
|
||||
: root
|
||||
? pathBasename(root)
|
||||
: null;
|
||||
return { root, label };
|
||||
}
|
||||
|
||||
export function canRevealSessionWorkspace(params: {
|
||||
session: GatewaySessionRow | undefined;
|
||||
workspaceRoot: string | null;
|
||||
methodAdvertised: boolean;
|
||||
hasAdminAccess: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
params.workspaceRoot &&
|
||||
params.methodAdvertised &&
|
||||
params.hasAdminAccess &&
|
||||
!params.session?.execNode &&
|
||||
!isCloudWorkerPlacementState(params.session?.placement?.state),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
||||
const placementState = props.session?.placement?.state;
|
||||
const cloud = isCloudWorkerPlacementState(placementState);
|
||||
const cloudLabel = cloud ? t("sessionsView.cloudWorkerPlacement", { state: placementState }) : "";
|
||||
const copyPathLabel =
|
||||
props.copiedAction === "copy-path"
|
||||
? t("chat.sessionHeader.copied")
|
||||
: t("chat.sessionHeader.copyPath");
|
||||
const copyBranchLabel =
|
||||
props.copiedAction === "copy-branch"
|
||||
? t("chat.sessionHeader.copied")
|
||||
: t("chat.sessionHeader.copyBranch");
|
||||
const copied = props.copiedAction === "copy-path" || props.copiedAction === "copy-branch";
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-pane__header ${props.active ? "chat-pane__header--active" : ""}"
|
||||
@mousedown=${beginNativeWindowDrag}
|
||||
>
|
||||
${cloud
|
||||
? html`<span
|
||||
class="chat-pane__cloud"
|
||||
role="img"
|
||||
aria-label=${cloudLabel}
|
||||
title=${cloudLabel}
|
||||
>${icons.globe}</span
|
||||
>`
|
||||
: nothing}
|
||||
${props.editing
|
||||
? html`<input
|
||||
class="chat-pane__session-title-input"
|
||||
.value=${props.renameValue}
|
||||
aria-label=${t("chat.sessionHeader.renameInputAria")}
|
||||
placeholder=${t("chat.sessionHeader.renameInputPlaceholder")}
|
||||
@input=${(event: InputEvent) =>
|
||||
props.onRenameInput((event.currentTarget as HTMLInputElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
props.onCommitRename();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
props.onCancelRename();
|
||||
}
|
||||
}}
|
||||
@blur=${props.onCommitRename}
|
||||
/>`
|
||||
: props.catalog || !props.session || !props.canRename
|
||||
? html`<span class="chat-pane__session-title" title=${props.title}>${props.title}</span>`
|
||||
: html`<button
|
||||
class="chat-pane__session-title chat-pane__session-title-button"
|
||||
type="button"
|
||||
title=${t("chat.sessionHeader.renameTooltip")}
|
||||
aria-label=${t("chat.sessionHeader.renameAria", { title: props.title })}
|
||||
@click=${props.onBeginRename}
|
||||
>
|
||||
${props.title}
|
||||
</button>`}
|
||||
${!props.catalog && props.workspaceLabel
|
||||
? html`
|
||||
<wa-dropdown
|
||||
class="chat-pane__workspace-menu"
|
||||
placement="bottom-start"
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const value = event.detail.item.value;
|
||||
if (value === "reveal" || value === "copy-path" || value === "copy-branch") {
|
||||
props.onMenuAction(value);
|
||||
}
|
||||
}}
|
||||
@wa-show=${() => props.onMenuOpenChange(true)}
|
||||
@wa-hide=${() => props.onMenuOpenChange(false)}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="chat-pane__workspace-chip"
|
||||
type="button"
|
||||
title=${props.workspaceRoot ?? props.workspaceLabel}
|
||||
aria-label=${t("chat.sessionHeader.workspaceAria", {
|
||||
workspace: props.workspaceLabel,
|
||||
})}
|
||||
>
|
||||
${copied ? icons.check : icons.folder}<span
|
||||
>${copied ? t("chat.sessionHeader.copied") : props.workspaceLabel}</span
|
||||
>
|
||||
</button>
|
||||
${props.canReveal && props.workspaceRoot
|
||||
? html`<wa-dropdown-item value="reveal"
|
||||
>${revealLabel(props.platform)}</wa-dropdown-item
|
||||
>`
|
||||
: nothing}
|
||||
${props.workspaceRoot
|
||||
? html`<wa-dropdown-item value="copy-path">${copyPathLabel}</wa-dropdown-item>`
|
||||
: nothing}
|
||||
${props.branch
|
||||
? html`<wa-dropdown-item value="copy-branch">${copyBranchLabel}</wa-dropdown-item>`
|
||||
: nothing}
|
||||
</wa-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
<div class="chat-pane__actions">
|
||||
${props.terminalAction}
|
||||
${props.catalog
|
||||
? nothing
|
||||
: html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}`}
|
||||
${props.onOpenSplitView
|
||||
? html`<openclaw-tooltip .content=${t("chat.splitView.open")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-open-split-view"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.open")}
|
||||
@click=${props.onOpenSplitView}
|
||||
>
|
||||
${icons.columns2}
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${!props.narrow && props.onSplitDown
|
||||
? html`<openclaw-tooltip .content=${t("chat.splitView.splitDown")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitDown")}
|
||||
@click=${() => props.onSplitDown?.(props.paneId)}
|
||||
>
|
||||
${icons.panelBottomOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${!props.narrow && props.onSplitRight
|
||||
? html`<openclaw-tooltip .content=${t("chat.splitView.splitRight")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitRight")}
|
||||
@click=${() => props.onSplitRight?.(props.paneId)}
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${props.onClosePane
|
||||
? html`<openclaw-tooltip .content=${t("chat.splitView.closePane")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.closePane")}
|
||||
@click=${() => props.onClosePane?.(props.paneId)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { repeat } from "lit/directives/repeat.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js";
|
||||
import type { SessionsListResult } from "../../../api/types.ts";
|
||||
import { beginNativeWindowDragFromTopInset } from "../../../app/native-window-drag.ts";
|
||||
import { resolveLocalUserName } from "../../../app/user-identity.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
@@ -1180,7 +1179,6 @@ function renderChatThreadContents(
|
||||
: null}
|
||||
@touchend=${props.onHistoryIntent}
|
||||
@touchcancel=${props.onHistoryIntent}
|
||||
@mousedown=${beginNativeWindowDragFromTopInset}
|
||||
@click=${(event: Event) => {
|
||||
handleMarkdownCodeBlockCopy(event);
|
||||
const target = markdownFileLinkFromEvent(event);
|
||||
|
||||
@@ -114,15 +114,12 @@ openclaw-chat-page {
|
||||
user-select: text;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-color: var(--muted-strong) transparent;
|
||||
/* The tall top inset is a titlebar band: it keeps the floating rail
|
||||
openers (.chat-floating-toggles, 28px at top 10px) clear of the first
|
||||
message and gives the native macOS app an empty strip that starts a
|
||||
window drag (see the thread mousedown handler in chat-thread.ts).
|
||||
No horizontal padding: macOS overlay scrollbars render inside the
|
||||
/* The pane header owns the titlebar and native drag surface. No horizontal
|
||||
padding: macOS overlay scrollbars render inside the
|
||||
scroll container's padding, which floats the thumb away from the pane
|
||||
edge (visible next to the sidebar divider). The side inset lives on
|
||||
.chat-thread-inner instead so the scrollbar hugs the edge. */
|
||||
padding: clamp(44px, 5vh, 52px) 0 6px;
|
||||
padding: clamp(20px, 3vh, 28px) 0 6px;
|
||||
margin: 0 0 0 0;
|
||||
min-height: 0;
|
||||
/* Allow shrinking for flex scroll behavior */
|
||||
@@ -138,14 +135,6 @@ openclaw-chat-page {
|
||||
background: var(--text);
|
||||
}
|
||||
|
||||
/* Split panes spend their top row on the pane header (which is both the
|
||||
toggle host and the drag surface), so their threads keep a compact inset.
|
||||
The single-pane view is also an openclaw-chat-pane but renders no header,
|
||||
hence the :has() guard. */
|
||||
openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
padding-top: clamp(20px, 3vh, 28px);
|
||||
}
|
||||
|
||||
.chat-thread-inner > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
/* A collapsed rail renders nothing at all — the whole width goes back to the
|
||||
thread. Reopen via the pane header / floating toggle or ⇧⌘B. */
|
||||
thread. Reopen via the pane header or ⇧⌘B. */
|
||||
.chat-workbench--workspace-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@@ -48,36 +48,8 @@
|
||||
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px) minmax(230px, 280px);
|
||||
}
|
||||
|
||||
/* Floating openers shown in single-pane mode while a rail is collapsed
|
||||
(split panes host the same toggles in their header row instead). One
|
||||
horizontal row inside the thread's titlebar band (see .chat-thread top
|
||||
padding) so the buttons never cover the first message and the band stays
|
||||
free as a window-drag surface in the native macOS app. */
|
||||
.chat-floating-toggles {
|
||||
position: absolute;
|
||||
/* 8px top + 2px row padding keeps the 28px buttons on the shared 24px
|
||||
chrome centerline (sidebar brand row, pane-header actions). */
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
/* Same tight spacing as .chat-pane__actions so both rows read identical. */
|
||||
gap: 2px;
|
||||
/* Ghost buttons carry no chrome of their own, but scrolled thread content
|
||||
passes under this row; a frosted wash keeps the icons legible without
|
||||
reading as boxed buttons. Invisible over the empty titlebar band. */
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Compact icon button shared by the split-pane header actions and the
|
||||
floating toggles above: one borderless 28px design so both surfaces match.
|
||||
Sizing only — chrome stays ghost; .btn--icon's 36px min-width/height would
|
||||
win otherwise. The floating row sits in the thread's reserved titlebar band
|
||||
(never over content), so it needs no backdrop of its own. */
|
||||
/* Compact icon button shared by the pane header actions. Sizing only — chrome
|
||||
stays ghost; .btn--icon's 36px min-width/height would win otherwise. */
|
||||
.chat-icon-btn {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
@@ -1605,9 +1577,6 @@
|
||||
|
||||
/* ── Session diff panel (sessions.diff sidebar content) ── */
|
||||
|
||||
/* The floating diff opener sits in the .chat-floating-toggles row and shares
|
||||
the .chat-icon-btn sizing/chrome rules near the top of this file. */
|
||||
|
||||
.session-diff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -148,6 +148,85 @@ openclaw-chat-pane {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane__session-title-button {
|
||||
display: block;
|
||||
padding: 3px 5px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.chat-pane__session-title-button:hover,
|
||||
.chat-pane__session-title-button:focus-visible {
|
||||
background: color-mix(in srgb, var(--text) 7%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-pane__session-title-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 60px;
|
||||
height: 28px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.chat-pane__cloud {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-pane__cloud svg,
|
||||
.chat-pane__workspace-chip svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chat-pane__workspace-menu {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-pane__workspace-chip {
|
||||
display: inline-flex;
|
||||
max-width: 180px;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-pane__workspace-chip:hover,
|
||||
.chat-pane__workspace-chip:focus-visible {
|
||||
border-color: var(--muted-strong);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-pane__workspace-chip span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -155,6 +234,30 @@ openclaw-chat-pane {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-pane__workspace-chip {
|
||||
width: 28px;
|
||||
padding: 0 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-pane__workspace-chip span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plain web with the nav collapsed: the floating sidebar-expand pill
|
||||
(.shell-nav-expand, fixed 10px/10px, 32px wide) overlays the first pane
|
||||
header's left edge; 52px clears it so the session title stays readable.
|
||||
Native shells below reserve larger insets for their own window chrome. */
|
||||
html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)
|
||||
.shell--nav-collapsed:not(.shell--mobile-nav)
|
||||
.chat-split-view__column:first-child
|
||||
> .chat-split-view__cell:first-child
|
||||
.chat-pane__header {
|
||||
padding-left: 52px;
|
||||
}
|
||||
|
||||
/* Native macOS: with the nav column collapsed the top-left pane header is the
|
||||
window's top surface, so its content shifts right of the floating window
|
||||
chrome (Safari-style) instead of hiding under it. Traffic lights end at
|
||||
|
||||
Reference in New Issue
Block a user