diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 61ca68ed4690..66e3ed4e5f18 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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? diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 52b744c767c8..ee189f400679 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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( SessionsResolveParamsSchema, ); +export const validateSessionsFilesListParams = lazyCompile( + SessionsFilesListParamsSchema, +); +export const validateSessionsFilesGetParams = lazyCompile( + SessionsFilesGetParamsSchema, +); export const validateSessionsCreateParams = lazyCompile( 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, diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index b2771eb980fa..fcc870290579 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 9d6cbd269ccf..f1d8f1310fe8 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -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( { diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index 171ebe6f1862..1a31a8eeb6dd 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -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">; diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 94dd7dc08ee1..e5085b8e2760 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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``;\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 = ` + + + + + + + + + + openclaw session artifact +`; + 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, }, }); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index ad372aac3081..10ed1f5a9b1c 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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" }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 0f6ec2b30a29..bb083bc13fa9 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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. */ diff --git a/src/gateway/server-methods/sessions-files.test.ts b/src/gateway/server-methods/sessions-files.test.ts new file mode 100644 index 000000000000..2b60e65004ea --- /dev/null +++ b/src/gateway/server-methods/sessions-files.test.ts @@ -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("../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, +) { + 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["calls"]): Record { + expect(calls).toHaveLength(1); + expect(calls[0]?.ok).toBe(true); + return calls[0]?.payload as Record; +} + +function expectError(calls: ReturnType["calls"]): Record { + expect(calls).toHaveLength(1); + expect(calls[0]?.ok).toBe(false); + return calls[0]?.error as Record; +} + +function assistantToolCall(name: string, args: Record) { + 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) => [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) => [ + 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) => [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) => [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) => [ + 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) => [ + 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) => 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) => 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", + }); + }); +}); diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts new file mode 100644 index 000000000000..f65903601f4b --- /dev/null +++ b/src/gateway/server-methods/sessions-files.ts @@ -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>; +type WorkspacePathStat = Awaited>; +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) { + return errorShape(ErrorCodes.INVALID_REQUEST, message, { + details: { + type, + ...details, + }, + }); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : 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 | undefined { + return ( + normalizePathValue(args.path) ?? + normalizePathValue(args.file_path) ?? + normalizePathValue(args.filePath) ?? + normalizePathValue(args.file) + ); +} + +function addTouchedFile( + files: Map, + 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, 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, 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, args: Record) { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + const relevance = new Map(); + 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, +): 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 { + const resolved = resolveTouchedFilePath({ root, fileRoot, filePath: touched.path }); + const base = { + path: touched.path, + name: displayNameForPath(touched.path), + kind: touched.kind, + } satisfies Pick; + 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, +): Promise { + 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(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; +}): 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 => { + 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 { + 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 { + 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(); + 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, + }); + }, +}; diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index d0a273d01a00..cc20a0c5db65 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index e38ce080620f..5be4605ba195 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 2366afc45f05..368c3eecf12a 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 957d4c29c2ec..495c460ff6fc 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 4d8914081cc9..691f05629994 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index b2945e727529..1e17ff833ca2 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index f444ed10cf9f..4cda3295d9ca 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index f8617c096cbc..1afa680fee7e 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 6453517b9746..d01d602623f8 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index b7dd48f2eb98..bd279e3f4694 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index e0cb8b8e2e56..8cdc7587f784 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index f66c44f143e5..398527ebad24 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 909ea9da8563..4928d0a3216d 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -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", diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index 831c075261a8..41ff9850a73e 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 15662bdadbfa..ad86338dfcce 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 2b1cc80d9bbc..64b3faed2948 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index c1b4beed51e3..6189ac1a5b1d 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 680f43f3fb82..7d075f092643 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index 381185978127..0d39001ec360 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -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 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 64deb7aa9f16..16a1b71b91cd 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -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: { diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 9d59ca1b5f4a..7996ce7eed66 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -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: { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1084cd77fe70..f3ba49f8d83e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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: { diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index b44e5a365272..1cbb23dfd4a2 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -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: { diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 35e1310ad078..65460cfa7bbc 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -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: { diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 9e810a851442..88d984c437f0 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -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: { diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 61e99175ddf2..ac6ee5838b7e 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -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: { diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index d863dd6ff205..d97648332060 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -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: { diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index 9a488e592430..1c17d10325d1 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -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: { diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index ca78a4c298df..569647fef137 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -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: { diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 8a3896850a2d..c13692a66783 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -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: { diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index fdd2086bd93a..c06beabcfddb 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -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: { diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index d5bef57253c3..6434ea2c81b5 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -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: { diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index 189e8cdf49ba..bc57e4676ff4 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -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: { diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index aceeff4f55f6..337b535a30ba 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -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: { diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 0177ee6f522f..6444c2ed973e 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -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: { diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 2da31c0c54e0..e432b6771339 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -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: { diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 8a675e7f5934..cc63eb59b13a 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -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: { diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 31deb0923bcc..b885bcc8b9e0 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -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: { diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 02d1ec79dcdf..a65cc07bffb5 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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); } } diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 56adf62dbac6..ab7b01304f6d 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8e3609d5e8ab..a670d3003f0e 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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 | null; collapsed: boolean; error: string | null; - list: AgentsFilesListResult | null; + list: SessionWorkspaceListResult | null; loading: boolean; + pendingReload: boolean; requestId: number; + sessionKey: string; }; const chatWorkspaceFilesStates = new WeakMap(); 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 | 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("agents.files.list", { - agentId: chatAgentId, + const res = await state.client?.request( + "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("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( + "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( + "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: () => { diff --git a/ui/src/ui/chat/sidebar-session-picker.browser.test.ts b/ui/src/ui/chat/sidebar-session-picker.browser.test.ts index 545bd06b3097..7da72307a32d 100644 --- a/ui/src/ui/chat/sidebar-session-picker.browser.test.ts +++ b/ui/src/ui/chat/sidebar-session-picker.browser.test.ts @@ -27,7 +27,7 @@ function iconSvg() { return ``; } -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 ? ` -