From 02a92ea1c52b47db781c9214e8ffbd5c6ac3fe14 Mon Sep 17 00:00:00 2001 From: chaitanya <116256002+HEREISCB@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:05:58 +0530 Subject: [PATCH 1/2] feat: panel side setting, hidden-file browsing, media previews, and video attachments Adds a Settings > Appearance 'Panel side' preference to dock the tool panel (files, terminal, diff, preview) on the left with chat on the right; a Files-panel toggle that lists gitignored and dot-prefixed entries via a direct filesystem walk; inline video/audio preview for workspace files; and end-to-end video attachments in chat (stored with the thread, played inline, handed to providers as a file path). Also fixes the revert prune deleting non-image attachments from disk, stops a file dropped outside the composer from navigating the app, and includes review-bot fixes for mixed image/video blob lifecycle, the image lightbox, video MIME validation, and left-dock titlebar insets. --- .../settings/DesktopClientSettings.test.ts | 1 + apps/server/src/assets/AssetAccess.ts | 3 +- apps/server/src/attachmentStore.ts | 31 +++++- apps/server/src/imageMime.ts | 31 ++++-- .../Layers/ProjectionPipeline.ts | 3 - apps/server/src/orchestration/Normalizer.ts | 42 +++++--- .../src/provider/Layers/ClaudeAdapter.ts | 13 ++- .../src/provider/Layers/CodexAdapter.ts | 9 +- .../provider/Layers/CodexSessionRuntime.ts | 14 ++- .../src/provider/Layers/CursorAdapter.ts | 13 ++- .../server/src/provider/Layers/GrokAdapter.ts | 12 ++- .../src/workspace/WorkspaceEntries.test.ts | 24 +++++ apps/server/src/workspace/WorkspaceEntries.ts | 51 ++++++++++ apps/web/src/components/ChatView.logic.ts | 7 +- apps/web/src/components/ChatView.tsx | 96 ++++++++++++------- apps/web/src/components/RightPanelTabs.tsx | 21 +++- .../src/components/WorkspacePageHeader.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 55 +++++++++-- .../components/chat/ExpandedImagePreview.tsx | 8 +- .../src/components/chat/MessagesTimeline.tsx | 11 ++- .../src/components/files/FileBrowserPanel.tsx | 40 +++++++- .../src/components/files/FilePreviewPanel.tsx | 85 ++++++++++++---- .../files/projectFilesQueryState.ts | 15 ++- .../components/preview/PreviewPanelShell.tsx | 16 +++- .../preview/RightPanelResizeHandle.tsx | 9 +- .../components/settings/SettingsPanels.tsx | 36 +++++++ .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/composerDraftStore.ts | 11 ++- apps/web/src/routes/__root.tsx | 16 ++++ apps/web/src/types.ts | 7 +- docs/user/composer.md | 8 ++ docs/user/workspace-panel.md | 12 +++ packages/contracts/src/orchestration.ts | 44 ++++++++- packages/contracts/src/project.ts | 3 + packages/contracts/src/settings.ts | 10 ++ packages/shared/src/filePreview.ts | 24 ++++- 36 files changed, 671 insertions(+), 124 deletions(-) create mode 100644 docs/user/workspace-panel.md diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..50b65a35a37b 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -38,6 +38,7 @@ const clientSettings: ClientSettings = { planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, + rightPanelSide: "right", sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 232a41e5a9c8..35b38acc4275 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, + isWorkspaceMediaPreviewPath, isWorkspacePreviewEntryPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -257,7 +258,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - claims = isWorkspaceImagePreviewPath(resolved.relativePath) + claims = isWorkspaceMediaPreviewPath(resolved.relativePath) ? { version: 1, kind: "workspace-file-exact", diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..62203811958e 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -8,9 +8,18 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; -import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; +import { + inferImageExtension, + inferVideoExtension, + SAFE_IMAGE_FILE_EXTENSIONS, + SAFE_VIDEO_FILE_EXTENSIONS, +} from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_FILENAME_EXTENSIONS = [ + ...SAFE_IMAGE_FILE_EXTENSIONS, + ...SAFE_VIDEO_FILE_EXTENSIONS, + ".bin", +]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -63,9 +72,27 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "video": { + const extension = inferVideoExtension({ + mimeType: attachment.mimeType, + fileName: attachment.name, + }); + return `${attachment.id}${extension}`; + } } } +/** + * No provider ingests raw video, so adapters forward a video attachment as + * this prompt line; the agent can then read or process the file with tools. + */ +export function videoAttachmentPromptText(input: { + readonly name: string; + readonly path: string; +}): string { + return `[The user attached the video file '${input.name}'. It is saved at: ${input.path}]`; +} + export function resolveAttachmentPath(input: { readonly attachmentsDir: string; readonly attachment: ChatAttachment; diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 66ce6096e853..06f9c13b2e27 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,6 +29,15 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +export const VIDEO_EXTENSION_BY_MIME_TYPE: Record = { + "video/mp4": ".mp4", + "video/quicktime": ".mov", + "video/webm": ".webm", + "video/x-m4v": ".m4v", +}; + +export const SAFE_VIDEO_FILE_EXTENSIONS = new Set([".m4v", ".mov", ".mp4", ".webm"]); + // Whether `code` is a character the base64 payload may contain, aside from // the whitespace handled separately below. function isBase64Char(code: number): boolean { @@ -112,26 +121,36 @@ export function parseBase64DataUrl( return { mimeType, base64 }; } -export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { +function inferAttachmentExtension( + input: { mimeType: string; fileName?: string }, + extensionByMimeType: Record, + safeExtensions: ReadonlySet, +): string { const key = input.mimeType.toLowerCase(); - const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) - ? IMAGE_EXTENSION_BY_MIME_TYPE[key] - : undefined; + const fromMime = Object.hasOwn(extensionByMimeType, key) ? extensionByMimeType[key] : undefined; if (fromMime) { return fromMime; } const fromMimeExtension = Mime.getExtension(input.mimeType); - if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { + if (fromMimeExtension && safeExtensions.has(fromMimeExtension)) { return fromMimeExtension; } const fileName = input.fileName?.trim() ?? ""; const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; - if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { + if (safeExtensions.has(fileNameExtension)) { return fileNameExtension; } return ".bin"; } + +export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { + return inferAttachmentExtension(input, IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS); +} + +export function inferVideoExtension(input: { mimeType: string; fileName?: string }): string { + return inferAttachmentExtension(input, VIDEO_EXTENSION_BY_MIME_TYPE, SAFE_VIDEO_FILE_EXTENSIONS); +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..ae388261ded6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -338,9 +338,6 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..dd9c6e6071c8 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -7,7 +7,9 @@ import { type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, + isProviderSendTurnSupportedVideoMimeType, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + PROVIDER_SEND_TURN_MAX_VIDEO_BYTES, } from "@t3tools/contracts"; import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; @@ -109,16 +111,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + const parsedMimeAccepted = + parsed !== null && + (attachment.type === "video" + ? isProviderSendTurnSupportedVideoMimeType(parsed.mimeType) + : parsed.mimeType.startsWith("image/")); + if (!parsed || !parsedMimeAccepted) { return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid ${attachment.type} attachment payload for '${attachment.name}'.`, }); } + const maxBytes = + attachment.type === "video" + ? PROVIDER_SEND_TURN_MAX_VIDEO_BYTES + : PROVIDER_SEND_TURN_MAX_IMAGE_BYTES; const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) { return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + message: `Attachment '${attachment.name}' is empty or too large.`, }); } @@ -129,13 +140,22 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); } - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; + const persistedAttachment = + attachment.type === "video" + ? { + type: "video" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + } + : { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 02d73e372d2b..035f4a2dcaa1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -72,7 +72,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; @@ -1265,6 +1265,17 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( for (const attachment of input.attachments ?? []) { if (attachment.type !== "image") { + // Claude ingests no raw video; hand the agent the stored file's path. + const videoPath = resolveAttachmentPath({ + attachmentsDir: dependencies.attachmentsDir, + attachment, + }); + if (videoPath) { + sdkContent.push({ + type: "text", + text: videoAttachmentPromptText({ name: attachment.name, path: videoPath }), + }); + } continue; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index bc48f94b3866..60c515d9266d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -52,7 +52,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, @@ -1780,6 +1780,13 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( detail: `Invalid attachment id '${attachment.id}'.`, }); } + if (attachment.type !== "image") { + // No raw-video ingestion; pass the stored file's path. + return { + type: "text" as const, + text: videoAttachmentPromptText({ name: attachment.name, path: attachmentPath }), + }; + } const bytes = yield* fileSystem.readFile(attachmentPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index fd926e43d7bf..69de1b44f5b5 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -111,10 +111,9 @@ export interface CodexSessionRuntimeOptions { export interface CodexSessionRuntimeSendTurnInput { readonly input?: string; - readonly attachments?: ReadonlyArray<{ - readonly type: "image"; - readonly url: string; - }>; + readonly attachments?: ReadonlyArray< + { readonly type: "image"; readonly url: string } | { readonly type: "text"; readonly text: string } + >; readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; @@ -368,10 +367,9 @@ export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; readonly prompt?: string; - readonly attachments?: ReadonlyArray<{ - readonly type: "image"; - readonly url: string; - }>; + readonly attachments?: ReadonlyArray< + { readonly type: "image"; readonly url: string } | { readonly type: "text"; readonly text: string } + >; readonly model?: string; readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae8..69f5e31df892 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -40,7 +40,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { @@ -983,6 +983,17 @@ export function makeCursorAdapter( detail: `Invalid attachment id '${attachment.id}'.`, }); } + if (attachment.type !== "image") { + // No raw-video ingestion; pass the stored file's path. + promptParts.push({ + type: "text", + text: videoAttachmentPromptText({ + name: attachment.name, + path: attachmentPath, + }), + }); + continue; + } const bytes = yield* fileSystem.readFile(attachmentPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..3b438c0fea8a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -32,7 +32,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { @@ -972,6 +972,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte detail: `Invalid attachment id '${attachment.id}'.`, }); } + if (attachment.type !== "image") { + // No raw-video ingestion; pass the stored file's path. + return { + type: "text", + text: videoAttachmentPromptText({ + name: attachment.name, + path: attachmentPath, + }), + } satisfies EffectAcpSchema.ContentBlock; + } const bytes = yield* fileSystem.readFile(attachmentPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d47aaaec8264..584240da5d76 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -121,6 +121,30 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(result.truncated).toBe(false); }), ); + + it.effect("includeHidden lists dotfiles and gitignored files, still skipping .git and node_modules", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, ".env"); + yield* writeTextFile(cwd, ".gitignore", "media/\n"); + yield* writeTextFile(cwd, "media/clip.mp4"); + yield* writeTextFile(cwd, ".git/HEAD"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.list({ cwd, includeHidden: true }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src/index.ts"); + expect(paths).toContain(".env"); + expect(paths).toContain("media"); + expect(paths).toContain("media/clip.mp4"); + expect(paths.some((entryPath) => entryPath.startsWith(".git/") || entryPath === ".git")).toBe(false); + expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); + expect(result.truncated).toBe(false); + }), + ); }); describe("search", () => { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 28a30481b1b6..a1c0c61cce59 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -138,6 +138,52 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu return path.resolve(expandHomePath(input.cwd, path), input.partialPath); }); +// The search index honors gitignore, so ignored and dot-prefixed entries never +// reach it. `includeHidden` listings walk the filesystem directly instead; +// these two are still skipped so the tree stays usable. +const HIDDEN_WALK_SKIP_DIRS = new Set([".git", "node_modules"]); +const HIDDEN_WALK_MAX_ENTRIES = 25_000; + +async function walkWorkspaceEntries( + root: string, + join: (left: string, right: string) => string, +): Promise { + const entries: Array = []; + let truncated = false; + // Breadth-first so a truncated listing keeps the shallow structure intact. + const pendingDirs = [""]; + for (let index = 0; index < pendingDirs.length && !truncated; index += 1) { + const relativeDir = pendingDirs[index]!; + let dirents: ReadonlyArray<{ readonly name: string; isDirectory(): boolean }>; + try { + dirents = await NodeFSP.readdir(join(root, relativeDir), { withFileTypes: true }); + } catch { + // Unreadable directories (permissions, races) are skipped, not fatal. + continue; + } + for (const dirent of dirents) { + const name = dirent.name; + if (entries.length >= HIDDEN_WALK_MAX_ENTRIES) { + truncated = true; + break; + } + const relativePath = relativeDir ? `${relativeDir}/${name}` : name; + if (dirent.isDirectory()) { + if (HIDDEN_WALK_SKIP_DIRS.has(name)) continue; + entries.push({ path: relativePath, kind: "directory" }); + pendingDirs.push(relativePath); + } else { + // Symlinks are listed but not followed, so cycles cannot recurse. + entries.push({ path: relativePath, kind: "file" }); + } + } + } + return { + entries: entries.toSorted((left, right) => left.path.localeCompare(right.path)), + truncated, + }; +} + export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -275,6 +321,11 @@ export const make = Effect.gen(function* () { const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + if (input.includeHidden === true) { + return yield* Effect.promise(() => + walkWorkspaceEntries(normalizedCwd, (left, right) => path.join(left, right)), + ); + } return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 83bea23b65e2..a1fc84700436 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -277,9 +277,6 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { return; } for (const attachment of message.attachments) { - if (attachment.type !== "image") { - continue; - } revokeBlobPreviewUrl(attachment.previewUrl); } } @@ -289,8 +286,10 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return []; } const previewUrls: string[] = []; + // Every attachment kind (image and video) stages a blob preview, and the + // handoff promotion compares counts against all server preview URLs — an + // image-only collection here would leave mixed messages stuck on blobs. for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ed051154a6..26937cbb6a87 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1725,7 +1725,12 @@ function ChatViewContent(props: ChatViewProps) { const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; + const rightPanelSide = useClientSettings((settings) => settings.rightPanelSide); + const rightPanelDockedLeft = rightPanelSide === "left" && !shouldUseRightPanelSheet; + // Docked left (and not maximized), the chat column reaches the workspace's + // right edge, so the chat header is what sits under the titlebar controls. + const inlineRightPanelOwnsTitleBar = + rightPanelOpen && !shouldUseRightPanelSheet && (!rightPanelDockedLeft || rightPanelMaximized); useEffect(() => { if (!activeThreadRef) return; @@ -2526,13 +2531,13 @@ function ChatViewContent(props: ChatViewProps) { continue; } - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], + const serverPreviews = serverMessage.attachments.flatMap((attachment) => + attachment.previewUrl ? [{ previewUrl: attachment.previewUrl, type: attachment.type }] : [], ); if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) + serverPreviews.length === 0 || + serverPreviews.length !== handoffPreviewUrls.length || + serverPreviews.some((preview) => preview.previewUrl.startsWith("blob:")) ) { continue; } @@ -2543,19 +2548,21 @@ function ChatViewContent(props: ChatViewProps) { const imageInstances: HTMLImageElement[] = []; const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), + serverPreviews.map(({ previewUrl, type }) => { + // Only images are preload-verified; a video swaps to its asset URL + // directly and the