feat(webui): add session workspace rail (#92856)

* feat(webui): add session workspace rail

* fix(webui): address session workspace review

* fix(webui): secure session workspace previews

* fix(webui): handle nested session workspace paths

* fix(webui): update session file protocol models

* fix(webui): clear session rail lint
This commit is contained in:
Colin Johnson
2026-06-14 10:39:51 -04:00
committed by GitHub
parent ccf5976d06
commit fd855c831f
58 changed files with 4268 additions and 675 deletions
@@ -2074,6 +2074,204 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable {
}
}
public struct SessionFileBrowserEntry: Codable, Sendable {
public let path: String
public let name: String
public let kind: AnyCodable
public let sessionkind: SessionFileRelevance?
public let size: Int?
public let updatedatms: Int?
public init(
path: String,
name: String,
kind: AnyCodable,
sessionkind: SessionFileRelevance?,
size: Int?,
updatedatms: Int?)
{
self.path = path
self.name = name
self.kind = kind
self.sessionkind = sessionkind
self.size = size
self.updatedatms = updatedatms
}
private enum CodingKeys: String, CodingKey {
case path
case name
case kind
case sessionkind = "sessionKind"
case size
case updatedatms = "updatedAtMs"
}
}
public struct SessionFileBrowserResult: Codable, Sendable {
public let path: String
public let parentpath: String?
public let search: String?
public let entries: [SessionFileBrowserEntry]
public let truncated: Bool?
public init(
path: String,
parentpath: String?,
search: String?,
entries: [SessionFileBrowserEntry],
truncated: Bool?)
{
self.path = path
self.parentpath = parentpath
self.search = search
self.entries = entries
self.truncated = truncated
}
private enum CodingKeys: String, CodingKey {
case path
case parentpath = "parentPath"
case search
case entries
case truncated
}
}
public struct SessionFileEntry: Codable, Sendable {
public let path: String
public let name: String
public let kind: SessionFileKind
public let missing: Bool
public let size: Int?
public let updatedatms: Int?
public let content: String?
public init(
path: String,
name: String,
kind: SessionFileKind,
missing: Bool,
size: Int?,
updatedatms: Int?,
content: String?)
{
self.path = path
self.name = name
self.kind = kind
self.missing = missing
self.size = size
self.updatedatms = updatedatms
self.content = content
}
private enum CodingKeys: String, CodingKey {
case path
case name
case kind
case missing
case size
case updatedatms = "updatedAtMs"
case content
}
}
public struct SessionsFilesListParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let path: String?
public let search: String?
public init(
sessionkey: String,
agentid: String? = nil,
path: String?,
search: String?)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.path = path
self.search = search
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case path
case search
}
}
public struct SessionsFilesListResult: Codable, Sendable {
public let sessionkey: String
public let root: String?
public let files: [SessionFileEntry]
public let browser: SessionFileBrowserResult?
public init(
sessionkey: String,
root: String?,
files: [SessionFileEntry],
browser: SessionFileBrowserResult?)
{
self.sessionkey = sessionkey
self.root = root
self.files = files
self.browser = browser
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case root
case files
case browser
}
}
public struct SessionsFilesGetParams: Codable, Sendable {
public let sessionkey: String
public let path: String
public let agentid: String?
public init(
sessionkey: String,
path: String,
agentid: String? = nil)
{
self.sessionkey = sessionkey
self.path = path
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case path
case agentid = "agentId"
}
}
public struct SessionsFilesGetResult: Codable, Sendable {
public let sessionkey: String
public let root: String?
public let file: SessionFileEntry
public init(
sessionkey: String,
root: String?,
file: SessionFileEntry)
{
self.sessionkey = sessionkey
self.root = root
self.file = file
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case root
case file
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
+42
View File
@@ -321,6 +321,16 @@ import {
SessionsCompactionListParamsSchema,
type SessionsCompactionRestoreParams,
SessionsCompactionRestoreParamsSchema,
type SessionFileBrowserEntry,
SessionFileBrowserEntrySchema,
type SessionFileBrowserResult,
SessionFileBrowserResultSchema,
type SessionFileEntry,
SessionFileEntrySchema,
type SessionFileKind,
SessionFileKindSchema,
type SessionFileRelevance,
SessionFileRelevanceSchema,
type SessionOperationEvent,
type SessionsCreateParams,
SessionsCreateParamsSchema,
@@ -328,6 +338,14 @@ import {
SessionsDeleteParamsSchema,
type SessionsDescribeParams,
SessionsDescribeParamsSchema,
type SessionsFilesGetParams,
SessionsFilesGetParamsSchema,
type SessionsFilesGetResult,
SessionsFilesGetResultSchema,
type SessionsFilesListParams,
SessionsFilesListParamsSchema,
type SessionsFilesListResult,
SessionsFilesListResultSchema,
type SessionsListParams,
SessionsListParamsSchema,
type SessionsMessagesSubscribeParams,
@@ -630,6 +648,12 @@ export const validateSessionsDescribeParams = lazyCompile<SessionsDescribeParams
export const validateSessionsResolveParams = lazyCompile<SessionsResolveParams>(
SessionsResolveParamsSchema,
);
export const validateSessionsFilesListParams = lazyCompile<SessionsFilesListParams>(
SessionsFilesListParamsSchema,
);
export const validateSessionsFilesGetParams = lazyCompile<SessionsFilesGetParams>(
SessionsFilesGetParamsSchema,
);
export const validateSessionsCreateParams = lazyCompile<SessionsCreateParams>(
SessionsCreateParamsSchema,
);
@@ -1005,6 +1029,15 @@ export {
SessionsPreviewParamsSchema,
SessionsDescribeParamsSchema,
SessionsResolveParamsSchema,
SessionFileBrowserEntrySchema,
SessionFileBrowserResultSchema,
SessionFileEntrySchema,
SessionFileKindSchema,
SessionFileRelevanceSchema,
SessionsFilesGetParamsSchema,
SessionsFilesGetResultSchema,
SessionsFilesListParamsSchema,
SessionsFilesListResultSchema,
SessionsCompactionListParamsSchema,
SessionsCompactionGetParamsSchema,
SessionsCompactionBranchParamsSchema,
@@ -1248,6 +1281,15 @@ export type {
AgentsFilesGetResult,
AgentsFilesSetParams,
AgentsFilesSetResult,
SessionFileBrowserEntry,
SessionFileBrowserResult,
SessionFileEntry,
SessionFileKind,
SessionFileRelevance,
SessionsFilesListParams,
SessionsFilesListResult,
SessionsFilesGetParams,
SessionsFilesGetResult,
ArtifactSummary,
ArtifactsListParams,
ArtifactsListResult,
@@ -262,12 +262,21 @@ import {
SessionsCompactionListResultSchema,
SessionsCompactionRestoreParamsSchema,
SessionsCompactionRestoreResultSchema,
SessionFileBrowserEntrySchema,
SessionFileBrowserResultSchema,
SessionCompactionCheckpointSchema,
SessionFileEntrySchema,
SessionFileKindSchema,
SessionFileRelevanceSchema,
SessionOperationEventSchema,
SessionsCleanupParamsSchema,
SessionsCreateParamsSchema,
SessionsDeleteParamsSchema,
SessionsDescribeParamsSchema,
SessionsFilesGetParamsSchema,
SessionsFilesGetResultSchema,
SessionsFilesListParamsSchema,
SessionsFilesListResultSchema,
SessionsListParamsSchema,
SessionsMessagesSubscribeParamsSchema,
SessionsMessagesUnsubscribeParamsSchema,
@@ -379,6 +388,15 @@ export const ProtocolSchemas = {
SessionsCompactionGetResult: SessionsCompactionGetResultSchema,
SessionsCompactionBranchResult: SessionsCompactionBranchResultSchema,
SessionsCompactionRestoreResult: SessionsCompactionRestoreResultSchema,
SessionFileBrowserEntry: SessionFileBrowserEntrySchema,
SessionFileBrowserResult: SessionFileBrowserResultSchema,
SessionFileKind: SessionFileKindSchema,
SessionFileEntry: SessionFileEntrySchema,
SessionFileRelevance: SessionFileRelevanceSchema,
SessionsFilesListParams: SessionsFilesListParamsSchema,
SessionsFilesListResult: SessionsFilesListResultSchema,
SessionsFilesGetParams: SessionsFilesGetParamsSchema,
SessionsFilesGetResult: SessionsFilesGetResultSchema,
SessionsCreateParams: SessionsCreateParamsSchema,
SessionsSendParams: SessionsSendParamsSchema,
SessionsMessagesSubscribeParams: SessionsMessagesSubscribeParamsSchema,
@@ -63,6 +63,97 @@ export const SessionCompactionCheckpointSchema = Type.Object(
{ additionalProperties: false },
);
/** Session file grouping used by the Control UI session workspace rail. */
export const SessionFileKindSchema = Type.Union([Type.Literal("modified"), Type.Literal("read")]);
/** Session relevance marker for browser entries. */
export const SessionFileRelevanceSchema = Type.Union([
Type.Literal("modified"),
Type.Literal("read"),
Type.Literal("mixed"),
]);
/** One file path referenced by a session transcript. */
export const SessionFileEntrySchema = Type.Object(
{
path: NonEmptyString,
name: NonEmptyString,
kind: SessionFileKindSchema,
missing: Type.Boolean(),
size: Type.Optional(Type.Integer({ minimum: 0 })),
updatedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
content: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
/** One file or folder in the session-rooted browser. */
export const SessionFileBrowserEntrySchema = Type.Object(
{
path: Type.String(),
name: NonEmptyString,
kind: Type.Union([Type.Literal("file"), Type.Literal("directory")]),
sessionKind: Type.Optional(SessionFileRelevanceSchema),
size: Type.Optional(Type.Integer({ minimum: 0 })),
updatedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
/** Folder listing or search result rooted at the session workspace. */
export const SessionFileBrowserResultSchema = Type.Object(
{
path: Type.String(),
parentPath: Type.Optional(Type.String()),
search: Type.Optional(Type.String()),
entries: Type.Array(SessionFileBrowserEntrySchema),
truncated: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
/** Lists files touched by a session transcript. */
export const SessionsFilesListParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
path: Type.Optional(Type.String()),
search: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
/** File references visible in one session workspace. */
export const SessionsFilesListResultSchema = Type.Object(
{
sessionKey: NonEmptyString,
root: Type.Optional(NonEmptyString),
files: Type.Array(SessionFileEntrySchema),
browser: Type.Optional(SessionFileBrowserResultSchema),
},
{ additionalProperties: false },
);
/** Reads one session-referenced file by path. */
export const SessionsFilesGetParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
path: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
/** Result for reading one session-referenced file. */
export const SessionsFilesGetResultSchema = Type.Object(
{
sessionKey: NonEmptyString,
root: Type.Optional(NonEmptyString),
file: SessionFileEntrySchema,
},
{ additionalProperties: false },
);
/** Lists sessions with optional scope, activity, label, and preview filters. */
export const SessionsListParamsSchema = Type.Object(
{
@@ -179,6 +179,15 @@ export type AgentsFilesGetParams = SchemaType<"AgentsFilesGetParams">;
export type AgentsFilesGetResult = SchemaType<"AgentsFilesGetResult">;
export type AgentsFilesSetParams = SchemaType<"AgentsFilesSetParams">;
export type AgentsFilesSetResult = SchemaType<"AgentsFilesSetResult">;
export type SessionFileKind = SchemaType<"SessionFileKind">;
export type SessionFileRelevance = SchemaType<"SessionFileRelevance">;
export type SessionFileEntry = SchemaType<"SessionFileEntry">;
export type SessionFileBrowserEntry = SchemaType<"SessionFileBrowserEntry">;
export type SessionFileBrowserResult = SchemaType<"SessionFileBrowserResult">;
export type SessionsFilesListParams = SchemaType<"SessionsFilesListParams">;
export type SessionsFilesListResult = SchemaType<"SessionsFilesListResult">;
export type SessionsFilesGetParams = SchemaType<"SessionsFilesGetParams">;
export type SessionsFilesGetResult = SchemaType<"SessionsFilesGetResult">;
export type ArtifactSummary = SchemaType<"ArtifactSummary">;
export type ArtifactsListParams = SchemaType<"ArtifactsListParams">;
export type ArtifactsListResult = SchemaType<"ArtifactsListResult">;
+240 -3
View File
@@ -14,6 +14,7 @@ import {
} from "../ui/vite.config.ts";
type CliOptions = {
allowedHosts: string[];
host: string;
port: number;
};
@@ -33,10 +34,20 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
const uiRoot = path.join(repoRoot, "ui");
function parseArgs(args: string[]): CliOptions {
const options: CliOptions = { host: "127.0.0.1", port: 5187 };
const options: CliOptions = { allowedHosts: [], host: "127.0.0.1", port: 5187 };
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--host") {
if (arg === "--allowed-host") {
const allowedHost = args[++i]?.trim();
if (allowedHost) {
options.allowedHosts.push(allowedHost);
}
} else if (arg.startsWith("--allowed-host=")) {
const allowedHost = arg.slice("--allowed-host=".length).trim();
if (allowedHost) {
options.allowedHosts.push(allowedHost);
}
} else if (arg === "--host") {
options.host = args[++i] ?? options.host;
} else if (arg.startsWith("--host=")) {
options.host = arg.slice("--host=".length) || options.host;
@@ -253,6 +264,146 @@ function createChatPickerScenario(): ControlUiMockGatewayScenario {
},
})),
);
const sessionFiles = [
{
kind: "modified",
missing: false,
name: "chat.ts",
path: "ui/src/ui/views/chat.ts",
size: 48320,
updatedAtMs: baseTime - 20_000,
},
{
kind: "modified",
missing: false,
name: "sidebar.css",
path: "ui/src/styles/chat/sidebar.css",
size: 18840,
updatedAtMs: baseTime - 18_000,
},
{
kind: "read",
missing: false,
name: "artifacts.ts",
path: "src/gateway/server-methods/artifacts.ts",
size: 21876,
updatedAtMs: baseTime - 300_000,
},
{
kind: "read",
missing: false,
name: "sessions.ts",
path: "packages/gateway-protocol/src/schema/sessions.ts",
size: 16542,
updatedAtMs: baseTime - 420_000,
},
];
const sessionWorkspaceRoot = repoRoot;
const sessionFileContentByPath = new Map([
[
"ui/src/ui/views/chat.ts",
'function renderSessionWorkspaceRail() {\n return html`<aside class="chat-workspace-rail">...</aside>`;\n}\n',
],
[
"ui/src/styles/chat/sidebar.css",
".chat-workspace-rail__section-title {\n color: var(--muted);\n text-transform: uppercase;\n}\n",
],
[
"src/gateway/server-methods/artifacts.ts",
"// Artifact gateway methods collect generated artifacts from session transcripts.\n",
],
[
"packages/gateway-protocol/src/schema/sessions.ts",
"export const SessionsFilesListParamsSchema = Type.Object({ sessionKey: NonEmptyString });\n",
],
[
"package.json",
'{\n "name": "openclaw",\n "scripts": { "dev:ui:mock": "tsx scripts/control-ui-mock-dev.ts" }\n}\n',
],
[
"ui/vite.config.ts",
"export default function controlUiViteConfig() {\n return { server: { strictPort: true } };\n}\n",
],
[
"ui/src/ui/e2e/chat-flow.e2e.test.ts",
"it('keeps the session workspace useful while browsing files', async () => {\n await page.getByText('Project files').waitFor();\n});\n",
],
]);
const sessionFileCases = [
{
match: { sessionKey: "agent:alpha" },
response: {
browser: {
entries: [
{
kind: "directory",
name: "packages",
path: "packages",
sessionKind: "read",
updatedAtMs: baseTime - 420_000,
},
{
kind: "directory",
name: "src",
path: "src",
sessionKind: "read",
updatedAtMs: baseTime - 300_000,
},
{
kind: "directory",
name: "ui",
path: "ui",
sessionKind: "modified",
updatedAtMs: baseTime - 20_000,
},
{
kind: "file",
name: "package.json",
path: "package.json",
size: 92750,
updatedAtMs: baseTime - 800_000,
},
],
path: "",
},
files: sessionFiles,
root: sessionWorkspaceRoot,
sessionKey: "agent:alpha",
},
},
];
const sessionFileGetCases = sessionFiles.map((file) => ({
match: { sessionKey: "agent:alpha", path: file.path },
response: {
file: {
...file,
content: sessionFileContentByPath.get(file.path) ?? "",
},
root: sessionWorkspaceRoot,
sessionKey: "agent:alpha",
},
}));
const lobsterSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
<rect width="640" height="360" fill="#10151d"/>
<circle cx="320" cy="185" r="76" fill="#e23f3f"/>
<ellipse cx="250" cy="178" rx="54" ry="38" fill="#f05a52"/>
<ellipse cx="390" cy="178" rx="54" ry="38" fill="#f05a52"/>
<circle cx="292" cy="145" r="10" fill="#0b0f14"/>
<circle cx="348" cy="145" r="10" fill="#0b0f14"/>
<path d="M232 114c-72-44-135-22-146 35 52 9 91-4 125-39" fill="none" stroke="#f06b5f" stroke-width="28" stroke-linecap="round"/>
<path d="M408 114c72-44 135-22 146 35-52 9-91-4-125-39" fill="none" stroke="#f06b5f" stroke-width="28" stroke-linecap="round"/>
<path d="M232 246c-45 28-91 35-142 23M408 246c45 28 91 35 142 23" fill="none" stroke="#e14b47" stroke-width="16" stroke-linecap="round"/>
<text x="320" y="326" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="24" fill="#f6f7f9">openclaw session artifact</text>
</svg>`;
const lobsterArtifact = {
id: "artifact-openclaw-lobster",
type: "image",
title: "openclaw-lobster-preview.svg",
mimeType: "image/svg+xml",
sizeBytes: Buffer.byteLength(lobsterSvg, "utf8"),
source: "session-transcript",
download: { mode: "bytes" },
};
const sessions = [
sessionRow("agent:alpha", "Alpha planning", baseTime - 1_000),
...buildSessionRows({
@@ -288,6 +439,91 @@ function createChatPickerScenario(): ControlUiMockGatewayScenario {
"agents.files.list": {
cases: workspaceListCases,
},
"sessions.files.get": {
cases: sessionFileGetCases,
},
"sessions.files.list": {
cases: [
{
match: { sessionKey: "agent:alpha", path: "ui" },
response: {
browser: {
entries: [
{
kind: "directory",
name: "src",
path: "ui/src",
sessionKind: "modified",
updatedAtMs: baseTime - 20_000,
},
{
kind: "file",
name: "vite.config.ts",
path: "ui/vite.config.ts",
size: 9860,
updatedAtMs: baseTime - 900_000,
},
],
parentPath: "",
path: "ui",
},
files: sessionFiles,
root: sessionWorkspaceRoot,
sessionKey: "agent:alpha",
},
},
{
match: { sessionKey: "agent:alpha", search: "chat" },
response: {
browser: {
entries: [
{
kind: "file",
name: "chat.ts",
path: "ui/src/ui/views/chat.ts",
sessionKind: "modified",
size: 48320,
updatedAtMs: baseTime - 20_000,
},
{
kind: "file",
name: "chat-flow.e2e.test.ts",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
size: 24950,
updatedAtMs: baseTime - 25_000,
},
],
path: "",
search: "chat",
},
files: sessionFiles,
root: sessionWorkspaceRoot,
sessionKey: "agent:alpha",
},
},
...sessionFileCases,
],
},
"artifacts.list": {
cases: [
{
match: { sessionKey: "agent:alpha" },
response: { artifacts: [lobsterArtifact] },
},
],
},
"artifacts.download": {
cases: [
{
match: { sessionKey: "agent:alpha", artifactId: lobsterArtifact.id },
response: {
artifact: lobsterArtifact,
data: Buffer.from(lobsterSvg, "utf8").toString("base64"),
encoding: "base64",
},
},
],
},
"sessions.list": {
cases: [
...buildSearchSessionListCases(telegramSessions, searchPrefixes("telegram")),
@@ -375,9 +611,10 @@ const server = await createServer({
},
root: uiRoot,
server: {
allowedHosts: options.allowedHosts,
host: options.host,
port: options.port,
strictPort: false,
strictPort: true,
},
});
+2
View File
@@ -106,6 +106,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "agents.files.list", scope: "operator.read" },
{ name: "agents.files.get", scope: "operator.read" },
{ name: "agents.files.set", scope: "operator.admin" },
{ name: "sessions.files.list", scope: "operator.read" },
{ name: "sessions.files.get", scope: "operator.read" },
{ name: "artifacts.list", scope: "operator.read" },
{ name: "artifacts.get", scope: "operator.read" },
{ name: "artifacts.download", scope: "operator.read" },
+8
View File
@@ -157,6 +157,10 @@ const loadSendHandlers = lazyHandlerModule(
() => import("./server-methods/send.js"),
(module) => module.sendHandlers,
);
const loadSessionsFilesHandlers = lazyHandlerModule(
() => import("./server-methods/sessions-files.js"),
(module) => module.sessionsFilesHandlers,
);
const loadSessionsHandlers = lazyHandlerModule(
() => import("./server-methods/sessions.js"),
(module) => module.sessionsHandlers,
@@ -575,6 +579,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
methods: ["artifacts.list", "artifacts.get", "artifacts.download"],
loadHandlers: loadArtifactsHandlers,
}),
...createLazyCoreHandlers({
methods: ["sessions.files.list", "sessions.files.get"],
loadHandlers: loadSessionsFilesHandlers,
}),
};
/** Builds the per-request method registry from core, plugin, and explicit extra handlers. */
@@ -0,0 +1,633 @@
// Session file method tests cover transcript-linked files plus the workspace browser.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sessionsFilesHandlers } from "./sessions-files.js";
const hoisted = vi.hoisted(() => ({
loadSessionEntry: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
visitSessionMessagesAsync: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: hoisted.resolveAgentWorkspaceDir,
resolveDefaultAgentId: hoisted.resolveDefaultAgentId,
}));
vi.mock("../session-utils.js", async () => {
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
return {
...actual,
loadSessionEntry: hoisted.loadSessionEntry,
visitSessionMessagesAsync: hoisted.visitSessionMessagesAsync,
};
});
function createResponder() {
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
return {
calls,
respond: (ok: boolean, payload?: unknown, error?: unknown) => {
calls.push({ ok, payload, error });
},
};
}
type SessionFilesMethod = "sessions.files.list" | "sessions.files.get";
async function invokeSessionFilesHandler(
method: SessionFilesMethod,
params: Record<string, unknown>,
) {
const responder = createResponder();
await sessionsFilesHandlers[method]?.({
req: { type: "req", id: method, method, params: {} },
params,
client: null,
isWebchatConnect: () => false,
respond: responder.respond,
context: {} as never,
});
return responder.calls;
}
function expectOkPayload(calls: ReturnType<typeof createResponder>["calls"]): Record<string, any> {
expect(calls).toHaveLength(1);
expect(calls[0]?.ok).toBe(true);
return calls[0]?.payload as Record<string, any>;
}
function expectError(calls: ReturnType<typeof createResponder>["calls"]): Record<string, any> {
expect(calls).toHaveLength(1);
expect(calls[0]?.ok).toBe(false);
return calls[0]?.error as Record<string, any>;
}
function assistantToolCall(name: string, args: Record<string, unknown>) {
return {
role: "assistant",
content: [
{
type: "toolCall",
name,
arguments: args,
},
],
};
}
function writeWorkspaceFile(root: string, filePath: string, content: string) {
const resolved = path.join(root, filePath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, "utf8");
}
describe("sessions.files RPC handlers", () => {
let workspaceRoot: string;
beforeEach(() => {
vi.clearAllMocks();
workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-files-test-"));
hoisted.resolveDefaultAgentId.mockReturnValue("main");
hoisted.resolveAgentWorkspaceDir.mockReturnValue(workspaceRoot);
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");
writeWorkspaceFile(workspaceRoot, "ui/vite.config.ts", "export default {};\n");
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath: path.join(workspaceRoot, ".sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
spawnedCwd: workspaceRoot,
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
[
assistantToolCall("edit", { path: "ui/chat.ts" }),
assistantToolCall("read", { path: "src/readme.md" }),
assistantToolCall("apply_patch", {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
}),
].forEach((message, index) => visit(message, index + 1));
return 3;
},
);
});
afterEach(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it("lists session-touched files with a browser rooted at the session workspace", async () => {
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.root).toBe(workspaceRoot);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/chat.ts", "modified"],
["src/readme.md", "read"],
]);
expect(payload.browser.path).toBe("");
expect(
payload.browser.entries.map((entry: Record<string, unknown>) => [
entry.path,
entry.kind,
entry.sessionKind,
]),
).toEqual([
["src", "directory", "read"],
["ui", "directory", "modified"],
["package.json", "file", "modified"],
]);
});
it("collects touched files from existing transcript tool-call spellings", async () => {
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(
{
role: "assistant",
content: [
{ type: "tool_use", name: "read", input: { path: "src/readme.md" } },
{ type: "toolcall", name: "edit", arguments: { path: "ui/vite.config.ts" } },
{ type: "tool_use", name: "read", args: { path: "ui/chat.ts" } },
{
type: "tool_call",
name: "apply_patch",
input: {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
},
},
],
},
1,
);
return 1;
},
);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/vite.config.ts", "modified"],
["src/readme.md", "read"],
["ui/chat.ts", "read"],
]);
});
it("collects changed files from structured apply_patch changes", async () => {
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(
assistantToolCall("apply_patch", {
changes: [
{ path: "ui/chat.ts", kind: "update" },
{ path: "src/readme.md", kind: "delete" },
{ path: "old-name.md", kind: { type: "update", move_path: "package.json" } },
],
}),
1,
);
return 1;
},
);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["old-name.md", "modified"],
["package.json", "modified"],
["src/readme.md", "modified"],
["ui/chat.ts", "modified"],
]);
});
it("prefers the spawned workspace root over a nested spawned cwd", async () => {
const nestedCwd = path.join(workspaceRoot, "packages/app");
fs.mkdirSync(nestedCwd, { recursive: true });
writeWorkspaceFile(workspaceRoot, "packages/app/src/readme.md", "# Nested read me\n");
writeWorkspaceFile(workspaceRoot, "packages/shared/config.ts", "export const shared = true;\n");
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath: path.join(workspaceRoot, ".sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
spawnedCwd: nestedCwd,
spawnedWorkspaceDir: workspaceRoot,
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
visit(assistantToolCall("read", { path: "../shared/config.ts" }), 2);
return 2;
},
);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.root).toBe(workspaceRoot);
expect(payload.files).toEqual([
expect.objectContaining({
missing: false,
path: "../shared/config.ts",
}),
expect.objectContaining({
missing: false,
path: "src/readme.md",
}),
]);
expect(
payload.browser.entries.map((entry: Record<string, unknown>) => [
entry.path,
entry.kind,
entry.sessionKind,
]),
).toEqual([
["packages", "directory", "read"],
["src", "directory", undefined],
["ui", "directory", undefined],
["package.json", "file", undefined],
]);
const preview = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "src/readme.md",
}),
);
expect(preview.file.content).toBe("# Nested read me\n");
const browserPreview = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "packages/app/src/readme.md",
}),
);
expect(browserPreview.file.content).toBe("# Nested read me\n");
const parentRelativePreview = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "../shared/config.ts",
}),
);
expect(parentRelativePreview.file.content).toBe("export const shared = true;\n");
const parentRelativeBrowserPreview = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "packages/shared/config.ts",
}),
);
expect(parentRelativeBrowserPreview.file.content).toBe("export const shared = true;\n");
});
it("falls back to the configured agent workspace for sessions without spawned metadata", async () => {
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath: path.join(workspaceRoot, ".sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
},
);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(hoisted.resolveAgentWorkspaceDir).toHaveBeenCalledWith(expect.any(Object), "main");
expect(payload.root).toBe(workspaceRoot);
expect(payload.files).toEqual([
expect.objectContaining({
missing: false,
path: "src/readme.md",
}),
]);
expect(payload.browser).toBeDefined();
});
it("uses the canonical session owner for configured workspace fallback", async () => {
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:aiden:main",
cfg: {},
storePath: path.join(workspaceRoot, ".sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
},
);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(hoisted.resolveAgentWorkspaceDir).toHaveBeenCalledWith(expect.any(Object), "aiden");
expect(payload.root).toBe(workspaceRoot);
expect(payload.files).toEqual([
expect.objectContaining({
missing: false,
path: "src/readme.md",
}),
]);
});
it("browses and searches workspace files without previewing browser-only files", async () => {
const folderPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
path: "ui",
}),
);
expect(folderPayload.browser.parentPath).toBe("");
expect(
folderPayload.browser.entries.map((entry: Record<string, unknown>) => [
entry.path,
entry.kind,
entry.sessionKind,
]),
).toEqual([
["ui/chat.ts", "file", "modified"],
["ui/vite.config.ts", "file", undefined],
]);
const searchPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
search: "vite",
}),
);
expect(searchPayload.browser.search).toBe("vite");
expect(
searchPayload.browser.entries.map((entry: Record<string, unknown>) => entry.path),
).toEqual(["ui/vite.config.ts"]);
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "ui/vite.config.ts",
}),
);
expect(error.details).toMatchObject({
path: "ui/vite.config.ts",
type: "session_file_not_found",
});
});
it("truncates broad workspace searches by visited entries, not only by matches", async () => {
for (let index = 0; index < 5_025; index += 1) {
writeWorkspaceFile(workspaceRoot, `bulk-${String(index).padStart(4, "0")}.txt`, "");
}
writeWorkspaceFile(workspaceRoot, "zz-tail-needle.ts", "export const needle = true;\n");
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
search: "needle",
}),
);
expect(payload.browser).toMatchObject({
search: "needle",
truncated: true,
});
expect(payload.browser.entries).toEqual([]);
});
it("does not read absolute paths outside the configured workspace", async () => {
const outsidePath = path.join(os.tmpdir(), `openclaw-outside-${Date.now()}.txt`);
fs.writeFileSync(outsidePath, "outside\n", "utf8");
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath: path.join(workspaceRoot, ".sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "missing-session.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: outsidePath }), 1);
return 1;
},
);
try {
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: outsidePath,
}),
);
expect(error.details).toMatchObject({
path: outsidePath,
type: "session_file_not_found",
});
} finally {
fs.rmSync(outsidePath, { force: true });
}
});
it("does not follow workspace symlinks for file previews", async () => {
const outsidePath = path.join(os.tmpdir(), `openclaw-linked-${Date.now()}.txt`);
fs.writeFileSync(outsidePath, "linked outside\n", "utf8");
fs.symlinkSync(outsidePath, path.join(workspaceRoot, "linked.txt"));
try {
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "linked.txt",
}),
);
expect(error.details).toMatchObject({
path: "linked.txt",
type: "session_file_not_found",
});
} finally {
fs.rmSync(outsidePath, { force: true });
}
});
it("does not follow symlinked parent directories for file previews", async () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linked-parent-"));
writeWorkspaceFile(outsideDir, "secret.txt", "linked parent outside\n");
fs.symlinkSync(outsideDir, path.join(workspaceRoot, "linked-dir"), "dir");
try {
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "linked-dir/secret.txt",
}),
);
expect(error.details).toMatchObject({
path: "linked-dir/secret.txt",
type: "session_file_not_found",
});
} finally {
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
it("returns integer file timestamps for protocol responses", async () => {
const datedPath = path.join(workspaceRoot, "dated.txt");
writeWorkspaceFile(workspaceRoot, "dated.txt", "dated\n");
fs.utimesSync(datedPath, 1_700_000_000.123, 1_700_000_000.123);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
const entry = payload.browser.entries.find(
(browserEntry: Record<string, unknown>) => browserEntry.path === "dated.txt",
);
expect(Number.isInteger(entry.updatedAtMs)).toBe(true);
});
it("does not browse paths outside the session workspace root", async () => {
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
path: "../",
}),
);
expect(payload.root).toBe(workspaceRoot);
expect(payload.browser).toBeUndefined();
});
it("does not derive a workspace root from transcript cwd", async () => {
const sessionsDir = path.join(workspaceRoot, "custom-sessions");
const transcriptCwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-transcript-cwd-"));
writeWorkspaceFile(transcriptCwd, "secret.txt", "transcript cwd secret\n");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, "sess-main.jsonl"),
`${JSON.stringify({ cwd: transcriptCwd })}\n`,
"utf8",
);
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath: path.join(sessionsDir, "sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "secret.txt" }), 1);
return 1;
},
);
try {
const listPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(listPayload.root).toBe(workspaceRoot);
expect(listPayload.browser).toBeDefined();
expect(listPayload.files).toMatchObject([
{
missing: true,
path: "secret.txt",
},
]);
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "secret.txt",
}),
);
expect(error.details).toMatchObject({
path: "secret.txt",
type: "session_file_not_found",
});
} finally {
fs.rmSync(transcriptCwd, { recursive: true, force: true });
}
});
it("reports oversized existing files without marking them missing", async () => {
writeWorkspaceFile(workspaceRoot, "large.log", "x".repeat(260 * 1024));
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "large.log" }), 1);
return 1;
},
);
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
sessionKey: "agent:main:main",
path: "large.log",
}),
);
expect(error.details).toMatchObject({
maxPreviewBytes: 256 * 1024,
path: "large.log",
size: 260 * 1024,
type: "session_file_too_large",
});
});
});
@@ -0,0 +1,737 @@
// Gateway methods expose files referenced by one session transcript.
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
type SessionFileBrowserEntry,
type SessionFileBrowserResult,
type SessionFileEntry,
type SessionFileRelevance,
type SessionsFilesGetParams,
validateSessionsFilesGetParams,
validateSessionsFilesListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { root as fsSafeRoot, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { loadSessionEntry, visitSessionMessagesAsync } from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
type FileKind = "modified" | "read";
type TouchedFile = {
path: string;
kind: FileKind;
};
type WorkspaceRoot = Awaited<ReturnType<typeof fsSafeRoot>>;
type WorkspacePathStat = Awaited<ReturnType<WorkspaceRoot["stat"]>>;
type WorkspaceDirEntry = WorkspacePathStat & { name: string };
type LoadedSessionFiles = {
root?: string;
fileRoot?: string;
files: TouchedFile[];
};
const MAX_PREVIEW_BYTES = 256 * 1024;
const MAX_BROWSER_ENTRIES = 250;
const MAX_SEARCH_ENTRIES = 500;
const MAX_SEARCH_VISITED_ENTRIES = 5_000;
const SEARCH_SKIP_DIRS = new Set([
".git",
".hg",
".next",
".turbo",
".yarn",
"coverage",
"dist",
"node_modules",
]);
function sessionFilesError(type: string, message: string, details?: Record<string, unknown>) {
return errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
type,
...details,
},
});
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function normalizePathValue(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function readPathArg(args: Record<string, unknown>): string | undefined {
return (
normalizePathValue(args.path) ??
normalizePathValue(args.file_path) ??
normalizePathValue(args.filePath) ??
normalizePathValue(args.file)
);
}
function addTouchedFile(
files: Map<string, TouchedFile>,
filePath: string | undefined,
kind: FileKind,
) {
if (!filePath) {
return;
}
const existing = files.get(filePath);
if (existing?.kind === "modified" || (existing && kind === "read")) {
return;
}
files.set(filePath, { path: filePath, kind });
}
function addRawPatchFiles(files: Map<string, TouchedFile>, input: unknown) {
if (typeof input !== "string") {
return;
}
const fileLinePattern = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
for (const match of input.matchAll(fileLinePattern)) {
addTouchedFile(files, match[1]?.trim(), "modified");
}
const moveLinePattern = /^\*\*\* Move to: (.+)$/gm;
for (const match of input.matchAll(moveLinePattern)) {
addTouchedFile(files, match[1]?.trim(), "modified");
}
}
function addStructuredPatchFiles(files: Map<string, TouchedFile>, changes: unknown) {
if (!Array.isArray(changes)) {
return;
}
for (const changeValue of changes) {
const change = asRecord(changeValue);
addTouchedFile(files, normalizePathValue(change?.path), "modified");
const kind = asRecord(change?.kind);
addTouchedFile(
files,
normalizePathValue(kind?.move_path) ?? normalizePathValue(kind?.movePath),
"modified",
);
}
}
function addPatchFiles(files: Map<string, TouchedFile>, args: Record<string, unknown>) {
addRawPatchFiles(files, args.input);
addStructuredPatchFiles(files, args.changes);
}
function isToolCallBlockType(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
const normalized = value.toLowerCase().replace(/[_-]/g, "");
return normalized === "toolcall" || normalized === "tooluse";
}
function collectTouchedFilesFromMessage(message: unknown, files: Map<string, TouchedFile>) {
const record = asRecord(message);
if (record?.role !== "assistant" || !Array.isArray(record.content)) {
return;
}
for (const blockValue of record.content) {
const block = asRecord(blockValue);
if (!block || !isToolCallBlockType(block.type)) {
continue;
}
const toolName = normalizeOptionalString(block.name)?.toLowerCase();
const args = asRecord(block.arguments) ?? asRecord(block.input) ?? asRecord(block.args);
if (!toolName || !args) {
continue;
}
if (toolName === "read") {
addTouchedFile(files, readPathArg(args), "read");
} else if (toolName === "write" || toolName === "edit") {
addTouchedFile(files, readPathArg(args), "modified");
} else if (toolName === "apply_patch") {
addPatchFiles(files, args);
}
}
}
function toDisplayPath(root: string, resolved: string): string {
const relative = path.relative(root, resolved);
if (!relative) {
return "";
}
return relative.split(path.sep).join("/");
}
function normalizeRelativePath(value: string | undefined): string {
if (!value) {
return "";
}
return value
.replaceAll("\\", "/")
.split("/")
.filter((part) => part && part !== ".")
.join("/");
}
function resolveWorkspacePath(root: string | undefined, filePath: string): string | undefined {
if (!root) {
return undefined;
}
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(root, filePath);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return undefined;
}
return resolved;
}
function isInsideRoot(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return !relative.startsWith("..") && !path.isAbsolute(relative);
}
function resolveTouchedFilePath(params: {
root: string | undefined;
fileRoot: string | undefined;
filePath: string;
}): string | undefined {
if (!params.root) {
return undefined;
}
const base = params.fileRoot ?? params.root;
const resolved = path.isAbsolute(params.filePath)
? path.resolve(params.filePath)
: path.resolve(base, params.filePath);
if (!isInsideRoot(params.root, resolved)) {
return undefined;
}
return resolved;
}
function resolveFileRoot(params: {
root: string | undefined;
spawnedCwd: string | undefined;
}): string | undefined {
if (!params.root) {
return undefined;
}
if (!params.spawnedCwd) {
return params.root;
}
const resolvedCwd = path.resolve(params.spawnedCwd);
const resolvedRoot = path.resolve(params.root);
return isInsideRoot(resolvedRoot, resolvedCwd) ? params.spawnedCwd : params.root;
}
async function openSessionWorkspaceRoot(rootDir: string): Promise<WorkspaceRoot | undefined> {
try {
return await fsSafeRoot(rootDir, {
hardlinks: "reject",
maxBytes: MAX_PREVIEW_BYTES,
nonBlockingRead: true,
symlinks: "reject",
});
} catch {
return undefined;
}
}
async function statWorkspacePath(
rootDir: string,
browserPath: string,
): Promise<WorkspacePathStat | undefined> {
const workspaceRoot = await openSessionWorkspaceRoot(rootDir);
if (!workspaceRoot) {
return undefined;
}
try {
return await workspaceRoot.stat(browserPath || ".");
} catch {
return undefined;
}
}
async function listWorkspacePath(
rootDir: string,
browserPath: string,
): Promise<WorkspaceDirEntry[] | undefined> {
const workspaceRoot = await openSessionWorkspaceRoot(rootDir);
if (!workspaceRoot) {
return undefined;
}
try {
return await workspaceRoot.list(browserPath || ".", { withFileTypes: true });
} catch {
return undefined;
}
}
async function readWorkspaceFile(
rootDir: string,
browserPath: string,
): Promise<ReadResult | undefined | "too-large"> {
const workspaceRoot = await openSessionWorkspaceRoot(rootDir);
if (!workspaceRoot) {
return undefined;
}
try {
return await workspaceRoot.read(browserPath, {
hardlinks: "reject",
maxBytes: MAX_PREVIEW_BYTES,
nonBlockingRead: true,
symlinks: "reject",
});
} catch (err) {
if (err instanceof FsSafeError && err.code === "too-large") {
return "too-large";
}
return undefined;
}
}
function relevanceForKind(kind: FileKind): SessionFileRelevance {
return kind;
}
function mergeRelevance(
current: SessionFileRelevance | undefined,
next: SessionFileRelevance | undefined,
): SessionFileRelevance | undefined {
if (!current) {
return next;
}
if (!next || current === next) {
return current;
}
return "mixed";
}
function buildSessionRelevanceMap(
files: readonly TouchedFile[],
root: string | undefined,
fileRoot: string | undefined,
): Map<string, SessionFileRelevance> {
const relevance = new Map<string, SessionFileRelevance>();
if (!root) {
for (const file of files) {
relevance.set(normalizeRelativePath(file.path), relevanceForKind(file.kind));
}
return relevance;
}
for (const file of files) {
const resolved = resolveTouchedFilePath({ root, fileRoot, filePath: file.path });
if (!resolved) {
continue;
}
relevance.set(toDisplayPath(root, resolved), relevanceForKind(file.kind));
}
return relevance;
}
function relevanceForBrowserPath(
browserPath: string,
kind: "file" | "directory",
relevance: ReadonlyMap<string, SessionFileRelevance>,
): SessionFileRelevance | undefined {
if (kind === "file") {
return relevance.get(browserPath);
}
const prefix = browserPath ? `${browserPath}/` : "";
let aggregate: SessionFileRelevance | undefined;
for (const [filePath, sessionKind] of relevance) {
if (filePath.startsWith(prefix) && filePath !== browserPath) {
aggregate = mergeRelevance(aggregate, sessionKind);
}
}
return aggregate;
}
function displayNameForPath(filePath: string): string {
const base = path.basename(filePath);
return base || filePath;
}
function toUpdatedAtMs(mtimeMs: number): number {
return Math.floor(mtimeMs);
}
function workspaceStatKind(stat: WorkspacePathStat): "file" | "directory" | "symlink" | undefined {
const kind = (stat as { kind?: unknown }).kind;
if (kind === "file" || kind === "directory" || kind === "symlink") {
return kind;
}
const nodeStat = stat as {
isDirectory?: boolean | (() => boolean);
isFile?: boolean | (() => boolean);
isSymbolicLink?: boolean | (() => boolean);
};
const isFile = typeof nodeStat.isFile === "function" ? nodeStat.isFile() : nodeStat.isFile;
if (isFile) {
return "file";
}
const isDirectory =
typeof nodeStat.isDirectory === "function" ? nodeStat.isDirectory() : nodeStat.isDirectory;
if (isDirectory) {
return "directory";
}
const isSymbolicLink =
typeof nodeStat.isSymbolicLink === "function"
? nodeStat.isSymbolicLink()
: nodeStat.isSymbolicLink;
return isSymbolicLink ? "symlink" : undefined;
}
async function toSessionFileEntry(
touched: TouchedFile,
root: string | undefined,
fileRoot: string | undefined,
opts: { includeContent?: boolean } = {},
): Promise<SessionFileEntry> {
const resolved = resolveTouchedFilePath({ root, fileRoot, filePath: touched.path });
const base = {
path: touched.path,
name: displayNameForPath(touched.path),
kind: touched.kind,
} satisfies Pick<SessionFileEntry, "path" | "name" | "kind">;
if (!resolved) {
return { ...base, missing: true };
}
const browserPath = toDisplayPath(root!, resolved);
const stat = await statWorkspacePath(root!, browserPath);
if (!stat || workspaceStatKind(stat) !== "file") {
return { ...base, missing: true };
}
const entry: SessionFileEntry = {
...base,
missing: false,
size: stat.size,
updatedAtMs: toUpdatedAtMs(stat.mtimeMs),
};
if (opts.includeContent && stat.size <= MAX_PREVIEW_BYTES) {
const read = await readWorkspaceFile(root!, browserPath);
if (!read) {
return { ...base, missing: true };
}
if (read !== "too-large") {
entry.size = read.stat.size;
entry.updatedAtMs = toUpdatedAtMs(read.stat.mtimeMs);
entry.content = read.buffer.toString("utf8");
}
}
return entry;
}
async function toBrowserEntry(
browserPath: string,
dirent: WorkspaceDirEntry,
relevance: ReadonlyMap<string, SessionFileRelevance>,
): Promise<SessionFileBrowserEntry | undefined> {
const statKind = workspaceStatKind(dirent);
const kind = statKind === "directory" ? "directory" : statKind === "file" ? "file" : null;
if (!kind) {
return undefined;
}
const sessionKind = relevanceForBrowserPath(browserPath, kind, relevance);
return {
path: browserPath,
name: dirent.name,
kind,
...(kind === "file" ? { size: dirent.size } : {}),
updatedAtMs: toUpdatedAtMs(dirent.mtimeMs),
...(sessionKind ? { sessionKind } : {}),
};
}
function sortBrowserEntries(
entries: readonly SessionFileBrowserEntry[],
): SessionFileBrowserEntry[] {
return entries.toSorted((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
}
function sortDirents<T extends { name: string }>(dirents: readonly T[]): T[] {
return dirents.toSorted((a, b) => a.name.localeCompare(b.name));
}
function matchesSearch(entryPath: string, name: string, query: string): boolean {
const normalizedQuery = query.toLowerCase();
return (
name.toLowerCase().includes(normalizedQuery) ||
entryPath.toLowerCase().includes(normalizedQuery)
);
}
async function searchBrowserEntries(params: {
root: string;
query: string;
relevance: ReadonlyMap<string, SessionFileRelevance>;
}): Promise<{ entries: SessionFileBrowserEntry[]; truncated?: boolean }> {
const entries: SessionFileBrowserEntry[] = [];
let visitedEntries = 0;
let truncated = false;
const shouldStop = (): boolean => {
if (entries.length >= MAX_SEARCH_ENTRIES || visitedEntries >= MAX_SEARCH_VISITED_ENTRIES) {
truncated = true;
return true;
}
return false;
};
const visit = async (dir: string): Promise<void> => {
if (shouldStop()) {
return;
}
const dirents = await listWorkspacePath(params.root, dir);
if (!dirents) {
return;
}
for (const dirent of sortDirents(dirents)) {
if (shouldStop()) {
return;
}
visitedEntries += 1;
const browserPath = dir ? `${dir}/${dirent.name}` : dirent.name;
if (matchesSearch(browserPath, dirent.name, params.query)) {
const entry = await toBrowserEntry(browserPath, dirent, params.relevance);
if (entry) {
entries.push(entry);
}
}
if (workspaceStatKind(dirent) === "directory" && !SEARCH_SKIP_DIRS.has(dirent.name)) {
await visit(browserPath);
}
}
};
await visit("");
return { entries: sortBrowserEntries(entries), ...(truncated ? { truncated } : {}) };
}
async function buildBrowserResult(params: {
root: string | undefined;
fileRoot: string | undefined;
path?: string;
search?: string;
files: readonly TouchedFile[];
}): Promise<SessionFileBrowserResult | undefined> {
if (!params.root) {
return undefined;
}
const search = normalizePathValue(params.search);
const relevance = buildSessionRelevanceMap(params.files, params.root, params.fileRoot);
if (search) {
const result = await searchBrowserEntries({
root: params.root,
query: search,
relevance,
});
return {
path: "",
search,
entries: result.entries,
...(result.truncated ? { truncated: result.truncated } : {}),
};
}
const browserPath = normalizeRelativePath(params.path);
const resolved = resolveWorkspacePath(params.root, browserPath);
if (!resolved) {
return undefined;
}
const stat = await statWorkspacePath(params.root, browserPath);
if (!stat || workspaceStatKind(stat) !== "directory") {
return undefined;
}
const dirents = await listWorkspacePath(params.root, browserPath);
if (!dirents) {
return undefined;
}
const entries = (
await Promise.all(
sortDirents(dirents)
.slice(0, MAX_BROWSER_ENTRIES + 1)
.map((dirent) => {
const entryPath = browserPath ? `${browserPath}/${dirent.name}` : dirent.name;
return toBrowserEntry(entryPath, dirent, relevance);
}),
)
).filter((entry): entry is SessionFileBrowserEntry => Boolean(entry));
const parent = path.dirname(browserPath);
return {
path: browserPath,
...(browserPath ? { parentPath: parent === "." ? "" : parent } : {}),
entries: sortBrowserEntries(entries.slice(0, MAX_BROWSER_ENTRIES)),
...(entries.length > MAX_BROWSER_ENTRIES ? { truncated: true } : {}),
};
}
async function loadSessionFiles(params: {
sessionKey: string;
agentId?: string;
}): Promise<LoadedSessionFiles> {
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(params.sessionKey, {
agentId: params.agentId,
});
if (!entry?.sessionId || !storePath) {
return { files: [] };
}
const agentId = normalizeAgentId(
parseAgentSessionKey(canonicalKey)?.agentId ??
params.agentId ??
parseAgentSessionKey(params.sessionKey)?.agentId ??
resolveDefaultAgentId(cfg),
);
const spawnedCwd = normalizePathValue(entry.spawnedCwd);
const root =
normalizePathValue(entry.spawnedWorkspaceDir) ??
spawnedCwd ??
normalizePathValue(resolveAgentWorkspaceDir(cfg, agentId));
const fileRoot = resolveFileRoot({ root, spawnedCwd });
const files = new Map<string, TouchedFile>();
await visitSessionMessagesAsync(
entry.sessionId,
storePath,
entry.sessionFile,
(message) => collectTouchedFilesFromMessage(message, files),
{
mode: "full",
reason: "session files transcript scan",
cache: "reuse",
},
);
return {
root,
fileRoot,
files: [...files.values()].toSorted((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "modified" ? -1 : 1;
}
return a.path.localeCompare(b.path);
}),
};
}
async function buildListResult(params: {
sessionKey: string;
agentId?: string;
path?: string;
search?: string;
}): Promise<{ root?: string; files: SessionFileEntry[]; browser?: SessionFileBrowserResult }> {
const loaded = await loadSessionFiles(params);
const files = await Promise.all(
loaded.files.map((file) => toSessionFileEntry(file, loaded.root, loaded.fileRoot)),
);
const browser = await buildBrowserResult({
root: loaded.root,
fileRoot: loaded.fileRoot,
path: params.path,
search: params.search,
files: loaded.files,
});
return {
...(loaded.root ? { root: loaded.root } : {}),
files,
...(browser ? { browser } : {}),
};
}
async function findSessionFile(
params: SessionsFilesGetParams,
): Promise<{ root?: string; file?: SessionFileEntry }> {
const loaded = await loadSessionFiles(params);
const exactTouched = loaded.files.find((file) => file.path === params.path);
if (exactTouched) {
return {
...(loaded.root ? { root: loaded.root } : {}),
file: await toSessionFileEntry(exactTouched, loaded.root, loaded.fileRoot, {
includeContent: true,
}),
};
}
const resolved = resolveWorkspacePath(loaded.root, params.path);
if (!resolved || !loaded.root) {
return loaded.root ? { root: loaded.root } : {};
}
const relevance = buildSessionRelevanceMap(loaded.files, loaded.root, loaded.fileRoot);
const browserPath = toDisplayPath(loaded.root, resolved);
const sessionKind = relevance.get(browserPath);
if (!sessionKind) {
return loaded.root ? { root: loaded.root } : {};
}
const touched: TouchedFile = {
path: browserPath,
kind: sessionKind === "modified" ? "modified" : "read",
};
return {
...(loaded.root ? { root: loaded.root } : {}),
file: await toSessionFileEntry(touched, loaded.root, loaded.root, {
includeContent: true,
}),
};
}
function respondSessionFileNotFound(respond: RespondFn, filePath: string) {
respond(
false,
undefined,
sessionFilesError("session_file_not_found", "session file not found", { path: filePath }),
);
}
function respondSessionFileTooLarge(respond: RespondFn, file: SessionFileEntry, filePath: string) {
respond(
false,
undefined,
sessionFilesError("session_file_too_large", "session file is too large to preview", {
maxPreviewBytes: MAX_PREVIEW_BYTES,
path: file.path || filePath,
size: file.size,
}),
);
}
/** Gateway handlers for files referenced by session transcripts. */
export const sessionsFilesHandlers: GatewayRequestHandlers = {
"sessions.files.list": async ({ params, respond }) => {
if (
!assertValidParams(params, validateSessionsFilesListParams, "sessions.files.list", respond)
) {
return;
}
const result = await buildListResult(params);
respond(true, {
sessionKey: params.sessionKey,
...result,
});
},
"sessions.files.get": async ({ params, respond }) => {
if (!assertValidParams(params, validateSessionsFilesGetParams, "sessions.files.get", respond)) {
return;
}
const result = await findSessionFile(params);
if (typeof result.file?.content !== "string") {
if (result.file && !result.file.missing) {
respondSessionFileTooLarge(respond, result.file, params.path);
return;
}
respondSessionFileNotFound(respond, params.path);
return;
}
respond(true, {
sessionKey: params.sessionKey,
...result,
});
},
};
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.191Z",
"generatedAt": "2026-06-14T04:03:18.297Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:10.731Z",
"generatedAt": "2026-06-14T04:03:17.952Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:10.822Z",
"generatedAt": "2026-06-14T04:03:18.021Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:12.036Z",
"generatedAt": "2026-06-14T04:03:18.916Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.096Z",
"generatedAt": "2026-06-14T04:03:18.227Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.562Z",
"generatedAt": "2026-06-14T04:03:18.574Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.279Z",
"generatedAt": "2026-06-14T04:03:18.366Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:10.912Z",
"generatedAt": "2026-06-14T04:03:18.090Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.005Z",
"generatedAt": "2026-06-14T04:03:18.160Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.940Z",
"generatedAt": "2026-06-14T04:03:18.847Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.653Z",
"generatedAt": "2026-06-14T04:03:18.642Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:10.631Z",
"generatedAt": "2026-06-14T04:03:17.881Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+1 -1
View File
@@ -4027,7 +4027,7 @@
"text": "Sanitized rich-text preview for quick reading."
},
{
"count": 3,
"count": 4,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/markdown-sidebar.ts",
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.747Z",
"generatedAt": "2026-06-14T04:03:18.710Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.372Z",
"generatedAt": "2026-06-14T04:03:18.435Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.468Z",
"generatedAt": "2026-06-14T04:03:18.507Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:11.843Z",
"generatedAt": "2026-06-14T04:03:18.780Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:10.441Z",
"generatedAt": "2026-06-14T04:03:17.740Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+25 -15
View File
@@ -1,27 +1,37 @@
{
"fallbackKeys": [
"chat.workspaceFiles.actions",
"chat.workspaceFiles.artifactCount",
"chat.workspaceFiles.artifacts",
"chat.workspaceFiles.browser",
"chat.workspaceFiles.browserCount",
"chat.workspaceFiles.changed",
"chat.workspaceFiles.changedCount",
"chat.workspaceFiles.collapse",
"chat.workspaceFiles.copyPath",
"chat.workspaceFiles.empty",
"chat.workspaceFiles.expand",
"chat.workspaceFiles.files",
"chat.workspaceFiles.label",
"chat.workspaceFiles.loading",
"chat.workspaceFiles.missing",
"chat.workspaceFiles.noBrowserFiles",
"chat.workspaceFiles.noSearchResults",
"chat.workspaceFiles.parentFolder",
"chat.workspaceFiles.path",
"chat.workspaceFiles.preview",
"chat.workspaceFiles.read",
"chat.workspaceFiles.readCount",
"chat.workspaceFiles.refresh",
"chat.workspaceFiles.root",
"chat.workspaceFiles.search",
"chat.workspaceFiles.searchResults",
"chat.workspaceFiles.session",
"chat.workspaceFiles.summary",
"chat.workspaceFiles.truncated",
"chat.workspaceFiles.workspace",
"cron.jobDetail.command",
"cron.jobDetail.cwd",
"logsView.autoFollow",
"logsView.empty",
"logsView.exportButton",
"logsView.exportLabels.filtered",
"logsView.exportLabels.visible",
"logsView.file",
"logsView.filter",
"logsView.searchPlaceholder",
"logsView.subtitle",
"logsView.title",
"logsView.truncated",
"skillWorkshop.header.useCurrentChat",
"skillWorkshop.header.useCurrentChatAria",
"skillWorkshop.header.useCurrentChatTooltip",
@@ -54,12 +64,12 @@
"workboard.unknownStatus",
"workboard.viewDetails"
],
"generatedAt": "2026-06-14T00:56:10.535Z",
"generatedAt": "2026-06-14T04:03:17.811Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "d0b2c5f7e87d471ad2373c4ea8875db08c9bab459b4c4c5573e5eff03ee5be12",
"totalKeys": 1356,
"translatedKeys": 1303,
"sourceHash": "cea79b090b14fca4ad1e3e07dc5dcd7f964b1f199fd028938e41054dc5167457",
"totalKeys": 1377,
"translatedKeys": 1314,
"workflow": 1
}
+21
View File
@@ -1354,7 +1354,28 @@ export const ar: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1379,7 +1379,28 @@ export const de: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+29 -8
View File
@@ -1352,15 +1352,36 @@ export const en: TranslationMap = {
toolError: "Tool error",
},
workspaceFiles: {
label: "Workspace files",
expand: "Expand workspace files",
collapse: "Collapse workspace files",
workspace: "Workspace",
files: "Files",
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
label: "Session workspace",
expand: "Expand session workspace",
collapse: "Collapse session workspace",
workspace: "Session",
files: "Workspace",
refresh: "Refresh session workspace",
loading: "Loading session workspace…",
empty: "No files touched in this session yet",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1376,7 +1376,28 @@ export const es: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1371,7 +1371,28 @@ export const fa: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1383,7 +1383,28 @@ export const fr: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1369,7 +1369,28 @@ export const id: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1376,7 +1376,28 @@ export const it: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1373,7 +1373,28 @@ export const ja_JP: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1362,7 +1362,28 @@ export const ko: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1374,7 +1374,28 @@ export const nl: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1374,7 +1374,28 @@ export const pl: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1370,7 +1370,28 @@ export const pt_BR: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1339,7 +1339,28 @@ export const th: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1376,7 +1376,28 @@ export const tr: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1373,7 +1373,28 @@ export const uk: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1362,7 +1362,28 @@ export const vi: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1334,7 +1334,28 @@ export const zh_CN: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+21
View File
@@ -1336,7 +1336,28 @@ export const zh_TW: TranslationMap = {
refresh: "Refresh files",
loading: "Loading files…",
empty: "No workspace files",
changed: "Changed",
read: "Read",
artifacts: "Artifacts",
browser: "Project files",
path: "Workspace path",
root: "Root",
search: "Search files",
searchResults: "Search results",
parentFolder: "Parent folder",
noBrowserFiles: "No files in this folder.",
noSearchResults: "No matching files.",
truncated: "Showing the first matching files. Refine the search to narrow results.",
session: "Session",
missing: "Missing",
summary: "Session workspace summary",
changedCount: "{count} changed",
readCount: "{count} read",
artifactCount: "{count} artifacts",
browserCount: "{count} shown",
actions: "Workspace file actions",
preview: "Preview",
copyPath: "Copy path",
},
},
languages: {
+213 -11
View File
@@ -37,7 +37,7 @@
display: flex;
flex-direction: column;
overflow: hidden;
animation: slide-in 200ms ease-out;
animation: chat-sidebar-fade-in 160ms ease-out;
}
.chat-workspace-rail {
@@ -159,39 +159,107 @@
color: var(--danger, #ef4444);
}
.chat-workspace-rail__scroll {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.chat-workspace-rail__list {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
overflow: auto;
padding: 8px;
padding: 0 8px 8px;
}
.chat-workspace-rail__list--browser {
padding-top: 4px;
}
.chat-workspace-rail__section {
display: flex;
flex-direction: column;
min-height: 0;
padding-top: 8px;
}
.chat-workspace-rail__section-title {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.1;
padding: 0 12px 6px;
text-transform: uppercase;
}
.chat-workspace-rail__summary {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 12px 8px;
}
.chat-workspace-rail__summary span {
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 62%, transparent);
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.2;
}
.chat-workspace-rail__file {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
gap: 6px;
width: 100%;
min-height: 38px;
padding: 7px 8px;
padding: 0 6px 0 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.chat-workspace-rail__file:hover,
.chat-workspace-rail__file:focus-visible,
.chat-workspace-rail__file:focus-within,
.chat-workspace-rail__file--active {
border-color: color-mix(in srgb, var(--border-strong) 62%, transparent);
background: var(--bg-hover);
}
.chat-workspace-rail__file-open {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 38px;
padding: 7px 8px;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.chat-workspace-rail__file-open:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent, #7dd3fc) 65%, transparent);
outline-offset: -2px;
}
.chat-workspace-rail__file--directory .chat-workspace-rail__file-name {
font-weight: 600;
}
.chat-workspace-rail__file-main {
display: flex;
flex-direction: column;
@@ -227,14 +295,148 @@
line-height: 1.2;
}
@keyframes slide-in {
.chat-workspace-rail__row-actions {
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 120ms ease;
}
.chat-workspace-rail__file:hover .chat-workspace-rail__row-actions,
.chat-workspace-rail__file:focus-within .chat-workspace-rail__row-actions {
opacity: 1;
}
.chat-workspace-rail__row-action {
display: inline-grid;
width: 24px;
height: 24px;
place-items: center;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.chat-workspace-rail__row-action:hover,
.chat-workspace-rail__row-action:focus-visible {
border-color: color-mix(in srgb, var(--border-strong) 72%, transparent);
background: color-mix(in srgb, var(--bg-elevated) 82%, transparent);
color: var(--text);
}
.chat-workspace-rail__row-action svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.8px;
}
.chat-workspace-rail__browser {
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-workspace-rail__browser-tools {
padding: 0 8px 8px;
}
.chat-workspace-rail__search {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
height: 32px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-elevated) 70%, transparent);
color: var(--muted);
}
.chat-workspace-rail__search-icon {
display: inline-flex;
flex: 0 0 auto;
}
.chat-workspace-rail__search-icon svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.5px;
}
.chat-workspace-rail__search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--control-ui-text-sm);
}
.chat-workspace-rail__search input::placeholder {
color: var(--muted);
}
.chat-workspace-rail__breadcrumbs {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
padding: 0 12px 6px;
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.2;
overflow: hidden;
}
.chat-workspace-rail__crumb {
min-width: 0;
max-width: 88px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-workspace-rail__crumb:hover,
.chat-workspace-rail__crumb:focus-visible {
color: var(--text);
text-decoration: underline;
text-underline-offset: 2px;
}
.chat-workspace-rail__crumb-separator,
.chat-workspace-rail__browser-caption {
color: var(--muted);
}
.chat-workspace-rail__browser-caption {
padding: 0 12px 6px;
font-size: var(--control-ui-text-xs);
line-height: 1.2;
}
@keyframes chat-sidebar-fade-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
+17
View File
@@ -433,6 +433,23 @@ function installControlUiMockGateway(input: {
};
case "agents.files.get":
return null;
case "sessions.files.list":
return {
browser: {
entries: [],
path: "",
},
files: [],
root: "",
sessionKey:
isRecord(params) && typeof params.sessionKey === "string" ? params.sessionKey : "main",
};
case "sessions.files.get":
return null;
case "artifacts.list":
return { artifacts: [] };
case "artifacts.download":
return null;
case "chat.history":
return {
messages: scenario.historyMessages,
+271 -44
View File
@@ -169,7 +169,6 @@ import {
} from "./navigation.ts";
import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts";
import { isCronSessionKey, resolveSessionDisplayName } from "./session-display.ts";
import "./components/dashboard-header.ts";
import {
buildAgentMainSessionKey,
isSessionKeyTiedToAgent,
@@ -178,10 +177,17 @@ import {
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import "./components/dashboard-header.ts";
import type { SidebarContent } from "./sidebar-content.ts";
import { loadLocalAssistantIdentity } from "./storage.ts";
import { normalizeStringEntries } from "./string-coerce.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import type { AgentsFilesGetResult, AgentsFilesListResult, GatewaySessionRow } from "./types.ts";
import type {
ArtifactDownloadResult,
GatewaySessionRow,
SessionWorkspaceGetResult,
SessionWorkspaceListResult,
} from "./types.ts";
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {
@@ -678,34 +684,48 @@ const lazyUsage = createLazyView(() => import("./views/usage.ts"), notifyLazyVie
const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notifyLazyViewChanged);
type ChatWorkspaceFilesState = {
activeName: string | null;
activeId: string | null;
agentId: string;
browserPath: string;
browserSearch: string;
browserSearchTimer: ReturnType<typeof globalThis.setTimeout> | null;
collapsed: boolean;
error: string | null;
list: AgentsFilesListResult | null;
list: SessionWorkspaceListResult | null;
loading: boolean;
pendingReload: boolean;
requestId: number;
sessionKey: string;
};
const chatWorkspaceFilesStates = new WeakMap<AppViewState, ChatWorkspaceFilesState>();
const chatWorkspaceFileOpenRequests = new WeakMap<
AppViewState,
{ agentId: string; id: number; name: string; sessionKey: string }
{ agentId: string; id: number; itemId: string; sessionKey: string }
>();
function getChatWorkspaceFilesState(state: AppViewState, agentId: string): ChatWorkspaceFilesState {
function getChatWorkspaceFilesState(
state: AppViewState,
sessionKey: string,
agentId: string,
): ChatWorkspaceFilesState {
const current = chatWorkspaceFilesStates.get(state);
if (current?.agentId === agentId) {
if (current?.sessionKey === sessionKey && current.agentId === agentId) {
return current;
}
const next = {
activeName: null,
activeId: null,
agentId,
browserPath: "",
browserSearch: "",
browserSearchTimer: null,
collapsed: true,
error: null,
list: null,
loading: false,
pendingReload: false,
requestId: 0,
sessionKey,
};
chatWorkspaceFilesStates.set(state, next);
return next;
@@ -1279,14 +1299,78 @@ function renderCronQuickCreateForTab(
});
}
function languageForWorkspaceFile(name: string): string {
const extension = name.match(/\.([a-z0-9_-]+)$/i)?.[1]?.toLowerCase() ?? "";
if (extension === "json") {
return "json";
}
if (extension === "mdx") {
return "mdx";
}
if (extension === "tsx" || extension === "jsx") {
return extension;
}
if (extension === "ts" || extension === "js" || extension === "css" || extension === "html") {
return extension;
}
if (extension === "yaml" || extension === "yml") {
return "yaml";
}
if (extension === "toml" || extension === "xml" || extension === "svg") {
return extension;
}
return extension;
}
function buildWorkspaceFileSidebarContent(name: string, content: string): string {
if (/\.(?:md|markdown|mdx)$/i.test(name)) {
return content;
}
const language = name.match(/\.([a-z0-9_-]+)$/i)?.[1]?.toLowerCase() ?? "";
const language = languageForWorkspaceFile(name);
return `# ${name}\n\n\`\`\`${language}\n${content}\n\`\`\``;
}
function buildArtifactSidebarContent(params: {
data?: string;
encoding?: string;
mimeType: string;
title: string;
url?: string;
}): SidebarContent {
const { data, encoding, mimeType, title, url } = params;
if (encoding === "base64" && data && mimeType.startsWith("image/")) {
return {
kind: "image",
title,
src: `data:${mimeType};base64,${data}`,
mimeType,
rawText: url ?? null,
};
}
if (encoding === "base64" && data && mimeType === "application/json") {
const decoded = globalThis.atob(data);
return {
kind: "markdown",
content: `# ${title}\n\n\`\`\`json\n${decoded}\n\`\`\``,
rawText: decoded,
};
}
if (encoding === "base64" && data && mimeType.startsWith("text/")) {
const decoded = globalThis.atob(data);
return {
kind: "markdown",
content: `# ${title}\n\n\`\`\`\n${decoded}\n\`\`\``,
rawText: decoded,
};
}
if (url) {
const content = `# ${title}\n\n[Open artifact](${url})`;
return { kind: "markdown", content, rawText: content };
}
const content = `# ${title}\n\nArtifact download is not previewable in the sidebar.`;
return { kind: "markdown", content, rawText: content };
}
export function renderApp(state: AppViewState) {
const updatableState = state as AppViewState & { requestUpdate?: () => void };
const requestHostUpdate =
@@ -1495,11 +1579,15 @@ export function renderApp(state: AppViewState) {
? (scopedChatAgentId ?? chatFallbackAgentId)
: (activeSessionAgentId ?? scopedChatAgentId ?? chatFallbackAgentId);
const toolsPanelUsesActiveSession = Boolean(resolvedAgentId && resolvedAgentId === chatAgentId);
const chatWorkspaceFiles = getChatWorkspaceFilesState(state, chatAgentId);
const chatWorkspaceAgentId = resolveChatWorkspaceAgentId();
const chatWorkspaceFiles = getChatWorkspaceFilesState(
state,
state.sessionKey,
chatWorkspaceAgentId,
);
const currentChatWorkspaceFilesState = () =>
resolveChatWorkspaceAgentId() === chatAgentId
? getChatWorkspaceFilesState(state, chatAgentId)
: null;
getChatWorkspaceFilesState(state, state.sessionKey, resolveChatWorkspaceAgentId());
const currentSessionWorkspaceKey = () => state.sessionKey;
const getCurrentConfigValue = () =>
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const findAgentIndex = (agentId: string) =>
@@ -2105,13 +2193,13 @@ export function renderApp(state: AppViewState) {
state.agentsList &&
!chatWorkspaceFiles.loading &&
!chatWorkspaceFiles.error &&
chatWorkspaceFiles.list?.agentId !== chatAgentId
chatWorkspaceFiles.list?.sessionKey !== state.sessionKey
) {
loadChatWorkspaceFiles();
}
const toggleChatWorkspaceFilesCollapsed = () => {
chatWorkspaceFiles.collapsed = !chatWorkspaceFiles.collapsed;
if (!chatWorkspaceFiles.collapsed && chatWorkspaceFiles.list?.agentId !== chatAgentId) {
if (!chatWorkspaceFiles.collapsed && chatWorkspaceFiles.list?.sessionKey !== state.sessionKey) {
loadChatWorkspaceFiles();
}
requestHostUpdate?.();
@@ -2119,8 +2207,36 @@ export function renderApp(state: AppViewState) {
const refreshChatWorkspaceFiles = () => {
loadChatWorkspaceFiles({ force: true });
};
const browseChatWorkspacePath = (path: string) => {
if (chatWorkspaceFiles.browserSearchTimer) {
globalThis.clearTimeout(chatWorkspaceFiles.browserSearchTimer);
chatWorkspaceFiles.browserSearchTimer = null;
}
chatWorkspaceFiles.browserPath = path;
chatWorkspaceFiles.browserSearch = "";
loadChatWorkspaceFiles({ force: true });
};
const searchChatWorkspaceFiles = (search: string) => {
chatWorkspaceFiles.browserSearch = search;
if (chatWorkspaceFiles.browserSearchTimer) {
globalThis.clearTimeout(chatWorkspaceFiles.browserSearchTimer);
}
chatWorkspaceFiles.browserSearchTimer = globalThis.setTimeout(() => {
chatWorkspaceFiles.browserSearchTimer = null;
loadChatWorkspaceFiles({ force: true });
}, 160);
};
const copyChatWorkspacePath = (filePath: string) => {
void globalThis.navigator?.clipboard?.writeText?.(filePath);
};
function loadChatWorkspaceFiles(opts?: { force?: boolean }) {
if (!state.client || !state.connected || chatWorkspaceFiles.loading) {
if (!state.client || !state.connected) {
return;
}
if (chatWorkspaceFiles.loading) {
if (opts?.force) {
chatWorkspaceFiles.pendingReload = true;
}
return;
}
const requestId = chatWorkspaceFiles.requestId + 1;
@@ -2131,18 +2247,45 @@ export function renderApp(state: AppViewState) {
chatWorkspaceFiles.list = null;
}
const requestState = chatWorkspaceFiles;
requestState.pendingReload = false;
const sessionKey = state.sessionKey;
const agentId = chatWorkspaceFiles.agentId;
void (async () => {
try {
const res = await state.client?.request<AgentsFilesListResult | null>("agents.files.list", {
agentId: chatAgentId,
const res = await state.client?.request<SessionWorkspaceListResult | null>(
"sessions.files.list",
{
sessionKey,
path: requestState.browserSearch ? "" : requestState.browserPath,
search: requestState.browserSearch,
...(agentId ? { agentId } : {}),
},
);
const artifacts = await state.client?.request<{
artifacts?: SessionWorkspaceListResult["artifacts"];
} | null>("artifacts.list", {
sessionKey,
...(agentId ? { agentId } : {}),
});
const current = currentChatWorkspaceFilesState();
if (current !== requestState || current.requestId !== requestId) {
return;
}
current.list = res ?? null;
if (current.activeName && !res?.files.some((file) => file.name === current.activeName)) {
current.activeName = null;
const files = res?.files ?? [];
const artifactItems = artifacts?.artifacts ?? [];
current.list = {
sessionKey,
...(res?.root ? { root: res.root } : {}),
files,
...(res?.browser ? { browser: res.browser } : {}),
artifacts: artifactItems,
};
if (
current.activeId &&
!files.some((file) => `file:${file.path}` === current.activeId) &&
!artifactItems.some((artifact) => `artifact:${artifact.id}` === current.activeId)
) {
current.activeId = null;
}
} catch (err) {
const current = currentChatWorkspaceFilesState();
@@ -2153,19 +2296,25 @@ export function renderApp(state: AppViewState) {
const current = currentChatWorkspaceFilesState();
if (current === requestState && current.requestId === requestId) {
current.loading = false;
const shouldReload = current.pendingReload;
current.pendingReload = false;
if (shouldReload) {
loadChatWorkspaceFiles({ force: true });
}
}
requestHostUpdate?.();
}
})();
}
const openChatWorkspaceFile = (name: string) => {
chatWorkspaceFiles.activeName = name;
const openChatWorkspaceFile = (filePath: string) => {
const itemId = `file:${filePath}`;
chatWorkspaceFiles.activeId = itemId;
const previousRequest = chatWorkspaceFileOpenRequests.get(state);
const openRequest = {
agentId: chatAgentId,
agentId: chatWorkspaceFiles.agentId,
id: (previousRequest?.id ?? 0) + 1,
name,
sessionKey: state.sessionKey,
itemId,
sessionKey: currentSessionWorkspaceKey(),
};
chatWorkspaceFileOpenRequests.set(state, openRequest);
const isCurrentOpenRequest = () => {
@@ -2174,9 +2323,10 @@ export function renderApp(state: AppViewState) {
return (
currentRequest?.id === openRequest.id &&
currentRequest.agentId === resolveChatWorkspaceAgentId() &&
currentRequest.name === name &&
currentRequest.sessionKey === state.sessionKey &&
currentFiles?.activeName === name
currentRequest.itemId === itemId &&
currentRequest.sessionKey === currentSessionWorkspaceKey() &&
currentFiles?.agentId === openRequest.agentId &&
currentFiles?.activeId === itemId
);
};
void (async () => {
@@ -2185,14 +2335,82 @@ export function renderApp(state: AppViewState) {
}
chatWorkspaceFiles.error = null;
try {
const res = await state.client.request<AgentsFilesGetResult | null>("agents.files.get", {
agentId: chatAgentId,
name,
});
const content = res?.file?.content;
if (typeof content !== "string") {
const agentId = openRequest.agentId;
const res = await state.client.request<SessionWorkspaceGetResult | null>(
"sessions.files.get",
{
sessionKey: openRequest.sessionKey,
path: filePath,
...(agentId ? { agentId } : {}),
},
);
const file = res?.file;
if (!file || typeof file.content !== "string") {
if (isCurrentOpenRequest()) {
chatWorkspaceFiles.error = `Failed to load ${name}`;
chatWorkspaceFiles.error = `Failed to load ${filePath}`;
requestHostUpdate?.();
}
return;
}
const content = file.content;
if (!isCurrentOpenRequest()) {
return;
}
state.handleOpenSidebar({
kind: "markdown",
content: buildWorkspaceFileSidebarContent(file.name || filePath, content),
rawText: content,
});
} catch (err) {
if (isCurrentOpenRequest()) {
chatWorkspaceFiles.error = String(err);
}
} finally {
requestHostUpdate?.();
}
})();
};
const openChatWorkspaceArtifact = (artifactId: string) => {
const itemId = `artifact:${artifactId}`;
chatWorkspaceFiles.activeId = itemId;
const previousRequest = chatWorkspaceFileOpenRequests.get(state);
const openRequest = {
agentId: chatWorkspaceFiles.agentId,
id: (previousRequest?.id ?? 0) + 1,
itemId,
sessionKey: currentSessionWorkspaceKey(),
};
chatWorkspaceFileOpenRequests.set(state, openRequest);
const isCurrentOpenRequest = () => {
const currentRequest = chatWorkspaceFileOpenRequests.get(state);
const currentFiles = currentChatWorkspaceFilesState();
return (
currentRequest?.id === openRequest.id &&
currentRequest.agentId === resolveChatWorkspaceAgentId() &&
currentRequest.itemId === itemId &&
currentRequest.sessionKey === currentSessionWorkspaceKey() &&
currentFiles?.agentId === openRequest.agentId &&
currentFiles?.activeId === itemId
);
};
void (async () => {
if (!state.client || !state.connected) {
return;
}
chatWorkspaceFiles.error = null;
try {
const agentId = openRequest.agentId;
const res = await state.client.request<ArtifactDownloadResult | null>(
"artifacts.download",
{
sessionKey: openRequest.sessionKey,
artifactId,
...(agentId ? { agentId } : {}),
},
);
if (!res?.artifact) {
if (isCurrentOpenRequest()) {
chatWorkspaceFiles.error = `Failed to load artifact ${artifactId}`;
requestHostUpdate?.();
}
return;
@@ -2200,11 +2418,16 @@ export function renderApp(state: AppViewState) {
if (!isCurrentOpenRequest()) {
return;
}
state.handleOpenSidebar({
kind: "markdown",
content: buildWorkspaceFileSidebarContent(name, content),
rawText: content,
const title = res.artifact.title;
const mimeType = res.artifact.mimeType ?? "";
const preview = buildArtifactSidebarContent({
data: res.data,
encoding: res.encoding,
mimeType,
title,
url: res.url,
});
state.handleOpenSidebar(preview);
} catch (err) {
if (isCurrentOpenRequest()) {
chatWorkspaceFiles.error = String(err);
@@ -3543,19 +3766,23 @@ export function renderApp(state: AppViewState) {
onDismissError: () => dismissChatError(state),
sessions: state.sessionsResult,
composerControls: renderGuardedChatControls(state),
workspaceFiles: {
sessionWorkspace: {
collapsed: chatWorkspaceFiles.collapsed,
agentId: chatAgentId,
sessionKey: state.sessionKey,
list:
chatWorkspaceFiles.list?.agentId === chatAgentId
chatWorkspaceFiles.list?.sessionKey === state.sessionKey
? chatWorkspaceFiles.list
: null,
loading: chatWorkspaceFiles.loading,
error: chatWorkspaceFiles.error,
activeName: chatWorkspaceFiles.activeName,
activeId: chatWorkspaceFiles.activeId,
onToggleCollapsed: toggleChatWorkspaceFilesCollapsed,
onRefresh: refreshChatWorkspaceFiles,
onBrowsePath: browseChatWorkspacePath,
onCopyPath: copyChatWorkspacePath,
onOpenFile: openChatWorkspaceFile,
onSearch: searchChatWorkspaceFiles,
onOpenArtifact: openChatWorkspaceArtifact,
},
autoExpandToolCalls: false,
onRefresh: () => {
@@ -27,7 +27,7 @@ function iconSvg() {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>`;
}
function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) {
function sidebarSessionPickerHtml(opts: { sidebarOpen?: boolean; workspaceRail?: boolean } = {}) {
const optionButtons = Array.from({ length: 18 }, (_, index) => {
const sessionKey = `dashboard-session-${index + 1}`;
const selected = index === 0;
@@ -53,7 +53,7 @@ function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) {
}).join("");
const workspaceRail = opts.workspaceRail
? `
<aside class="chat-workspace-rail" aria-label="Workspace files">
<aside class="chat-workspace-rail" aria-label="Session workspace">
<div class="chat-workspace-rail__header">
<div class="chat-workspace-rail__title">
<span class="chat-workspace-rail__eyebrow">Workspace</span>
@@ -65,20 +65,24 @@ function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) {
</div>
<div class="chat-workspace-rail__path">/workspace/openclaw</div>
<div class="chat-workspace-rail__list" role="list">
<button class="chat-workspace-rail__file chat-workspace-rail__file--active" type="button" role="listitem">
<span class="chat-workspace-rail__file-icon">${iconSvg()}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">ui/src/ui/chat/session-controls.ts</span>
<span class="chat-workspace-rail__file-meta">24 KB</span>
</span>
</button>
<button class="chat-workspace-rail__file" type="button" role="listitem">
<span class="chat-workspace-rail__file-icon">${iconSvg()}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">ui/src/styles/layout.css</span>
<span class="chat-workspace-rail__file-meta">31 KB</span>
</span>
</button>
<div class="chat-workspace-rail__file chat-workspace-rail__file--active" role="listitem">
<button class="chat-workspace-rail__file-open" type="button">
<span class="chat-workspace-rail__file-icon">${iconSvg()}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">ui/src/ui/chat/session-controls.ts</span>
<span class="chat-workspace-rail__file-meta">24 KB</span>
</span>
</button>
</div>
<div class="chat-workspace-rail__file" role="listitem">
<button class="chat-workspace-rail__file-open" type="button">
<span class="chat-workspace-rail__file-icon">${iconSvg()}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">ui/src/styles/layout.css</span>
<span class="chat-workspace-rail__file-meta">31 KB</span>
</span>
</button>
</div>
</div>
</aside>
`
@@ -157,32 +161,65 @@ function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) {
<main class="content content--chat">
<section class="card chat">
<div class="chat-workbench">
<div class="chat-split-container">
<div class="chat-main">
<div class="chat-thread" role="log">
<div class="chat-thread-inner">
<div class="chat-group assistant">
<div class="chat-group-messages">
<div class="chat-bubble">
<div class="chat-text">
<p>Keep the sidebar session picker interactive even when the desktop chat workbench is visible.</p>
${workspaceRail}
<div class="chat-workbench__main">
<div class="chat-split-container${opts.sidebarOpen ? " chat-split-container--open" : ""}">
<div class="chat-main" style="flex: ${opts.sidebarOpen ? "0 1 72%" : "1 1 100%"}">
<div class="chat-thread" role="log">
<div class="chat-thread-inner">
<div class="chat-group assistant">
<div class="chat-group-messages">
<div class="chat-bubble">
<div class="chat-text">
<p>Keep the sidebar session picker interactive even when the desktop chat workbench is visible.</p>
</div>
</div>
</div>
</div>
<div
style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:28px 0 0;padding:0 8px;"
>
<button class="btn" type="button">What can you do?</button>
<button class="btn" type="button">Summarize recent sessions</button>
<button class="btn" type="button">Help me configure a channel</button>
<button class="btn" type="button">Check system health</button>
</div>
</div>
<div
style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:28px 0 0;padding:0 8px;"
>
<button class="btn" type="button">What can you do?</button>
<button class="btn" type="button">Summarize recent sessions</button>
<button class="btn" type="button">Help me configure a channel</button>
<button class="btn" type="button">Check system health</button>
</div>
<div class="agent-chat__input">
<div class="agent-chat__composer-combobox">
<textarea placeholder="Message OpenClaw"></textarea>
</div>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button class="agent-chat__input-btn" type="button" aria-label="Attach file">
${iconSvg()}
</button>
</div>
<button class="btn btn--ghost" type="button">Send</button>
</div>
</div>
</div>
${
opts.sidebarOpen
? `
<resizable-divider style="width:14px;flex:0 0 14px;"></resizable-divider>
<div class="chat-sidebar">
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">AGENTS.md</div>
<button class="btn" type="button">Close</button>
</div>
<div class="sidebar-content">
<article class="sidebar-markdown">File preview content</article>
</div>
</div>
</div>
`
: ""
}
</div>
</div>
${workspaceRail}
</div>
</section>
</main>
@@ -206,7 +243,7 @@ function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) {
async function openSidebarSessionPickerFixture(
width: number,
height: number,
opts: { workspaceRail?: boolean } = {},
opts: { sidebarOpen?: boolean; workspaceRail?: boolean } = {},
): Promise<Page> {
const page = await browser.newPage({ viewport: { width, height } });
await page.setContent(
@@ -289,4 +326,43 @@ describeBrowserLayout("sidebar session picker browser layout", () => {
await page.close();
}
});
it("keeps the file preview sidebar inside the main workbench column when the workspace rail is visible", async () => {
const page = await openSidebarSessionPickerFixture(1366, 900, {
sidebarOpen: true,
workspaceRail: true,
});
try {
await expectNoHorizontalOverflow(page);
const boxes = await page.evaluate(() => {
const rectFor = (selector: string) => {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Missing ${selector}`);
}
const rect = element.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
width: rect.width,
};
};
return {
main: rectFor(".chat-workbench__main"),
input: rectFor(".agent-chat__input"),
rail: rectFor(".chat-workspace-rail"),
sidebar: rectFor(".chat-sidebar"),
split: rectFor(".chat-split-container"),
};
});
expect(boxes.split.right).toBeLessThanOrEqual(boxes.rail.left + 1);
expect(boxes.sidebar.right).toBeLessThanOrEqual(boxes.rail.left + 1);
expect(boxes.input.right).toBeLessThanOrEqual(boxes.sidebar.left + 1);
expect(boxes.sidebar.left).toBeGreaterThanOrEqual(boxes.main.left - 1);
expect(boxes.sidebar.width).toBeGreaterThanOrEqual(300);
} finally {
await page.close();
}
});
});
+57 -14
View File
@@ -230,43 +230,86 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.files.list": {
agentId: "main",
files: [{ name: "AGENTS.md", path: "/workspace/AGENTS.md", size: 2048 }],
workspace: "/workspace",
"artifacts.list": {
artifacts: [
{
download: { mode: "bytes" },
id: "artifact-1",
mimeType: "image/png",
sizeBytes: 128,
title: "preview.png",
type: "image",
},
],
},
"sessions.files.list": {
browser: {
entries: [
{
kind: "directory",
name: "src",
path: "src",
sessionKind: "modified",
},
{
kind: "file",
name: "package.json",
path: "package.json",
size: 4096,
},
],
path: "",
},
files: [
{
kind: "modified",
missing: false,
name: "AGENTS.md",
path: "/workspace/AGENTS.md",
size: 2048,
},
],
root: "/workspace",
sessionKey: "main",
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByRole("button", { name: "Expand workspace files" }).waitFor({
await page.getByRole("button", { name: "Expand session workspace" }).waitFor({
timeout: 10_000,
});
expect(await gateway.getRequests("agents.files.list")).toHaveLength(0);
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(0);
expect(await page.locator(".chat-workspace-rail__file").count()).toBe(0);
expect(await page.locator(".chat-workspace-rail__collapsed-icon svg").count()).toBe(1);
await page.getByRole("button", { name: "Expand workspace files" }).click();
await page.getByRole("button", { name: "Collapse workspace files" }).waitFor({
await page.getByRole("button", { name: "Expand session workspace" }).click();
await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({
timeout: 10_000,
});
await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 });
expect(await gateway.getRequests("agents.files.list")).toHaveLength(1);
await page.getByText("preview.png").waitFor({ timeout: 10_000 });
await page.getByText("Project files").waitFor({ timeout: 10_000 });
await page.locator(".chat-workspace-rail__file-name", { hasText: "package.json" }).waitFor({
timeout: 10_000,
});
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
expect(await gateway.getRequests("artifacts.list")).toHaveLength(1);
await page.getByRole("button", { name: "Collapse workspace files" }).click();
await page.getByRole("button", { name: "Expand workspace files" }).waitFor({
await page.getByRole("button", { name: "Collapse session workspace" }).click();
await page.getByRole("button", { name: "Expand session workspace" }).waitFor({
timeout: 10_000,
});
expect(await page.locator(".chat-workspace-rail__file").count()).toBe(0);
expect(await page.locator(".chat-workspace-rail__collapsed-icon svg").count()).toBe(1);
await page.getByRole("button", { name: "Expand workspace files" }).click();
await page.getByRole("button", { name: "Collapse workspace files" }).waitFor({
await page.getByRole("button", { name: "Expand session workspace" }).click();
await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({
timeout: 10_000,
});
await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 });
expect(await gateway.getRequests("agents.files.list")).toHaveLength(1);
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
await page.setViewportSize({ height: 900, width: 1000 });
expect(await page.locator(".chat-workspace-rail").isHidden()).toBe(true);
+11 -1
View File
@@ -25,4 +25,14 @@ export type CanvasSidebarContent = {
unavailableReason?: "not_found" | "oversized" | "not_visible" | null;
};
export type SidebarContent = MarkdownSidebarContent | CanvasSidebarContent;
export type ImageSidebarContent = {
kind: "image";
title: string;
src: string;
mimeType?: string | null;
rawText?: string | null;
fullMessageRequest?: SidebarFullMessageRequest;
unavailableReason?: "not_found" | "oversized" | "not_visible" | null;
};
export type SidebarContent = MarkdownSidebarContent | CanvasSidebarContent | ImageSidebarContent;
+60
View File
@@ -387,6 +387,66 @@ export type AgentsFilesSetResult = {
file: AgentFileEntry;
};
export type SessionWorkspaceFileEntry = {
path: string;
name: string;
kind: "modified" | "read";
missing: boolean;
size?: number;
updatedAtMs?: number;
content?: string;
};
export type SessionWorkspaceBrowserEntry = {
path: string;
name: string;
kind: "file" | "directory";
sessionKind?: "modified" | "read" | "mixed";
size?: number;
updatedAtMs?: number;
};
export type SessionWorkspaceBrowserResult = {
path: string;
parentPath?: string;
search?: string;
entries: SessionWorkspaceBrowserEntry[];
truncated?: boolean;
};
export type SessionWorkspaceArtifactEntry = {
id: string;
type: string;
title: string;
mimeType?: string;
sizeBytes?: number;
source?: string;
download: {
mode: "bytes" | "url" | "unsupported";
};
};
export type SessionWorkspaceListResult = {
sessionKey: string;
root?: string;
files: SessionWorkspaceFileEntry[];
browser?: SessionWorkspaceBrowserResult;
artifacts?: SessionWorkspaceArtifactEntry[];
};
export type SessionWorkspaceGetResult = {
sessionKey: string;
root?: string;
file: SessionWorkspaceFileEntry;
};
export type ArtifactDownloadResult = {
artifact: SessionWorkspaceArtifactEntry;
encoding?: "base64";
data?: string;
url?: string;
};
export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
export type SubagentRunState = "active" | "interrupted" | "historical";
+81 -14
View File
@@ -22,6 +22,7 @@ import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow, ModelCatalogEntry, SessionsListResult } from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { renderChat, resetChatViewState } from "./chat.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
vi.fn(async (state: AppViewState) => {
@@ -878,30 +879,56 @@ describe("chat composer workbench", () => {
it("renders session controls in the composer and workspace files in the expanded rail", () => {
const onToggleCollapsed = vi.fn();
const onRefresh = vi.fn();
const onBrowsePath = vi.fn();
const onCopyPath = vi.fn();
const onOpenFile = vi.fn();
const onSearch = vi.fn();
const container = renderChatView({
composerControls: html`<button class="test-composer-control">Model</button>`,
workspaceFiles: {
sessionWorkspace: {
collapsed: false,
agentId: "main",
sessionKey: "agent:main",
list: {
agentId: "main",
workspace: "/workspace",
sessionKey: "agent:main",
root: "/workspace",
files: [
{
name: "AGENTS.md",
path: "/workspace/AGENTS.md",
kind: "modified",
missing: false,
size: 2048,
},
],
browser: {
path: "",
entries: [
{
name: "ui",
path: "ui",
kind: "directory",
sessionKind: "modified",
},
{
name: "package.json",
path: "package.json",
kind: "file",
size: 4096,
},
],
},
artifacts: [],
},
loading: false,
error: null,
activeName: "AGENTS.md",
activeId: "file:/workspace/AGENTS.md",
onToggleCollapsed,
onRefresh,
onBrowsePath,
onCopyPath,
onOpenFile,
onSearch,
onOpenArtifact: () => undefined,
},
});
@@ -920,40 +947,57 @@ describe("chat composer workbench", () => {
expect(container.querySelector(".chat-workspace-rail__path")?.textContent?.trim()).toBe(
"/workspace",
);
const file = container.querySelector<HTMLButtonElement>(".chat-workspace-rail__file");
const file = container.querySelector<HTMLDivElement>(".chat-workspace-rail__file");
expect(file?.textContent).toContain("AGENTS.md");
expect(file?.textContent).toContain("2 KB");
expect(container.querySelector(".chat-workspace-rail__summary")?.textContent).toContain(
"1 changed",
);
expect(container.querySelector(".chat-workspace-rail__browser")?.textContent).toContain(
"package.json",
);
file?.click();
file?.querySelector<HTMLButtonElement>(".chat-workspace-rail__file-open")?.click();
file?.querySelector<HTMLButtonElement>('button[aria-label="Copy path"]')?.click();
const browserDirectory = Array.from(
container.querySelectorAll<HTMLDivElement>(".chat-workspace-rail__file"),
).find((row) => row.textContent?.includes("ui"));
browserDirectory?.querySelector<HTMLButtonElement>(".chat-workspace-rail__file-open")?.click();
container
.querySelector<HTMLButtonElement>('button[aria-label="Collapse workspace files"]')
.querySelector<HTMLButtonElement>('button[aria-label="Collapse session workspace"]')
?.click();
expect(onOpenFile).toHaveBeenCalledWith("AGENTS.md");
expect(onOpenFile).toHaveBeenCalledWith("/workspace/AGENTS.md");
expect(onCopyPath).toHaveBeenCalledWith("/workspace/AGENTS.md");
expect(onBrowsePath).toHaveBeenCalledWith("ui");
expect(onToggleCollapsed).toHaveBeenCalledTimes(1);
expect(container.querySelector('button[aria-label="Workspace files"]')).toBeNull();
expect(container.querySelector('button[aria-label="Session workspace"]')).toBeNull();
});
it("keeps the workspace files rail reachable from the collapsed strip", () => {
const onToggleCollapsed = vi.fn();
const container = renderChatView({
workspaceFiles: {
sessionWorkspace: {
collapsed: true,
agentId: "main",
sessionKey: "agent:main",
list: null,
loading: false,
error: null,
activeName: null,
activeId: null,
onToggleCollapsed,
onRefresh: () => undefined,
onBrowsePath: () => undefined,
onCopyPath: () => undefined,
onOpenFile: () => undefined,
onSearch: () => undefined,
onOpenArtifact: () => undefined,
},
});
expect(container.querySelector(".chat-workspace-rail__list")).toBeNull();
expect(container.querySelector(".chat-workspace-rail__collapsed-icon")).not.toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand workspace files"]',
'button[aria-label="Expand session workspace"]',
);
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
@@ -2068,6 +2112,29 @@ describe("chat sidebar raw content", () => {
rawText: "Raw",
});
});
it("renders image sidebar content as an image instead of markdown text", () => {
const container = document.createElement("div");
render(
renderMarkdownSidebar({
content: {
kind: "image",
title: "artifact-preview.png",
src: "data:image/png;base64,aW1hZ2U=",
mimeType: "image/png",
},
error: null,
onClose: () => undefined,
onViewRawText: () => undefined,
}),
container,
);
const image = container.querySelector<HTMLImageElement>("img.chat-tool-card__preview-image");
expect(image?.getAttribute("src")).toBe("data:image/png;base64,aW1hZ2U=");
expect(container.textContent).not.toContain("data:image/png;base64");
});
});
describe("chat welcome", () => {
+554 -243
View File
@@ -58,12 +58,7 @@ import { icons } from "../icons.ts";
import { formatGoalDetail, formatGoalSummary } from "../session-goal.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type {
AgentFileEntry,
AgentsFilesListResult,
SessionGoal,
SessionsListResult,
} from "../types.ts";
import type { SessionWorkspaceListResult, SessionGoal, SessionsListResult } from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { resolveLocalUserName } from "../user-identity.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
@@ -198,16 +193,20 @@ export type ChatProps = {
onChatScroll?: (event: Event) => void;
basePath?: string;
composerControls?: TemplateResult | typeof nothing | ReturnType<typeof guard>;
workspaceFiles?: {
sessionWorkspace?: {
collapsed: boolean;
agentId: string;
list: AgentsFilesListResult | null;
sessionKey: string;
list: SessionWorkspaceListResult | null;
loading: boolean;
error: string | null;
activeName: string | null;
activeId: string | null;
onToggleCollapsed: () => void;
onRefresh: () => void;
onOpenFile: (name: string) => void;
onBrowsePath: (path: string) => void;
onCopyPath: (path: string) => void;
onOpenFile: (path: string) => void;
onSearch: (search: string) => void;
onOpenArtifact: (artifactId: string) => void;
};
};
@@ -1010,7 +1009,7 @@ function renderChatGoal(goal: SessionGoal | undefined): TemplateResult | typeof
`;
}
function formatWorkspaceFileSize(file: AgentFileEntry): string {
function formatWorkspaceFileSize(file: { size?: number }): string {
const size = file.size;
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) {
return "";
@@ -1024,13 +1023,32 @@ function formatWorkspaceFileSize(file: AgentFileEntry): string {
return `${size} B`;
}
function renderWorkspaceFileRail(
workspaceFiles: NonNullable<ChatProps["workspaceFiles"]> | undefined,
function renderWorkspaceArtifactSize(artifact: { sizeBytes?: number }): string {
return formatWorkspaceFileSize({ size: artifact.sizeBytes });
}
function renderWorkspaceRailSection(
title: string,
content: TemplateResult | typeof nothing,
): TemplateResult | typeof nothing {
if (!workspaceFiles) {
if (content === nothing) {
return nothing;
}
if (workspaceFiles.collapsed) {
return html`
<section class="chat-workspace-rail__section">
<div class="chat-workspace-rail__section-title">${title}</div>
${content}
</section>
`;
}
function renderSessionWorkspaceRail(
sessionWorkspace: NonNullable<ChatProps["sessionWorkspace"]> | undefined,
): TemplateResult | typeof nothing {
if (!sessionWorkspace) {
return nothing;
}
if (sessionWorkspace.collapsed) {
return html`
<aside
class="chat-workspace-rail chat-workspace-rail--collapsed"
@@ -1042,7 +1060,7 @@ function renderWorkspaceFileRail(
title=${t("chat.workspaceFiles.expand")}
aria-label=${t("chat.workspaceFiles.expand")}
aria-expanded="false"
@click=${workspaceFiles.onToggleCollapsed}
@click=${sessionWorkspace.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.panelRightOpen}</span>
</button>
@@ -1052,7 +1070,315 @@ function renderWorkspaceFileRail(
</aside>
`;
}
const files = workspaceFiles.list?.files ?? [];
const files = sessionWorkspace.list?.files ?? [];
const modifiedFiles = files.filter((file) => file.kind === "modified");
const readFiles = files.filter((file) => file.kind === "read");
const artifacts = sessionWorkspace.list?.artifacts ?? [];
const browser = sessionWorkspace.list?.browser ?? null;
const hasSessionItems = files.length > 0 || artifacts.length > 0;
const hasBrowserItems = (browser?.entries.length ?? 0) > 0;
const hasItems = hasSessionItems || hasBrowserItems;
const renderPathActions = (
path: string,
options: { preview?: boolean } = {},
): TemplateResult => html`
<span
class="chat-workspace-rail__row-actions"
role="group"
aria-label=${t("chat.workspaceFiles.actions")}
>
${options.preview === false
? nothing
: html`<button
class="chat-workspace-rail__row-action"
type="button"
title=${t("chat.workspaceFiles.preview")}
aria-label=${t("chat.workspaceFiles.preview")}
@click=${(event: Event) => {
event.stopPropagation();
sessionWorkspace.onOpenFile(path);
}}
>
${icons.eye}
</button>`}
<button
class="chat-workspace-rail__row-action"
type="button"
title=${t("chat.workspaceFiles.copyPath")}
aria-label=${t("chat.workspaceFiles.copyPath")}
@click=${(event: Event) => {
event.stopPropagation();
sessionWorkspace.onCopyPath(path);
}}
>
${icons.copy}
</button>
</span>
`;
const renderSessionSummary = (): TemplateResult | typeof nothing => {
if (!sessionWorkspace.list) {
return nothing;
}
const browserCount = browser?.entries.length ?? 0;
return html`
<div class="chat-workspace-rail__summary" aria-label=${t("chat.workspaceFiles.summary")}>
<span
>${t("chat.workspaceFiles.changedCount", { count: String(modifiedFiles.length) })}</span
>
<span>${t("chat.workspaceFiles.readCount", { count: String(readFiles.length) })}</span>
<span>${t("chat.workspaceFiles.artifactCount", { count: String(artifacts.length) })}</span>
<span>${t("chat.workspaceFiles.browserCount", { count: String(browserCount) })}</span>
</div>
`;
};
const renderFileRows = (rows: typeof files): TemplateResult | typeof nothing =>
rows.length === 0
? nothing
: html`
<div class="chat-workspace-rail__list" role="list">
${rows.map((file) => {
const size = formatWorkspaceFileSize(file);
const itemId = `file:${file.path}`;
const isActive = itemId === sessionWorkspace.activeId;
return html`
<div
class="chat-workspace-rail__file ${isActive
? "chat-workspace-rail__file--active"
: ""}"
role="listitem"
title=${file.path || file.name}
>
<button
class="chat-workspace-rail__file-open"
type="button"
@click=${() => sessionWorkspace.onOpenFile(file.path)}
>
<span class="chat-workspace-rail__file-icon">${icons.fileText}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">${file.path || file.name}</span>
${size
? html`<span class="chat-workspace-rail__file-meta">${size}</span>`
: nothing}
</span>
</button>
${file.missing
? html`<span class="chat-workspace-rail__file-badge"
>${t("chat.workspaceFiles.missing")}</span
>`
: nothing}
${renderPathActions(file.path)}
</div>
`;
})}
</div>
`;
const renderBrowserBadge = (
sessionKind: "modified" | "read" | "mixed" | undefined,
): TemplateResult | typeof nothing => {
if (!sessionKind) {
return nothing;
}
const label =
sessionKind === "modified"
? t("chat.workspaceFiles.changed")
: sessionKind === "read"
? t("chat.workspaceFiles.read")
: t("chat.workspaceFiles.session");
return html`<span class="chat-workspace-rail__file-badge">${label}</span>`;
};
const renderBrowserBreadcrumbs = (): TemplateResult | typeof nothing => {
if (!browser || browser.search) {
return nothing;
}
const parts = browser.path ? browser.path.split("/").filter(Boolean) : [];
let currentPath = "";
return html`
<div class="chat-workspace-rail__breadcrumbs" aria-label=${t("chat.workspaceFiles.path")}>
<button
class="chat-workspace-rail__crumb"
type="button"
@click=${() => sessionWorkspace.onBrowsePath("")}
>
${t("chat.workspaceFiles.root")}
</button>
${parts.map((part) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const pathForPart = currentPath;
return html`
<span class="chat-workspace-rail__crumb-separator">/</span>
<button
class="chat-workspace-rail__crumb"
type="button"
@click=${() => sessionWorkspace.onBrowsePath(pathForPart)}
>
${part}
</button>
`;
})}
</div>
`;
};
const renderBrowserRows = (): TemplateResult => {
const entries = browser?.entries ?? [];
const parentPath = browser?.parentPath;
return html`
<section class="chat-workspace-rail__browser">
<div class="chat-workspace-rail__browser-tools">
<label class="chat-workspace-rail__search">
<span class="chat-workspace-rail__search-icon" aria-hidden="true">${icons.search}</span>
<input
type="search"
placeholder=${t("chat.workspaceFiles.search")}
aria-label=${t("chat.workspaceFiles.search")}
.value=${browser?.search ?? ""}
@input=${(event: Event) => {
const target = event.target as HTMLInputElement;
sessionWorkspace.onSearch(target.value);
}}
/>
</label>
</div>
${renderBrowserBreadcrumbs()}
${browser?.search
? html`<div class="chat-workspace-rail__browser-caption">
${t("chat.workspaceFiles.searchResults")}
</div>`
: nothing}
<div class="chat-workspace-rail__list chat-workspace-rail__list--browser" role="list">
${!browser?.search && parentPath != null
? html`
<div
class="chat-workspace-rail__file chat-workspace-rail__file--directory"
role="listitem"
>
<button
class="chat-workspace-rail__file-open"
type="button"
@click=${() => sessionWorkspace.onBrowsePath(parentPath)}
>
<span class="chat-workspace-rail__file-icon">${icons.folder}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">..</span>
<span class="chat-workspace-rail__file-meta"
>${t("chat.workspaceFiles.parentFolder")}</span
>
</span>
</button>
</div>
`
: nothing}
${entries.length === 0
? html`<div class="chat-workspace-rail__state">
${browser?.search
? t("chat.workspaceFiles.noSearchResults")
: t("chat.workspaceFiles.noBrowserFiles")}
</div>`
: entries.map((entry) => {
const size = entry.kind === "file" ? formatWorkspaceFileSize(entry) : "";
const itemId = `file:${entry.path}`;
const isActive = itemId === sessionWorkspace.activeId;
const canPreview = entry.kind === "file" && Boolean(entry.sessionKind);
return html`
<div
class="chat-workspace-rail__file ${entry.kind === "directory"
? "chat-workspace-rail__file--directory"
: ""} ${isActive ? "chat-workspace-rail__file--active" : ""}"
role="listitem"
title=${entry.path || entry.name}
>
<button
class="chat-workspace-rail__file-open"
type="button"
?disabled=${entry.kind === "file" && !canPreview}
@click=${() =>
entry.kind === "directory"
? sessionWorkspace.onBrowsePath(entry.path)
: canPreview
? sessionWorkspace.onOpenFile(entry.path)
: undefined}
>
<span class="chat-workspace-rail__file-icon"
>${entry.kind === "directory" ? icons.folder : icons.fileText}</span
>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">${entry.name}</span>
<span class="chat-workspace-rail__file-meta">
${entry.kind === "directory"
? entry.path || t("chat.workspaceFiles.root")
: [entry.path, size].filter(Boolean).join(" / ")}
</span>
</span>
</button>
${renderBrowserBadge(entry.sessionKind)}
${entry.kind === "file"
? renderPathActions(entry.path, { preview: canPreview })
: nothing}
</div>
`;
})}
</div>
${browser?.truncated
? html`<div class="chat-workspace-rail__state">
${t("chat.workspaceFiles.truncated")}
</div>`
: nothing}
</section>
`;
};
const renderArtifactRows = (): TemplateResult | typeof nothing =>
artifacts.length === 0
? nothing
: html`
<div class="chat-workspace-rail__list" role="list">
${artifacts.map((artifact) => {
const size = renderWorkspaceArtifactSize(artifact);
const itemId = `artifact:${artifact.id}`;
const isActive = itemId === sessionWorkspace.activeId;
const isImage = artifact.mimeType?.startsWith("image/");
return html`
<div
class="chat-workspace-rail__file ${isActive
? "chat-workspace-rail__file--active"
: ""}"
role="listitem"
title=${artifact.title}
>
<button
class="chat-workspace-rail__file-open"
type="button"
@click=${() => sessionWorkspace.onOpenArtifact(artifact.id)}
>
<span class="chat-workspace-rail__file-icon"
>${isImage ? icons.image : icons.paperclip}</span
>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">${artifact.title}</span>
${size || artifact.mimeType
? html`<span class="chat-workspace-rail__file-meta"
>${[artifact.mimeType, size].filter(Boolean).join(" / ")}</span
>`
: nothing}
</span>
</button>
<span class="chat-workspace-rail__row-actions">
<button
class="chat-workspace-rail__row-action"
type="button"
title=${t("chat.workspaceFiles.preview")}
aria-label=${t("chat.workspaceFiles.preview")}
@click=${(event: Event) => {
event.stopPropagation();
sessionWorkspace.onOpenArtifact(artifact.id);
}}
>
${icons.eye}
</button>
</span>
</div>
`;
})}
</div>
`;
return html`
<aside class="chat-workspace-rail" aria-label=${t("chat.workspaceFiles.label")}>
<div class="chat-workspace-rail__header">
@@ -1066,8 +1392,8 @@ function renderWorkspaceFileRail(
type="button"
title=${t("chat.workspaceFiles.refresh")}
aria-label=${t("chat.workspaceFiles.refresh")}
?disabled=${workspaceFiles.loading}
@click=${workspaceFiles.onRefresh}
?disabled=${sessionWorkspace.loading}
@click=${sessionWorkspace.onRefresh}
>
${icons.refresh}
</button>
@@ -1077,7 +1403,7 @@ function renderWorkspaceFileRail(
title=${t("chat.workspaceFiles.collapse")}
aria-label=${t("chat.workspaceFiles.collapse")}
aria-expanded="true"
@click=${workspaceFiles.onToggleCollapsed}
@click=${sessionWorkspace.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${icons.panelRightClose}</span
@@ -1085,51 +1411,44 @@ function renderWorkspaceFileRail(
</button>
</div>
</div>
${workspaceFiles.list?.workspace
? html`<div class="chat-workspace-rail__path" title=${workspaceFiles.list.workspace}>
${workspaceFiles.list.workspace}
${sessionWorkspace.list?.root
? html`<div class="chat-workspace-rail__path" title=${sessionWorkspace.list.root}>
${sessionWorkspace.list.root}
</div>`
: nothing}
${workspaceFiles.error
${renderSessionSummary()}
${sessionWorkspace.error
? html`<div class="chat-workspace-rail__state chat-workspace-rail__state--error">
${workspaceFiles.error}
${sessionWorkspace.error}
</div>`
: workspaceFiles.loading && files.length === 0
: sessionWorkspace.loading && !hasItems
? html`<div class="chat-workspace-rail__state">${t("chat.workspaceFiles.loading")}</div>`
: files.length === 0
? html`<div class="chat-workspace-rail__state">${t("chat.workspaceFiles.empty")}</div>`
: html`
<div class="chat-workspace-rail__list" role="list">
${files.map((file) => {
const size = formatWorkspaceFileSize(file);
const isActive = file.name === workspaceFiles.activeName;
return html`
<button
class="chat-workspace-rail__file ${isActive
? "chat-workspace-rail__file--active"
: ""}"
type="button"
role="listitem"
title=${file.path || file.name}
@click=${() => workspaceFiles.onOpenFile(file.name)}
>
<span class="chat-workspace-rail__file-icon">${icons.fileText}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">${file.name}</span>
${size
? html`<span class="chat-workspace-rail__file-meta">${size}</span>`
: nothing}
</span>
${file.missing
? html`<span class="chat-workspace-rail__file-badge"
>${t("chat.workspaceFiles.missing")}</span
>`
: nothing}
</button>
`;
})}
</div>
`}
: html`
<div class="chat-workspace-rail__scroll">
${!hasSessionItems
? html`<div class="chat-workspace-rail__state">
${t("chat.workspaceFiles.empty")}
</div>`
: html`
${renderWorkspaceRailSection(
t("chat.workspaceFiles.changed"),
renderFileRows(modifiedFiles),
)}
${renderWorkspaceRailSection(
t("chat.workspaceFiles.read"),
renderFileRows(readFiles),
)}
${renderWorkspaceRailSection(
t("chat.workspaceFiles.artifacts"),
renderArtifactRows(),
)}
`}
${renderWorkspaceRailSection(
t("chat.workspaceFiles.browser"),
browser ? renderBrowserRows() : nothing,
)}
</div>
`}
</aside>
`;
}
@@ -2055,6 +2374,176 @@ export function renderChat(props: ChatProps) {
const slashMenuVisible = isSlashMenuVisible();
const activeSlashMenuOptionId = getActiveSlashMenuOptionId();
const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel();
const chatColumnFooter = html`
${renderChatQueue({
queue: props.queue,
canAbort: showAbortableUi,
onQueueRetry: props.onQueueRetry,
onQueueSteer: props.onQueueSteer,
onQueueRemove: props.onQueueRemove,
})}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
${icons.arrowDown} New messages
</button>
`
: nothing}
<!-- Input bar -->
<div
class="agent-chat__input"
@click=${(event: MouseEvent) => focusComposerFromChrome(event, props.connected)}
>
${renderSlashMenu(requestUpdate, props, visibleDraft)} ${renderAttachmentPreview(props)}
<div class="agent-chat__composer-status-stack">
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
compactBusy,
compactDisabled: !props.connected || isBusy || showAbortableUi,
onCompact: props.onCompact,
})}
${renderChatGoal(activeSession?.goal)}
</div>
<input
type="file"
accept=${CHAT_ATTACHMENT_ACCEPT}
multiple
class="agent-chat__file-input"
@change=${(e: Event) => handleFileSelect(e, props)}
/>
${renderRealtimeTalkOptions(props)}
${props.realtimeTalkActive || props.realtimeTalkDetail || props.realtimeTalkTranscript
? html`
<div class="agent-chat__stt-interim agent-chat__talk-status">
${props.realtimeTalkDetail ??
((props.realtimeTalkConversation?.length ?? 0) === 0
? props.realtimeTalkTranscript
: null) ??
(props.realtimeTalkStatus === "thinking"
? "Asking OpenClaw..."
: props.realtimeTalkStatus === "connecting"
? "Connecting Talk..."
: "Talk live")}
</div>
`
: nothing}
<div class="agent-chat__composer-combobox">
<textarea
${ref((el) => {
composerTextarea = el instanceof HTMLTextAreaElement ? el : null;
if (composerTextarea) {
adjustTextareaHeight(composerTextarea);
}
})}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
?disabled=${!props.connected}
aria-autocomplete="list"
aria-controls=${ifDefined(slashMenuVisible ? SLASH_MENU_LISTBOX_ID : undefined)}
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@input=${handleInput}
@blur=${handleBlur}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
rows="1"
></textarea>
<span
id=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
class="agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
</div>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button
type="button"
class="agent-chat__input-btn"
@click=${clickComposerFileInput}
title=${t("chat.composer.attachFile")}
aria-label=${t("chat.composer.attachFile")}
?disabled=${!props.connected}
>
${icons.paperclip}
<span class="agent-chat__control-label">${t("chat.composer.attachFile")}</span>
</button>
${props.onToggleRealtimeTalk
? html`
<button
class="agent-chat__input-btn ${props.realtimeTalkActive
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalk}
title=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
aria-label=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
?disabled=${!props.connected}
>
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
<span class="agent-chat__control-label"
>${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}</span
>
</button>
`
: nothing}
${props.onToggleRealtimeTalkOptions
? html`
<button
class="agent-chat__input-btn ${props.realtimeTalkOptionsOpen
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalkOptions}
title="Talk settings"
aria-label="Talk settings"
aria-expanded=${props.realtimeTalkOptionsOpen ? "true" : "false"}
?disabled=${!props.connected || props.realtimeTalkActive}
>
${icons.settings}
<span class="agent-chat__control-label">Talk settings</span>
</button>
`
: nothing}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
${composerControls && composerControls !== nothing
? html`<div class="agent-chat__composer-controls">${composerControls}</div>`
: nothing}
${renderChatRunControls({
canAbort: showAbortableUi,
connected: props.connected,
draft: visibleDraft,
hasMessages: props.messages.length > 0,
isBusy,
sending: props.sending,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: handleSend,
onStoreDraft: () => {},
showSecondary: false,
})}
</div>
</div>
`;
return html`
<section
@@ -2104,17 +2593,17 @@ export function renderChat(props: ChatProps) {
<div
class="chat-workbench ${
props.workspaceFiles?.collapsed ? "chat-workbench--workspace-collapsed" : ""
props.sessionWorkspace?.collapsed ? "chat-workbench--workspace-collapsed" : ""
}"
>
${renderWorkspaceFileRail(props.workspaceFiles)}
${renderSessionWorkspaceRail(props.sessionWorkspace)}
<div class="chat-workbench__main">
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div
class="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
style="flex: ${sidebarOpen ? `0 1 ${splitRatio * 100}%` : "1 1 100%"}"
>
${thread}
${thread} ${chatColumnFooter}
</div>
${
@@ -2149,184 +2638,6 @@ export function renderChat(props: ChatProps) {
}
</div>
${renderChatQueue({
queue: props.queue,
canAbort: showAbortableUi,
onQueueRetry: props.onQueueRetry,
onQueueSteer: props.onQueueSteer,
onQueueRemove: props.onQueueRemove,
})}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${
props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
${icons.arrowDown} New messages
</button>
`
: nothing
}
<!-- Input bar -->
<div
class="agent-chat__input"
@click=${(event: MouseEvent) => focusComposerFromChrome(event, props.connected)}
>
${renderSlashMenu(requestUpdate, props, visibleDraft)} ${renderAttachmentPreview(props)}
<div class="agent-chat__composer-status-stack">
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
compactBusy,
compactDisabled: !props.connected || isBusy || showAbortableUi,
onCompact: props.onCompact,
})}
${renderChatGoal(activeSession?.goal)}
</div>
<input
type="file"
accept=${CHAT_ATTACHMENT_ACCEPT}
multiple
class="agent-chat__file-input"
@change=${(e: Event) => handleFileSelect(e, props)}
/>
${renderRealtimeTalkOptions(props)}
${
props.realtimeTalkActive || props.realtimeTalkDetail || props.realtimeTalkTranscript
? html`
<div class="agent-chat__stt-interim agent-chat__talk-status">
${props.realtimeTalkDetail ??
((props.realtimeTalkConversation?.length ?? 0) === 0
? props.realtimeTalkTranscript
: null) ??
(props.realtimeTalkStatus === "thinking"
? "Asking OpenClaw..."
: props.realtimeTalkStatus === "connecting"
? "Connecting Talk..."
: "Talk live")}
</div>
`
: nothing
}
<div class="agent-chat__composer-combobox">
<textarea
${ref((el) => {
composerTextarea = el instanceof HTMLTextAreaElement ? el : null;
if (composerTextarea) {
adjustTextareaHeight(composerTextarea);
}
})}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
?disabled=${!props.connected}
aria-autocomplete="list"
aria-controls=${ifDefined(slashMenuVisible ? SLASH_MENU_LISTBOX_ID : undefined)}
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@input=${handleInput}
@blur=${handleBlur}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
rows="1"
></textarea>
<span
id=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
class="agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
</div>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button
type="button"
class="agent-chat__input-btn"
@click=${clickComposerFileInput}
title=${t("chat.composer.attachFile")}
aria-label=${t("chat.composer.attachFile")}
?disabled=${!props.connected}
>
${icons.paperclip}
<span class="agent-chat__control-label">${t("chat.composer.attachFile")}</span>
</button>
${
props.onToggleRealtimeTalk
? html`
<button
class="agent-chat__input-btn ${props.realtimeTalkActive
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalk}
title=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
aria-label=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
?disabled=${!props.connected}
>
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
<span class="agent-chat__control-label"
>${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}</span
>
</button>
`
: nothing
}
${
props.onToggleRealtimeTalkOptions
? html`
<button
class="agent-chat__input-btn ${props.realtimeTalkOptionsOpen
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalkOptions}
title="Talk settings"
aria-label="Talk settings"
aria-expanded=${props.realtimeTalkOptionsOpen ? "true" : "false"}
?disabled=${!props.connected || props.realtimeTalkActive}
>
${icons.settings}
<span class="agent-chat__control-label">Talk settings</span>
</button>
`
: nothing
}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
${
composerControls && composerControls !== nothing
? html`<div class="agent-chat__composer-controls">${composerControls}</div>`
: nothing
}
${renderChatRunControls({
canAbort: showAbortableUi,
connected: props.connected,
draft: visibleDraft,
hasMessages: props.messages.length > 0,
isBusy,
sending: props.sending,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: handleSend,
onStoreDraft: () => {},
showSecondary: false,
})}
</div>
</div>
</div>
</section>
`;
+58 -32
View File
@@ -43,16 +43,18 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
props.allowExternalEmbedUrls ?? false,
)
: null;
const title =
content?.kind === "canvas"
? content.title?.trim() || "Render Preview"
: content?.kind === "image"
? content.title.trim() || "Image Preview"
: content?.kind === "markdown"
? "Markdown Preview"
: "Tool Details";
return html`
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">
${content?.kind === "canvas"
? content.title?.trim() || "Render Preview"
: content?.kind === "markdown"
? "Markdown Preview"
: "Tool Details"}
</div>
<div class="sidebar-title">${title}</div>
<button
@click=${props.onClose}
class="btn"
@@ -111,33 +113,57 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
: nothing}
</div>
`
: html`
<section class="sidebar-markdown-shell">
<div class="sidebar-markdown-shell__toolbar">
<div class="sidebar-markdown-shell__intro">
<div class="sidebar-markdown-shell__eyebrow">
${icons.scrollText}
<span>Rendered Markdown</span>
</div>
<div class="sidebar-markdown-shell__hint">
Sanitized rich-text preview for quick reading.
</div>
: content.kind === "image"
? html`
<div class="chat-tool-card__preview" data-kind="image">
<div class="chat-tool-card__preview-panel" data-side="front">
<img
class="chat-tool-card__preview-image"
src=${content.src}
alt=${title}
style="display:block;max-width:100%;height:auto;border-radius:8px;"
/>
</div>
<button @click=${props.onViewRawText} class="btn btn--sm" type="button">
View Raw Text
</button>
${content.rawText?.trim()
? html`
<div style="margin-top: 12px;">
<button @click=${props.onViewRawText} class="btn" type="button">
View Raw Text
</button>
</div>
`
: nothing}
</div>
${markdownHtml
? html`
<article class="sidebar-markdown-reader sidebar-markdown">
${unsafeHTML(markdownHtml)}
</article>
`
: html`
<div class="sidebar-markdown-empty">No previewable markdown content.</div>
`}
</section>
`
`
: html`
<section class="sidebar-markdown-shell">
<div class="sidebar-markdown-shell__toolbar">
<div class="sidebar-markdown-shell__intro">
<div class="sidebar-markdown-shell__eyebrow">
${icons.scrollText}
<span>Rendered Markdown</span>
</div>
<div class="sidebar-markdown-shell__hint">
Sanitized rich-text preview for quick reading.
</div>
</div>
<button @click=${props.onViewRawText} class="btn btn--sm" type="button">
View Raw Text
</button>
</div>
${markdownHtml
? html`
<article class="sidebar-markdown-reader sidebar-markdown">
${unsafeHTML(markdownHtml)}
</article>
`
: html`
<div class="sidebar-markdown-empty">
No previewable markdown content.
</div>
`}
</section>
`
: html` <div class="muted">No content available</div> `}
</div>
</div>