From e18504c88edb28273c512f3952aa42ae86b5cb34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 15 Jun 2026 00:15:36 -0700 Subject: [PATCH 1/8] Add workspace file browsing and preview - add project list/read RPCs and workspace file safety checks - introduce file browser and preview surfaces in the right panel - replace vscode icon assets with Pierre icons --- apps/server/src/server.test.ts | 36 + .../workspace/Layers/WorkspaceEntries.test.ts | 29 + .../src/workspace/Layers/WorkspaceEntries.ts | 15 + .../Layers/WorkspaceFileSystem.test.ts | 58 + .../workspace/Layers/WorkspaceFileSystem.ts | 67 +- .../workspace/Services/WorkspaceEntries.ts | 9 + .../workspace/Services/WorkspaceFileSystem.ts | 23 +- apps/server/src/ws.ts | 31 + apps/web/THIRD_PARTY_NOTICES.md | 11 + apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.tsx | 27 +- apps/web/src/components/ChatView.browser.tsx | 148 +- apps/web/src/components/ChatView.tsx | 126 +- .../src/components/ComposerPromptEditor.tsx | 2 +- .../src/components/NoActiveThreadState.tsx | 6 +- apps/web/src/components/RightPanelTabs.tsx | 101 +- .../src/components/chat/ChangedFilesTree.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 75 +- .../components/chat/ComposerCommandMenu.tsx | 4 +- apps/web/src/components/chat/FileTagChip.tsx | 6 +- .../chat/MessagesTimeline.browser.tsx | 8 +- .../components/chat/PanelLayoutControls.tsx | 114 + .../src/components/chat/PierreEntryIcon.tsx | 95 + .../src/components/chat/VscodeEntryIcon.tsx | 37 - .../src/components/files/FileBrowserPanel.tsx | 151 + .../src/components/files/FilePreviewPanel.tsx | 181 + .../web/src/components/files/filePath.test.ts | 23 + apps/web/src/components/files/filePath.ts | 17 + .../components/preview/PreviewPanelShell.tsx | 17 +- apps/web/src/environmentApi.ts | 2 + .../service.threadSubscriptions.test.ts | 2 + apps/web/src/index.css | 43 + apps/web/src/localApi.test.ts | 2 + apps/web/src/pierre-icons.test.ts | 57 + apps/web/src/pierre-icons.ts | 117 + apps/web/src/rightPanelStore.test.ts | 26 + apps/web/src/rightPanelStore.ts | 37 +- .../vscode-icons-language-associations.json | 481 - apps/web/src/vscode-icons-manifest.json | 8351 ----------------- apps/web/src/vscode-icons.test.ts | 52 - apps/web/src/vscode-icons.ts | 211 - package.json | 1 - packages/client-runtime/src/wsRpcClient.ts | 6 + packages/contracts/src/ipc.ts | 6 + packages/contracts/src/project.ts | 44 +- packages/contracts/src/rpc.ts | 22 + pnpm-lock.yaml | 30 + scripts/sync-vscode-icons.mjs | 153 - 49 files changed, 1603 insertions(+), 9464 deletions(-) create mode 100644 apps/web/THIRD_PARTY_NOTICES.md create mode 100644 apps/web/src/components/chat/PanelLayoutControls.tsx create mode 100644 apps/web/src/components/chat/PierreEntryIcon.tsx delete mode 100644 apps/web/src/components/chat/VscodeEntryIcon.tsx create mode 100644 apps/web/src/components/files/FileBrowserPanel.tsx create mode 100644 apps/web/src/components/files/FilePreviewPanel.tsx create mode 100644 apps/web/src/components/files/filePath.test.ts create mode 100644 apps/web/src/components/files/filePath.ts create mode 100644 apps/web/src/pierre-icons.test.ts create mode 100644 apps/web/src/pierre-icons.ts delete mode 100644 apps/web/src/vscode-icons-language-associations.json delete mode 100644 apps/web/src/vscode-icons-manifest.json delete mode 100644 apps/web/src/vscode-icons.test.ts delete mode 100644 apps/web/src/vscode-icons.ts delete mode 100644 scripts/sync-vscode-icons.mjs diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 40c9c7cd9a8..7b883ae88f5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4356,6 +4356,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-files-" }); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "index.ts"), + "export const answer = 42;\n", + ); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all({ + listing: client[WS_METHODS.projectsListEntries]({ cwd: workspaceDir }), + file: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "src/index.ts", + }), + }), + ), + ); + + assert.isTrue(response.listing.entries.some((entry) => entry.path === "src/index.ts")); + assert.deepEqual(response.file, { + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index ffee4d56a52..b7f9bb8ba6b 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -84,6 +84,35 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { vi.restoreAllMocks(); }); + describe("list", () => { + it.effect("returns the complete cached workspace index", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "README.md"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + + const workspaceEntries = yield* WorkspaceEntries; + const result = yield* workspaceEntries.list({ cwd }); + + expect(result.entries).toEqual( + expect.arrayContaining([ + { path: "src", kind: "directory" }, + { path: "src/components", kind: "directory", parentPath: "src" }, + { + path: "src/components/Composer.tsx", + kind: "file", + parentPath: "src/components", + }, + { path: "README.md", kind: "file" }, + ]), + ); + expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); + expect(result.truncated).toBe(false); + }), + ); + }); + describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 95d957136b7..b63c065d09a 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -528,9 +528,24 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const list: WorkspaceEntriesShape["list"] = Effect.fn("WorkspaceEntries.list")(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( + Effect.map((index) => ({ + entries: index.entries.map(({ path: entryPath, kind, parentPath }) => ({ + path: entryPath, + kind, + ...(parentPath ? { parentPath } : {}), + })), + truncated: index.truncated, + })), + ); + }); + return { browse, invalidate, + list, search, } satisfies WorkspaceEntriesShape; }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 9b93b1e863b..149873b3e50 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -54,6 +54,64 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( }); it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { + describe("readFile", () => { + it.effect("reads UTF-8 files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "src/index.ts", + }); + + expect(result).toEqual({ + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }), + ); + + it.effect("rejects reads outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "../escape.md" }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + + it.effect("rejects symlinks that resolve outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); + yield* fileSystem.symlink( + path.join(outsideDir, "secret.txt"), + path.join(cwd, "linked-secret.txt"), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "linked-secret.txt" }) + .pipe(Effect.flip); + + expect(error.message).toContain("resolves outside the project root"); + }), + ); + }); + describe("writeFile", () => { it.effect("writes files relative to the workspace root", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 9f53ade1bb9..309896e85a0 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import fsPromises from "node:fs/promises"; + import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -11,12 +14,74 @@ import { import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; + export const makeWorkspaceFileSystem = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries; + const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( + function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const result = yield* Effect.tryPromise({ + try: async () => { + const [realWorkspaceRoot, realTargetPath] = await Promise.all([ + fsPromises.realpath(input.cwd), + fsPromises.realpath(target.absolutePath), + ]); + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + throw new Error("Workspace file path resolves outside the project root."); + } + + const handle = await fsPromises.open(realTargetPath, "r"); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error("Workspace path is not a file."); + } + const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); + const buffer = Buffer.alloc(bytesToRead); + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); + const fileBytes = buffer.subarray(0, bytesRead); + if (fileBytes.includes(0)) { + throw new Error("Binary files cannot be previewed as text."); + } + const contents = new TextDecoder("utf-8").decode(fileBytes); + return { + relativePath: target.relativePath, + contents, + byteLength: stat.size, + truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, + }; + } finally { + await handle.close(); + } + }, + catch: (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + + return result; + }, + ); + const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { @@ -52,7 +117,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { yield* workspaceEntries.invalidate(input.cwd); return { relativePath: target.relativePath }; }); - return { writeFile } satisfies WorkspaceFileSystemShape; + return { readFile, writeFile } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index ba65da04f38..888eff2ef3d 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -13,6 +13,8 @@ import type * as Effect from "effect/Effect"; import type { FilesystemBrowseInput, FilesystemBrowseResult, + ProjectListEntriesInput, + ProjectListEntriesResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; @@ -50,6 +52,13 @@ export interface WorkspaceEntriesShape { input: FilesystemBrowseInput, ) => Effect.Effect; + /** + * List the cached workspace index for a project root. + */ + readonly list: ( + input: ProjectListEntriesInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 16fcdf0b57f..5126ec417bf 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -10,7 +10,12 @@ import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; +import type { + ProjectReadFileInput, + ProjectReadFileResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "@t3tools/contracts"; import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( @@ -22,12 +27,26 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass Effect.Effect< + ProjectReadFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + /** * Write a file relative to the workspace root. * diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2823923e033..bfbbd3fc678 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,8 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProjectListEntriesError, + ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -160,6 +162,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], + [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], @@ -1172,6 +1176,33 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListEntries]: (input) => + observeRpcEffect( + WS_METHODS.projectsListEntries, + workspaceEntries.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + message: `Failed to list workspace entries: ${cause.detail}`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsReadFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsReadFile, + workspaceFileSystem.readFile(input).pipe( + Effect.mapError((cause) => { + const message = isWorkspacePathOutsideRootError(cause) + ? "Workspace file path must stay within the project root." + : `Failed to read workspace file: ${cause.detail}`; + return new ProjectReadFileError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000000..c9a675ef41c --- /dev/null +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## vscode-icons + +The custom file icon symbols in `src/pierre-icons.ts` are adapted from the +[`vscode-icons`](https://github.com/vscode-icons/vscode-icons) project. + +Copyright (c) 2016 Roberto Huertas + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/package.json b/apps/web/package.json index cbf554a6679..7704365b0ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3aba45249fa..c0bf82babd2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -33,12 +33,8 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; -import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; -import { - getVscodeIconUrlForEntry, - hasSpecificVscodeIconForFileName, - syntheticFileNameForLanguageId, -} from "../vscode-icons"; +import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; @@ -443,22 +439,17 @@ function MarkdownCodeBlockTitleContent({ language: string; theme: "light" | "dark"; }) { - const [failedIconUrl, setFailedIconUrl] = useState(null); - if (fenceTitle) { return ( <> - + {fenceTitle} ); } const fileName = syntheticFileNameForLanguageId(language); - const iconUrl = hasSpecificVscodeIconForFileName(fileName, theme) - ? getVscodeIconUrlForEntry(fileName, "file", theme) - : null; - if (!iconUrl || failedIconUrl === iconUrl) { + if (!hasSpecificPierreIconForFileName(fileName)) { return {language}; } return ( @@ -468,15 +459,7 @@ function MarkdownCodeBlockTitleContent({ } > - setFailedIconUrl(iconUrl)} - /> + {language} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 81b9c74231c..03ba4f0cde6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -53,7 +53,7 @@ import { resetSavedEnvironmentRuntimeStoreForTests, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +} from "../environments/runtime/catalog"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, removeInlineTerminalContextPlaceholder, @@ -2097,6 +2097,127 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps panel toggles fixed and can maximize the right panel", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-maximize-right-panel" as MessageId, + targetText: "maximize right panel", + }), + }); + + try { + const terminalToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", + ); + const rightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find right panel toggle.", + ); + const headerActions = await waitForElement( + () => document.querySelector("[data-chat-header-actions]"), + "Unable to find chat header actions.", + ); + const chatHeader = await waitForElement( + () => document.querySelector("[data-chat-header]"), + "Unable to find chat header.", + ); + const panelLayoutControls = await waitForElement( + () => document.querySelector("[data-panel-layout-controls]"), + "Unable to find panel layout controls.", + ); + expect(chatHeader.getBoundingClientRect().height).toBe(52); + expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); + expect(panelLayoutControls.getBoundingClientRect().top).toBe( + chatHeader.getBoundingClientRect().top, + ); + const initialTerminalRect = terminalToggle.getBoundingClientRect(); + const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); + const initialControlRects = [initialTerminalRect, initialRightPanelRect]; + expect(document.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); + expect(initialControlRects.every((rect) => rect.width === 28 && rect.height === 28)).toBe( + true, + ); + expect(initialControlRects.every((rect) => rect.top === initialControlRects[0]?.top)).toBe( + true, + ); + expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4); + expect( + initialTerminalRect.left - + (headerActions.lastElementChild?.getBoundingClientRect().right ?? Number.NaN), + ).toBe(4); + + document.documentElement.classList.add("wco"); + expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); + expect(panelLayoutControls.getBoundingClientRect().top).toBe( + chatHeader.getBoundingClientRect().top, + ); + document.documentElement.classList.remove("wco"); + + rightPanelToggle.click(); + + const maximizeButton = await waitForElement( + () => document.querySelector('button[aria-label="Maximize panel"]'), + "Unable to find maximize panel button.", + ); + const rightPanelTabbar = await waitForElement( + () => document.querySelector("[data-right-panel-tabbar]"), + "Unable to find right panel tab bar.", + ); + const maximizeRect = maximizeButton.getBoundingClientRect(); + const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); + expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); + expect(rightPanelTabbarRect.height).toBe(52); + expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); + expect(maximizeRect.width).toBe(28); + expect(maximizeRect.height).toBe(28); + expect(maximizeRect.top).toBe(initialTerminalRect.top); + expect(initialTerminalRect.left - maximizeRect.right).toBe(4); + expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(initialTerminalRect.left, 1); + expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo( + initialRightPanelRect.left, + 1, + ); + + document.documentElement.classList.add("wco"); + expect(rightPanelTabbar.getBoundingClientRect().height).toBe( + panelLayoutControls.getBoundingClientRect().height, + ); + expect(rightPanelTabbar.getBoundingClientRect().top).toBe( + panelLayoutControls.getBoundingClientRect().top, + ); + document.documentElement.classList.remove("wco"); + + maximizeButton.click(); + + await vi.waitFor(() => { + const chatColumn = document.querySelector( + '[data-chat-column-maximized-away="true"]', + ); + const panel = document.querySelector( + '[data-preview-panel-mode="inline"][data-preview-panel-maximized="true"]', + ); + expect(chatColumn?.getBoundingClientRect().width).toBe(0); + expect(panel?.getBoundingClientRect().width).toBeGreaterThan(1_000); + expect( + document.querySelector('button[aria-label="Restore panel size"]'), + ).not.toBeNull(); + expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo( + initialTerminalRect.left, + 1, + ); + expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo( + initialRightPanelRect.left, + 1, + ); + }); + } finally { + await mounted.cleanup(); + } + }); + it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, @@ -2113,11 +2234,10 @@ describe("ChatView timeline estimator parity (full app)", () => { ); rightPanelToggle.click(); - const addSurface = await waitForElement( - () => document.querySelector('button[aria-label="Add panel surface"]'), - "Unable to find add panel surface button.", - ); - expect(document.body.textContent).toContain("Open a surface"); + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Open a surface"); + }); + expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); expect( selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), ).toEqual({ @@ -2127,16 +2247,14 @@ describe("ChatView timeline estimator parity (full app)", () => { }); expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); - addSurface.click(); - - const terminalItem = await waitForElement( + const emptyStateTerminalButton = await waitForElement( () => - Array.from(document.querySelectorAll('[role="menuitem"]')).find( - (item) => item.textContent?.trim() === "Terminal", + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Start a shell in this workspace."), ) ?? null, - "Unable to find Terminal panel menu item.", + "Unable to find the empty-state Terminal button.", ); - terminalItem.click(); + emptyStateTerminalButton.click(); await vi.waitFor(() => { expect( @@ -2146,6 +2264,10 @@ describe("ChatView timeline estimator parity (full app)", () => { ).toEqual(["term-1"]); }); + const addSurface = await waitForElement( + () => document.querySelector('button[aria-label="Add panel surface"]'), + "Unable to find add panel surface button beside the tabs.", + ); addSurface.click(); const secondTerminalItem = await waitForElement( () => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 82254b70970..94608757630 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -35,7 +35,7 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useVcsStatus } from "~/lib/vcsStatusState"; -import { usePrimaryEnvironmentId } from "../environments/primary"; +import { usePrimaryEnvironmentId } from "../environments/primary/context"; import { readEnvironmentApi } from "../environmentApi"; import { resolveAssetUrl } from "../assets/assetUrls"; import { isElectron } from "../env"; @@ -111,6 +111,8 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const FileBrowserPanel = lazy(() => import("./files/FileBrowserPanel")); +const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -137,10 +139,10 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { - reconnectSavedEnvironment, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +} from "../environments/runtime/catalog"; +import { reconnectSavedEnvironment } from "../environments/runtime/service"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -167,6 +169,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; +import { PanelLayoutControls } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -1152,6 +1155,9 @@ export default function ChatView(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( + null, + ); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -1324,6 +1330,9 @@ export default function ChatView(props: ChatViewProps) { const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; + const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const rightPanelMaximized = + canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { @@ -2209,6 +2218,8 @@ export default function ChatView(props: ChatViewProps) { }), [terminalUiState.terminalOpen], ); + const terminalToggleShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.toggle"); + const rightPanelToggleShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -2257,6 +2268,14 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen, threadId, ]); + const addFilesSurface = () => { + if (!activeThreadRef || !activeProject) return; + useRightPanelStore.getState().open(activeThreadRef, "files"); + }; + const openFileSurface = (relativePath: string) => { + if (!activeThreadRef || !activeProject) return; + useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + }; // Right-panel arbitration: // - The diff panel's openness is mirrored by the `?diff=1` URL search // param so it deep-links cleanly. The store still records preview/plan @@ -2981,12 +3000,14 @@ export default function ChatView(props: ChatViewProps) { }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); const closePlanSidebar = useCallback(() => { if (!activeThreadRef) return; + setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]); const closePreviewPanel = useCallback(() => { if (!activeThreadRef) return; + setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); }, [activeThreadRef]); const activateRightPanelSurface = useCallback( @@ -3020,8 +3041,17 @@ export default function ChatView(props: ChatViewProps) { ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; + if (rightPanelOpen) { + setMaximizedRightPanelThreadKey(null); + } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef]); + }, [activeThreadRef, rightPanelOpen]); + const toggleRightPanelMaximized = () => { + if (!canMaximizeRightPanel) return; + setMaximizedRightPanelThreadKey((threadKey) => + threadKey === routeThreadKey ? null : routeThreadKey, + ); + }; const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; @@ -4483,23 +4513,43 @@ export default function ChatView(props: ChatViewProps) { } return ( -
+
{isElectron && activeThreadRef ? ( ) : null} -
+ +
{/* Top bar */}
@@ -4732,6 +4777,7 @@ export default function ChatView(props: ChatViewProps) { {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( {activeRightPanelSurface?.kind === "preview" ? ( @@ -4778,6 +4826,29 @@ export default function ChatView(props: ChatViewProps) { + ) : activeRightPanelSurface?.kind === "files" && activeProject ? ( + + + + + + ) : activeRightPanelSurface?.kind === "file" && activeProject ? ( + + + ) : null} ) : null} @@ -4795,8 +4866,10 @@ export default function ChatView(props: ChatViewProps) { onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddFiles={addFilesSurface} browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} + filesAvailable={Boolean(activeProject)} > {activeRightPanelSurface?.kind === "preview" ? ( @@ -4845,6 +4918,29 @@ export default function ChatView(props: ChatViewProps) { mode="embedded" onClose={closePlanSidebar} /> + ) : activeRightPanelSurface?.kind === "files" && activeProject ? ( + + + + + + ) : activeRightPanelSurface?.kind === "file" && activeProject ? ( + + + ) : null} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2fcbd929e17..18579cda6d3 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -64,7 +64,7 @@ import { type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { basenameOfPath } from "~/vscode-icons"; +import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index a2a801a3b4e..c874ee58a98 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -10,13 +10,11 @@ export function NoActiveThreadState() {
{isElectron ? ( - + No active thread ) : ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b4d5c9cbb45..9231b2e13fc 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,19 +1,29 @@ import type { PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + ClipboardList, + FileCode2, + FileDiff, + Files, + Globe2, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type ReactNode, useState } from "react"; import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; interface RightPanelTabsProps { mode: PreviewPanelMode; + maximized?: boolean; surfaces: readonly RightPanelSurface[]; activeSurfaceId: string | null; previewSessions: Readonly>; @@ -23,8 +33,10 @@ interface RightPanelTabsProps { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddFiles: () => void; browserAvailable: boolean; diffAvailable: boolean; + filesAvailable: boolean; children: ReactNode; } @@ -32,8 +44,10 @@ function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddFiles: () => void; browserAvailable: boolean; diffAvailable: boolean; + filesAvailable: boolean; }) { const actions = [ { @@ -50,6 +64,13 @@ function RightPanelEmptyState(props: { available: true, onClick: props.onAddTerminal, }, + { + label: "Files", + description: "Browse and read workspace files.", + icon: Files, + available: props.filesAvailable, + onClick: props.onAddFiles, + }, { label: "Diff", description: "Review changes in this thread.", @@ -68,7 +89,7 @@ function RightPanelEmptyState(props: { Choose what to show in the right panel.

-
+
{actions.map((action) => { const Icon = action.icon; return ( @@ -101,6 +122,10 @@ function surfaceTitle( switch (surface.kind) { case "diff": return "Diff"; + case "files": + return "Files"; + case "file": + return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1); case "terminal": return ( terminalLabelsById.get(surface.activeTerminalId) ?? @@ -152,6 +177,10 @@ function SurfaceIcon({ } case "diff": return ; + case "files": + return ; + case "file": + return ; case "terminal": return ; case "plan": @@ -163,14 +192,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; return ( - +
{props.surfaces.map((surface) => { @@ -212,29 +245,35 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
); })} + {props.surfaces.length > 0 ? ( + + + + + + + + Browser + + + + Terminal + + + + Files + + + + Diff + + + + ) : null}
- - - - - - - - Browser - - - - Terminal - - - - Diff - - -
{props.activeSurfaceId === null ? ( @@ -242,8 +281,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddBrowser={props.onAddBrowser} onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} + onAddFiles={props.onAddFiles} browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} + filesAvailable={props.filesAvailable} /> ) : ( props.children diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index ae5b4317382..3ea0e6315fd 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -9,7 +9,7 @@ import { import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; -import { VscodeEntryIcon } from "./VscodeEntryIcon"; +import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -177,7 +177,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { {hasDirectoryNodes || depth > 0 ? (