From 25c071b5d88d73289b74e6cb24891f4d635a4ed6 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:06:52 -0400 Subject: [PATCH 1/4] fix(web): keep project picker popup inside the sidebar (#8627) (cherry picked from commit 074bcd6dc897f9c28b1bbc04737daa6ee3d8e40f) --- apps/web/src/components/DiffPanel.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 5 ++++- apps/web/src/components/ui/combobox.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 53de0fdb8..6c4b720e9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -617,7 +617,7 @@ export default function DiffPanel({
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b449955a8..7b614ea73 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3579,7 +3579,10 @@ export default function Sidebar() { - +
From 256311a2a719cb527e942c6085f30941ab1b6320 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:21:36 -0700 Subject: [PATCH 2/4] fix(web): render nested markdown images correctly (#8501) (cherry picked from commit c0e09f323ac9f6bf4b9119cbad841db3379588d6) --- apps/web/src/components/ChatMarkdown.tsx | 135 +++++++++++---- .../ChatMarkdown.workspace-images.test.tsx | 162 +++++++++++++++++- .../components/files/FileMarkdownPreview.tsx | 34 ++++ .../src/components/files/FilePreviewPanel.tsx | 6 +- apps/web/src/markdown-links.test.ts | 11 ++ apps/web/src/markdown-links.ts | 4 + .../client-runtime/src/markdownImages.test.ts | 11 +- packages/client-runtime/src/markdownImages.ts | 6 + 8 files changed, 329 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/files/FileMarkdownPreview.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7dbd497a3..2bb517b79 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -25,12 +25,16 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, + type CSSProperties, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, @@ -91,6 +95,7 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { extractMarkdownLinkHrefs, + isWindowsDrivePathHref, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, @@ -150,6 +155,7 @@ interface ChatMarkdownProps { lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; + imageBaseDir?: string | undefined; } export function canUseMarkdownFileShellActions( @@ -255,27 +261,24 @@ export function orderedListGutterStyle( return { "--list-gutter": `${markerWidth + 1}ch` }; } -type MarkdownHtmlAstNode = { +type MarkdownImageHastNode = { type?: string; tagName?: string; properties?: Record; - children?: MarkdownHtmlAstNode[]; + children?: MarkdownImageHastNode[]; }; -/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ -function rehypeNormalizeWindowsImageSrc() { - return (tree: MarkdownHtmlAstNode) => { - const visit = (node: MarkdownHtmlAstNode) => { +/** Carries authored image source metadata through the sanitizer to the image renderer. */ +function rehypePreserveImageSourceMeta() { + return (tree: MarkdownImageHastNode) => { + const visit = (node: MarkdownImageHastNode) => { const src = node.properties?.src; - if ( - node.type === "element" && - node.tagName === "img" && - typeof src === "string" && - WINDOWS_DRIVE_PATH_REGEX.test(src) - ) { + const title = node.properties?.title; + if (node.type === "element" && node.tagName === "img") { node.properties = { ...node.properties, - src: `file:///${src.replaceAll("\\", "/")}`, + ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), + ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), }; } node.children?.forEach(visit); @@ -292,6 +295,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], + img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], }, protocols: { ...defaultSchema.protocols, @@ -319,7 +323,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, - rehypeNormalizeWindowsImageSrc, + rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -1068,21 +1072,63 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = - "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; +const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( + "h-auto w-auto object-contain", + CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, +); + +function markdownImageCopy(alt: string, src: string, title: string | undefined): string { + const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); + const titleSuffix = + title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + return `![${escapedAlt}](${src}${titleSuffix})`; +} + +function authoredImageSizeStyle( + width: string | number | undefined, + height: string | number | undefined, +): CSSProperties | undefined { + const parsedWidth = Number(width); + const parsedHeight = Number(height); + const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; + const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; + if (hasWidth && hasHeight) { + return { + width: parsedWidth, + height: "auto", + aspectRatio: `${parsedWidth} / ${parsedHeight}`, + maxWidth: `min(100%, 30rem, ${(30 * parsedWidth) / parsedHeight}rem)`, + }; + } + if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; + if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; + return undefined; +} -// block! outranks the unlayered `.chat-markdown img { display: inline-block }` -// rule, keeping workspace images on the same block layout as their placeholder. +const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, - "my-1 block! rounded-lg border border-border/40", + CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, + "rounded-lg border border-border/40", ); -function ChatMarkdownImageFallback(props: { readonly alt: string }) { +function ChatMarkdownImageFallback(props: { + readonly alt: string; + readonly copyMarkdown?: string | undefined; +}) { return ( - - - {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + + + + {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + ); } @@ -1092,6 +1138,9 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop readonly threadRef: ScopedThreadRef; readonly path: string; readonly alt: string; + readonly copyMarkdown: string; + readonly srcFragment: string; + readonly style?: CSSProperties | undefined; }) { const assetUrl = useAssetUrlState(props.threadRef.environmentId, { _tag: "workspace-file", @@ -1101,24 +1150,32 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop const [failedUrl, setFailedUrl] = useState(null); if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ; + return ; } if (assetUrl._tag !== "Success") { return ( ); } return ( {props.alt} setFailedUrl(assetUrl.url)} /> ); @@ -1659,6 +1716,7 @@ function ChatMarkdown({ className, lineBreaks = false, parseRawHtml = true, + imageBaseDir, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1758,6 +1816,7 @@ function ChatMarkdown({ return buildFileLinkParentSuffixByPath(filePaths); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { + if (isWindowsDrivePathHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); // Re-emit highlighted content as markdown so copying out of the rendered @@ -2180,10 +2239,19 @@ function ChatMarkdown({ ); }, - img({ node: _node, title: _title, src, alt, ...props }) { - const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + img({ node, title, src, alt, ...props }) { + const localSrc = node?.properties?.dataLocalSrc; + const markdownTitle = node?.properties?.dataMarkdownTitle; + const authoredSrc = typeof localSrc === "string" ? localSrc : src; + const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; + const srcString = + typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : ""; + const classifiedSrc = + typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; const altText = alt ?? ""; - const imageSource = classifyMarkdownImageSource(srcString, cwd); + const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); + const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); + const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); if (imageSource._tag === "Direct") { return ( {altText} ); } @@ -2201,10 +2270,13 @@ function ChatMarkdown({ threadRef={threadRef} path={imageSource.path} alt={altText} + copyMarkdown={copyMarkdown} + srcFragment={markdownImageSourceFragment(classifiedSrc)} + style={authoredSizeStyle} /> ); } - return ; + return ; }, table({ node: _node, ...props }) { return ; @@ -2249,6 +2321,7 @@ function ChatMarkdown({ diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, + imageBaseDir, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 37a0f27a0..39ea214d4 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -4,16 +4,16 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ resources: [] as Array, - assetState: "success" as "success" | "loading", + assetState: "success" as "success" | "loading" | "failure", })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../assets/assetUrls", () => ({ useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.resources.push(resource); - return testState.assetState === "loading" - ? { _tag: "Loading" } - : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; + if (testState.assetState === "loading") return { _tag: "Loading" }; + if (testState.assetState === "failure") return { _tag: "Failure" }; + return { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; }, })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); @@ -42,6 +42,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ })); import ChatMarkdown from "./ChatMarkdown"; +import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; const threadRef = { environmentId: EnvironmentId.make("env-windows"), @@ -58,12 +59,60 @@ function renderWithoutThread(markdown: string): string { return renderToStaticMarkup(); } +function renderFilePreview(cwd: string, relativePath: string): string { + return renderToStaticMarkup( + , + ); +} + +function copiedMarkdownFrom(html: string): string { + const copy = /data-markdown-copy="([^"]*)"/.exec(html)?.[1]?.replaceAll(""", '"'); + expect(copy).toBeDefined(); + return copy ?? ""; +} + +function firstInlineStyle(html: string): Record { + const style = /style="([^"]+)"/.exec(html)?.[1]; + expect(style).toBeDefined(); + return Object.fromEntries( + (style ?? "").split(";").map((declaration) => { + const separator = declaration.indexOf(":"); + return [declaration.slice(0, separator), declaration.slice(separator + 1)]; + }), + ); +} + describe("ChatMarkdown workspace images", () => { beforeEach(() => { testState.resources = []; testState.assetState = "success"; }); + it.each([ + ["/workspace/project", "docs/README.md", "/workspace/project/docs/images/diagram.png"], + [ + "C:\\Users\\shawn\\project", + "docs\\README.md", + "C:\\Users\\shawn\\project\\docs\\images\\diagram.png", + ], + ["/workspace/project", "README.md", "/workspace/project/images/diagram.png"], + ])("resolves images beside a nested file in %s", (cwd, relativePath, expectedPath) => { + renderFilePreview(cwd, relativePath); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: expectedPath, + }, + ]); + }); + it("loads every Windows workspace path form through a signed asset URL", () => { const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; const html = render( @@ -108,13 +157,116 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("https://signed.test/workspace-image.svg"); }); - it("uses a static placeholder while a signed asset URL loads", () => { + it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { + const markdown = 'sized'; + const loadedStyle = firstInlineStyle(render(markdown)); + testState.assetState = "loading"; + const loadingStyle = firstInlineStyle(render(markdown)); + + expect(loadedStyle).toMatchObject({ + width: "96px", + height: "auto", + "aspect-ratio": "96 / 128", + "max-width": "min(100%, 30rem, 22.5rem)", + }); + expect(loadingStyle).toEqual(loadedStyle); + }); + + it.each([ + ["width", "max-width", "min(100%, 30rem, 300px)"], + ["height", "max-height", "min(30rem, 300px)"], + ])("treats a lone authored %s as a cap", (axis, constraint, expectedValue) => { + const markdown = `sized`; + const loadedStyle = firstInlineStyle(render(markdown)); + + expect(loadedStyle).not.toHaveProperty(axis); + expect(loadedStyle).toHaveProperty(constraint, expectedValue); + }); + + it("keeps all images baseline-aligned and workspace images inline", () => { + const html = render( + "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)", + ); + const classNames = Array.from(html.matchAll(/]*class="([^"]*)"/g), (match) => + match[1]?.split(" "), + ); + + expect(classNames).toHaveLength(2); + expect(classNames[1]).toContain("inline-block!"); + + const centeredHtml = render( + '

logo

', + ); + const centeredClassName = /]*class="([^"]*)"/.exec(centeredHtml)?.[1]; + + expect(centeredClassName?.split(" ")).toContain("inline-block!"); + }); + + it("retains an authored SVG fragment on the signed URL", () => { + const html = render("![logo](icons.svg#logo)"); + + expect(html).toContain('src="https://signed.test/workspace-image.svg#logo"'); + }); + + it.each(["success", "loading", "failure", "no-thread"] as const)( + "copies the authored workspace source (%s)", + (scenario) => { + if (scenario === "no-thread") { + const html = renderWithoutThread("![diagram](images/diagram.png)"); + expect(copiedMarkdownFrom(html)).toBe("![diagram](images/diagram.png)"); + return; + } + + testState.assetState = scenario; + const html = render("![diagram](images/diagram.png#preview)"); + + expect(copiedMarkdownFrom(html)).toBe("![diagram](images/diagram.png#preview)"); + }, + ); + + it("copies an authored title with a workspace image", () => { + const html = render('![logo](images/logo.svg "My Title")'); + + expect(copiedMarkdownFrom(html)).toBe('![logo](images/logo.svg "My Title")'); + }); + + it("escapes double quotes in an authored image title", () => { + const html = render(`![logo](images/logo.svg 'My "Title"')`); + + expect(copiedMarkdownFrom(html)).toBe('![logo](images/logo.svg "My \\"Title\\"")'); + }); + + it("escapes a closing bracket in authored image alt text", () => { + const markdown = String.raw`![build\] badge](badge.svg)`; + + expect(copiedMarkdownFrom(render(markdown))).toBe(markdown); + }); + + it("escapes a literal backslash in authored image alt text", () => { + const markdown = String.raw`![folder\\name](badge.svg)`; + + expect(copiedMarkdownFrom(render(markdown))).toBe(markdown); + }); + + it("escapes a literal backslash before a quote in an authored image title", () => { + const html = render( + String.raw`logo`, + ); + + expect(copiedMarkdownFrom(html)).toBe( + String.raw`![logo](images/logo.svg "Path \\\"Title\\\"")`, + ); + }); + + it("uses a static bounded-width placeholder while a signed asset URL loads", () => { testState.assetState = "loading"; const html = render("![loading](.t3/workspace-image.svg)"); + const className = /]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; expect(html).toContain('aria-label="Loading image"'); expect(html).not.toContain("animate-pulse"); + expect(className?.split(" ")).toContain("w-64"); }); it("never passes a workspace source to a raw image when thread context is unavailable", () => { diff --git a/apps/web/src/components/files/FileMarkdownPreview.tsx b/apps/web/src/components/files/FileMarkdownPreview.tsx new file mode 100644 index 000000000..e36ada48a --- /dev/null +++ b/apps/web/src/components/files/FileMarkdownPreview.tsx @@ -0,0 +1,34 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import ChatMarkdown from "~/components/ChatMarkdown"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +export function FileMarkdownPreview(props: { + readonly cwd: string; + readonly relativePath: string; + readonly text: string; + readonly threadRef: ScopedThreadRef; + readonly onTaskListChange?: + | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void) + | undefined; +}) { + const lastSeparator = Math.max( + props.relativePath.lastIndexOf("/"), + props.relativePath.lastIndexOf("\\"), + ); + const imageBaseDir = + lastSeparator >= 0 + ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd) + : props.cwd; + + return ( + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763..bc2ff98f0 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -18,7 +18,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { useAssetUrlState } from "~/assets/assetUrls"; -import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; @@ -42,6 +41,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import FileBrowserPanel from "./FileBrowserPanel"; +import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -727,11 +727,11 @@ function RenderedMarkdownSurface({ return ( - { const currentContents = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 118cede95..fb4658f3d 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown"; import { extractMarkdownLinkHrefs, + isWindowsDrivePathHref, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, @@ -13,6 +14,16 @@ import { shouldOpenMarkdownFileLinkInEditor, } from "./markdown-links"; +describe("isWindowsDrivePathHref", () => { + it.each([ + ["C:\\repo\\image.png", true], + ["C:%5Crepo%5Cimage.png", true], + ["https://example.com/image.png", false], + ])("classifies %s as %s", (href, expected) => { + expect(isWindowsDrivePathHref(href)).toBe(expected); + }); +}); + function renderMarkdownLinkHref(markdown: string): string | undefined { let renderedHref: string | undefined; renderToStaticMarkup( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 4757ff2d9..39ec9788a 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -84,6 +84,10 @@ function safeDecode(value: string): string { } } +export function isWindowsDrivePathHref(href: string): boolean { + return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); +} + function unwrapMarkdownLinkDestination(value: string): string { return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; } diff --git a/packages/client-runtime/src/markdownImages.test.ts b/packages/client-runtime/src/markdownImages.test.ts index a4160c3da..8ee186756 100644 --- a/packages/client-runtime/src/markdownImages.test.ts +++ b/packages/client-runtime/src/markdownImages.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { classifyMarkdownImageSource } from "./markdownImages.js"; +import { classifyMarkdownImageSource, markdownImageSourceFragment } from "./markdownImages.js"; describe("classifyMarkdownImageSource", () => { it.each([ @@ -60,3 +60,12 @@ describe("classifyMarkdownImageSource", () => { expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" }); }); }); + +describe("markdownImageSourceFragment", () => { + it.each([ + ["", "#logo"], + ["icons.svg?version=2", ""], + ])("extracts %s as %s", (source, fragment) => { + expect(markdownImageSourceFragment(source)).toBe(fragment); + }); +}); diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 404f82839..671a94d49 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -20,6 +20,12 @@ function normalizeSource(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } +export function markdownImageSourceFragment(source: string): string { + const normalizedSource = normalizeSource(source); + const hashIndex = normalizedSource.indexOf("#"); + return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : ""; +} + function normalizeWindowsDrivePath(value: string): string { return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; } From ff29ee9567cd581a87dfa062a1d842588ea51364 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:45:10 +0200 Subject: [PATCH 3/4] feat(web): keybinding settings as settings rows (#8532) (cherry picked from commit be218ac768d809087bfcea4d8f15c431f0d70e16) --- .../settings/KeybindingsSettings.tsx | 854 +++++++++++------- 1 file changed, 545 insertions(+), 309 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index cc8fbc485..33ae53770 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -3,7 +3,6 @@ import { CircleXIcon, EllipsisIcon, FileJsonIcon, - InfoIcon, MinusIcon, PlusIcon, SearchIcon, @@ -44,12 +43,12 @@ import { serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Toggle } from "../ui/toggle"; import { toastManager } from "../ui/toast"; @@ -69,17 +68,23 @@ import { unknownWhenVariables, whenAstToExpression, } from "./KeybindingsSettings.logic"; -import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { - const parts = value.split("+"); + // Keys dedupe repeated parts; a literal "+" in a shortcut splits into empty strings. + const seenParts = new Map(); + const parts = value.split("+").map((part) => { + const seen = seenParts.get(part) ?? 0; + seenParts.set(part, seen + 1); + return { part, key: seen === 0 ? part : `${part}-${seen}` }; + }); return ( - {parts.map((part) => ( - + {parts.map(({ part, key }) => ( + {part === "mod" ? navigator.platform.toLowerCase().includes("mac") ? "⌘" @@ -231,19 +236,18 @@ function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode }; } -function UnknownWhenVariableWarning({ - identifiers, +/** Warning glyph whose explanation lives in a tooltip; the one owner of that affordance here. */ +function WarningTooltipIcon({ + label, focusable = true, + className, + children, }: { - identifiers: ReadonlyArray; + label: string; focusable?: boolean; + className?: string | undefined; + children: ReactNode; }) { - if (identifiers.length === 0) return null; - const label = - identifiers.length === 1 - ? `Unknown condition: ${identifiers[0]}` - : `Unknown conditions: ${identifiers.join(", ")}`; - return ( - -
+ className={cn( + "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25", + className, + )} + /> } - /> + > + + - Pylon does not recognize this condition yet. It can still be saved, but it may not match - unless the runtime provides it. + {children} ); } +function UnknownWhenVariableWarning({ + identifiers, + focusable = true, +}: { + identifiers: ReadonlyArray; + focusable?: boolean; +}) { + if (identifiers.length === 0) return null; + const label = + identifiers.length === 1 + ? `Unknown condition: ${identifiers[0]}` + : `Unknown conditions: ${identifiers.join(", ")}`; + + return ( + + Pylon does not recognize this condition yet. It can still be saved, but it may not match + unless the runtime provides it. + + ); +} + function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray }) { if (labels.length === 0) return null; const description = @@ -273,22 +300,9 @@ function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray } : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`; return ( - - - -
- } - /> - - {description} The most recent matching binding wins when both conditions can apply. - - + + {description} The most recent matching binding wins when both conditions can apply. + ); } @@ -733,32 +747,20 @@ function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput { }; } -function KeybindingTableRow({ +/** Draft state and actions for editing one existing binding; layouts decide how to render it. */ +function useKeybindingRowEditor({ row, allRows, - variables, - isSaving, onSave, - onReset, - onRemove, }: { row: KeybindingRow; allRows: ReadonlyArray; - variables: ReadonlyArray; - isSaving: boolean; onSave: (input: ServerUpsertKeybindingInput) => void; - onReset: (row: KeybindingRow) => void; - onRemove: (row: KeybindingRow) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; - const displayShortcut = formatShortcutLabel(row.binding.shortcut); - const canReset = row.source === "Custom" && row.defaultKey !== null; - const canRemove = row.source !== "Default"; - const hasRowActions = canReset || canRemove; - const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; const conflictLabels = keybindingConflictLabels(allRows, { rowId: row.id, key: keyDraft, @@ -786,139 +788,277 @@ function KeybindingTableRow({ setDraft({ keyDraft: next, isRecording: false }); }; + return { + keyDraft, + whenDraft, + isRecording, + isWhenDraftValid, + whenDraftExpression, + isDirty, + conflictLabels, + setDraft, + save, + captureKeybinding, + }; +} + +type KeybindingRowEditor = ReturnType; + +interface KeybindingRowActions { + allRows: ReadonlyArray; + variables: ReadonlyArray; + onSave: (input: ServerUpsertKeybindingInput) => void; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +} + +type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean }; + +/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */ +function KeybindingKeyControl({ + row, + editor, + isSaving, + pillClassName, +}: { + row: KeybindingRow; + editor: KeybindingRowEditor; + isSaving: boolean; + pillClassName?: string | undefined; +}) { + const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } = + editor; + const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; + return ( -
-
-
- - - } - > - {commandLabel(row.command)} - - {row.command} - -
-
-
- {showPill ? ( - - ) : ( - setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} + <> + {isDirty ? ( + + ) : null} + {showPill ? ( + + ) : ( + setDraft({ isRecording: true })} + onBlur={() => setDraft({ isRecording: false })} + onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={captureKeybinding} + /> + )} + + ); +} + +/** Quiet inline trigger showing the when clause; opens the expression builder. */ +function WhenClauseControl({ + label, + expression, + value, + variables, + onChange, + onValidityChange, +}: { + label: string; + expression: string; + value: KeybindingWhenNode | undefined; + variables: ReadonlyArray; + onChange: (value: KeybindingWhenNode | undefined) => void; + onValidityChange: (valid: boolean) => void; +}) { + return ( + + - )} - {isDirty ? ( + } + aria-label={`Edit when clause for ${label}`} + > + {expression || "Always"} + + + + + + + ); +} + +function KeybindingRowMenu({ + row, + isSaving, + onReset, + onRemove, +}: { + row: KeybindingRow; + isSaving: boolean; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +}) { + const canReset = row.source === "Custom" && row.defaultKey !== null; + const canRemove = row.source !== "Default"; + if (!canReset && !canRemove) return null; + + return ( + + - {isSaving ? "Saving" : "Save"} - + type="button" + variant="ghost" + size="icon-sm" + className="size-7 text-muted-foreground hover:text-foreground sm:size-7" + disabled={isSaving} + aria-label={`Actions for ${commandLabel(row.command)}`} + /> + } + > + + + + {canReset ? ( + onReset(row)}> + Reset to default + ) : null} -
-
- - - {whenDraftExpression || "Always"} - - - - setDraft({ whenDraft: nextWhenDraft })} - onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} - /> - - -
-
- - {hasRowActions ? ( - - - } - > - - - - {canReset ? ( - onReset(row)}> - Reset to default - - ) : null} - {canRemove ? ( - onRemove(row)}> - Remove - - ) : null} - - + {canRemove ? ( + onRemove(row)}> + Remove + ) : null} - {displayShortcut} -
-
+ + ); } -function NewKeybindingTableRow({ - commandOptions, - allRows, +function KeybindingSourceBadge({ source }: { source: KeybindingRow["source"] }) { + if (source === "Default") return null; + return ( + + {source} + + ); +} + +function KeybindingRowTitle({ row }: { row: KeybindingRow }) { + return ( + + }> + {commandLabel(row.command)} + + + {row.command} + + ); +} + +function KeybindingRowWhen({ + row, + editor, variables, - isSaving, - onSave, - onCancel, }: { - commandOptions: ReadonlyArray; - allRows: ReadonlyArray; + row: KeybindingRow; + editor: KeybindingRowEditor; variables: ReadonlyArray; +}) { + return ( + + When + editor.setDraft({ whenDraft })} + onValidityChange={(isWhenDraftValid) => editor.setDraft({ isWhenDraftValid })} + /> + + ); +} + +/** Row actions that stay hidden until the row is hovered or holds focus. */ +function KeybindingHoverRowMenu(props: { + row: KeybindingRow; isSaving: boolean; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +}) { + return ( + + + + ); +} + +/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ +function KeybindingSettingsRow(props: KeybindingRowProps) { + const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props; + const editor = useKeybindingRowEditor({ row, allRows, onSave }); + + return ( + } + description={} + control={ +
+ + + +
+ } + /> + ); +} + +/** Draft state for a binding that does not exist yet. */ +function useNewKeybindingDraft({ + allRows, + onSave, +}: { + allRows: ReadonlyArray; onSave: (input: ServerUpsertKeybindingInput) => void; - onCancel: () => void; }) { const [commandDraft, setCommandDraft] = useState(""); const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { @@ -935,6 +1075,7 @@ function NewKeybindingTableRow({ when: whenDraftExpression, }); const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding"; + const canSave = Boolean(commandDraft) && keyDraft.trim().length > 0 && isWhenDraftValid; const save = () => { if (!commandDraft) return; @@ -957,93 +1098,222 @@ function NewKeybindingTableRow({ setDraft({ keyDraft: next, isRecording: false }); }; + return { + commandDraft, + setCommandDraft, + keyDraft, + whenDraft, + whenDraftExpression, + isRecording, + conflictLabels, + commandLabelText, + canSave, + setDraft, + save, + captureKeybinding, + }; +} + +type NewKeybindingDraft = ReturnType; + +interface NewKeybindingProps { + commandOptions: ReadonlyArray; + allRows: ReadonlyArray; + variables: ReadonlyArray; + isSaving: boolean; + onSave: (input: ServerUpsertKeybindingInput) => void; + onCancel: () => void; +} + +function NewKeybindingCommandSelect({ + draft, + commandOptions, + className, +}: { + draft: NewKeybindingDraft; + commandOptions: ReadonlyArray; + className?: string | undefined; +}) { return ( -
-
- -
-
- setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} + + ); +} + +function NewKeybindingKeyInput({ + draft, + autoFocus = false, + className, +}: { + draft: NewKeybindingDraft; + autoFocus?: boolean; + className?: string | undefined; +}) { + return ( + draft.setDraft({ isRecording: true })} + onBlur={() => draft.setDraft({ isRecording: false })} + onChange={(event) => draft.setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={draft.captureKeybinding} + /> + ); +} + +function NewKeybindingWhen({ + draft, + variables, +}: { + draft: NewKeybindingDraft; + variables: ReadonlyArray; +}) { + return ( + draft.setDraft({ whenDraft })} + onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })} + /> + ); +} + +function NewKeybindingCancelIcon({ + isSaving, + onCancel, +}: { + isSaving: boolean; + onCancel: () => void; +}) { + return ( + + + } + > + + + Cancel + + ); +} + +/** Add-binding form shaped like the binding rows below it. */ +function NewKeybindingSettingsRow(props: NewKeybindingProps) { + const { commandOptions, allRows, variables, isSaving, onSave, onCancel } = props; + const draft = useNewKeybindingDraft({ allRows, onSave }); + + return ( + + When + + + } + control={ +
+ + + + + +
+ } + /> + ); +} + +interface KeybindingsListProps extends KeybindingRowActions { + rows: ReadonlyArray; + commandOptions: ReadonlyArray; + savingCommand: KeybindingCommand | null; + isAddingBinding: boolean; + onCancelAdd: () => void; +} + +/** The add-binding row, one settings row per binding, and the empty state. */ +function KeybindingsList(props: KeybindingsListProps) { + const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = + props; + const newProps: NewKeybindingProps = { + commandOptions, + allRows: rows, + variables: rowActions.variables, + isSaving: savingCommand !== null, + onSave: rowActions.onSave, + onCancel: onCancelAdd, + }; + return ( +
+ {isAddingBinding ? : null} + {rows.map((row) => ( + - -
-
- - - {whenDraftExpression || "Always"} - - - - setDraft({ whenDraft: nextWhenDraft })} - onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} - /> - - -
-
- - - - } - > - - - Cancel - -
+ ))} + {rows.length === 0 && !isAddingBinding ? ( +
+ No keybindings match your search. +
+ ) : null} +
+ ); +} + +/** Shown in the browser build only; the desktop app receives every shortcut. */ +function BrowserKeybindingNotice() { + return ( +
+ + + Some shortcuts may be claimed by the browser before Pylon sees them. Use the desktop app for + better keybinding support. +
); } @@ -1187,6 +1457,8 @@ export function KeybindingsSettingsPanel() { [saveKeybinding], ); + const cancelAdd = useCallback(() => setIsAddingBinding(false), []); + const bindingsCount = ( {rows.length + (isAddingBinding ? 1 : 0)}{" "} @@ -1194,8 +1466,21 @@ export function KeybindingsSettingsPanel() { ); + const listProps: KeybindingsListProps = { + rows, + allRows: rows, + commandOptions, + variables: whenVariables, + savingCommand, + isAddingBinding, + onCancelAdd: cancelAdd, + onSave: saveKeybinding, + onReset: resetKeybinding, + onRemove: removeKeybinding, + }; + return ( - + } > - {!isElectron ? ( -
- -

- Some shortcuts may be claimed by the browser before Pylon sees them. Use the desktop - app for better keybinding support. -

-
- ) : null} + {!isElectron ? : null} - -
-
Command
-
Keybinding
-
When
-
Status
-
-
- {isAddingBinding ? ( - setIsAddingBinding(false)} - /> - ) : null} - {rows.map((row) => ( - - ))} - {rows.length === 0 && !isAddingBinding ? ( -
- No keybindings match your search. -
- ) : null} -
-
+
); From f19e3d996281cb2f0ff76bb3827e671ce38d20a0 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:25:20 +0300 Subject: [PATCH 4/4] fix(web): four composer spacing defects (#8090) Adapted for Pylon: the segment cap lands on Pylon's fork-only TaskProgressSegments in chat/TaskProgressStatus.tsx (as MAX_TASK_PROGRESS_SEGMENTS) rather than upstream's TaskSegments helper in ComposerTasksBadge.tsx, which Pylon does not have. Pylon's dismissal helpers in ComposerTasksBadge.tsx are kept, and the ChatView import block drops upstream's shouldShowPlanFollowUpPrompt, which Pylon retired. (cherry picked from commit 660cddd3bc9801e089afcabba11c62f41aeac5c3) --- .../web/src/components/ChatView.logic.test.ts | 19 +++++++ apps/web/src/components/ChatView.logic.ts | 11 ++++ apps/web/src/components/ChatView.tsx | 56 +++++++++++-------- .../components/chat/ComposerStashBadge.tsx | 2 +- .../chat/ComposerTasksBadge.test.tsx | 39 +++++++++++-- .../components/chat/ComposerTasksBadge.tsx | 4 +- .../chat/TaskProgressStatus.test.tsx | 19 +++++++ .../components/chat/TaskProgressStatus.tsx | 8 ++- 8 files changed, 128 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 030b53732..ab1000f65 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -33,6 +33,7 @@ import { resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, + shoulderTabReserve, startNewThreadForProject, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, @@ -46,6 +47,24 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("shoulderTabReserve", () => { + it("ignores the top drawer when measuring the shoulder tab band", () => { + const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement; + const elements = new Map([ + ['[data-chat-composer-form="true"]', elementAt(20)], + [".chat-composer-shoulder-tab", elementAt(100)], + ['[data-chat-composer-main-surface="true"]', elementAt(128)], + ]); + const overlay = { + querySelector: (selector: string) => elements.get(selector) ?? null, + } as HTMLElement; + + expect(shoulderTabReserve(overlay)).toBe(28); + elements.set(".chat-composer-tasks-tab", elementAt(100)); + expect(shoulderTabReserve(overlay)).toBe(0); + }); +}); + describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 768cd5072..9c7e8863c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -40,6 +40,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function shoulderTabReserve(overlay: HTMLElement): number { + if (overlay.querySelector(".chat-composer-tasks-tab")) return 0; + const tab = overlay.querySelector(".chat-composer-shoulder-tab"); + const surface = overlay.querySelector('[data-chat-composer-main-surface="true"]'); + if (!tab || !surface) return 0; + return Math.max( + 0, + Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top), + ); +} + export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6bd396dd0..365261076 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -390,6 +390,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, + shoulderTabReserve, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -1615,6 +1616,7 @@ function ChatViewContent(props: ChatViewProps) { const legendListRef = useRef(null); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); @@ -1622,25 +1624,6 @@ function ChatViewContent(props: ChatViewProps) { const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); - useLayoutEffect(() => { - if (!composerOverlayElement) return; - - const updateHeight = () => { - const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); - if (nextHeight <= 0) return; - setComposerOverlayHeight((currentHeight) => - currentHeight === nextHeight ? currentHeight : nextHeight, - ); - }; - - updateHeight(); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver(updateHeight); - observer.observe(composerOverlayElement); - return () => observer.disconnect(); - }, [composerOverlayElement]); - const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); @@ -4740,6 +4723,35 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeComposerPlan?.turnId, activeComposerTaskSteps, agentSessionLive, threadActivities], ); + + useLayoutEffect(() => { + if (!composerOverlayElement) return; + + const updateHeight = () => { + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + if (nextHeight <= 0) return; + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement)); + setScrollToEndClearance((currentClearance) => + currentClearance === nextClearance ? currentClearance : nextClearance, + ); + }; + + updateHeight(); + if (typeof ResizeObserver === "undefined") return; + + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(composerOverlayElement); + const tabObserver = new MutationObserver(updateHeight); + tabObserver.observe(composerOverlayElement, { childList: true, subtree: true }); + return () => { + resizeObserver.disconnect(); + tabObserver.disconnect(); + }; + }, [composerOverlayElement]); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const linkedPullRequestStatus = useLinkedThreadPullRequest( @@ -8225,7 +8237,7 @@ function ChatViewContent(props: ChatViewProps) { {showScrollToBottom && (