| author | |
| committer | |
| log | 7f741a56d176617d7f97865de24dbdf11ea3fd7e |
| tree | 4d5feec23ef47b0fd02faa1d9b6eea5ead379d79 |
| parent | cc9e81ac93b66b1d7ec308424fcd67bfc6d671c0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: ss <ss@barekey.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>36 files changed, 2925 insertions(+), 603 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+2-2| ... | ... | @@ -10,7 +10,7 @@ import type { |
| 10 | 10 | import { |
| 11 | 11 | detectComposerTrigger, |
| 12 | 12 | replaceTextRange, |
| 13 | serializeComposerMentionPath, | |
| 13 | serializeComposerFileLink, | |
| 14 | 14 | type ComposerTrigger, |
| 15 | 15 | } from "@t3tools/shared/composerTrigger"; |
| 16 | 16 | import { TextInputWrapper } from "expo-paste-input"; |
| ... | ... | @@ -419,7 +419,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 419 | 419 | |
| 420 | 420 | let replacement = ""; |
| 421 | 421 | if (item.type === "path") { |
| 422 | replacement = `@${serializeComposerMentionPath(item.path)} `; | |
| 422 | replacement = `${serializeComposerFileLink(item.path)} `; | |
| 423 | 423 | } else if (item.type === "skill") { |
| 424 | 424 | replacement = `$${item.skill.name} `; |
| 425 | 425 | } else if (item.type === "slash-command") { |
apps/web/package.json+3| ... | ... | @@ -43,6 +43,9 @@ |
| 43 | 43 | "react": "19.2.6", |
| 44 | 44 | "react-dom": "19.2.6", |
| 45 | 45 | "react-markdown": "^10.1.0", |
| 46 | "rehype-raw": "^7.0.0", | |
| 47 | "rehype-sanitize": "^6.0.0", | |
| 48 | "remark-breaks": "^4.0.0", | |
| 46 | 49 | "remark-gfm": "^4.0.1", |
| 47 | 50 | "tailwind-merge": "^3.4.0", |
| 48 | 51 | "zustand": "^5.0.11" |
apps/web/src/components/ChatMarkdown.browser.tsx+543| ... | ... | @@ -24,6 +24,7 @@ vi.mock("../localApi", () => ({ |
| 24 | 24 | })); |
| 25 | 25 | |
| 26 | 26 | import ChatMarkdown from "./ChatMarkdown"; |
| 27 | import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; | |
| 27 | 28 | |
| 28 | 29 | describe("ChatMarkdown", () => { |
| 29 | 30 | afterEach(() => { |
| ... | ... | @@ -134,8 +135,550 @@ describe("ChatMarkdown", () => { |
| 134 | 135 | await expect.element(link).toBeInTheDocument(); |
| 135 | 136 | await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); |
| 136 | 137 | await expect.element(link).toHaveAttribute("target", "_blank"); |
| 138 | const favicon = link.element().querySelector<HTMLElement>(".chat-markdown-link-favicon"); | |
| 139 | const leading = link.element().querySelector<HTMLElement>(".chat-markdown-link-leading"); | |
| 140 | expect(favicon).not.toBeNull(); | |
| 141 | expect(leading).not.toBeNull(); | |
| 142 | expect(leading?.contains(favicon)).toBe(true); | |
| 143 | expect(getComputedStyle(leading!).display).toBe("inline"); | |
| 144 | expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); | |
| 145 | expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); | |
| 146 | expect(leading?.textContent).toBe("O"); | |
| 147 | expect(link.element().textContent).toBe("OpenAI"); | |
| 148 | expect(getComputedStyle(link.element()).textDecorationLine).toBe("none"); | |
| 149 | expect(link.element().querySelector("img, svg")?.getBoundingClientRect().width).toBe(14); | |
| 150 | await link.hover(); | |
| 151 | expect(getComputedStyle(link.element()).backgroundImage).not.toBe("none"); | |
| 152 | await expect.element(page.getByText("https://openai.com/docs")).toBeVisible(); | |
| 137 | 153 | } finally { |
| 138 | 154 | await screen.unmount(); |
| 139 | 155 | } |
| 140 | 156 | }); |
| 157 | ||
| 158 | it("keeps a favicon with the leading segment of a wrapping URL", async () => { | |
| 159 | const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; | |
| 160 | const screen = await render( | |
| 161 | <div style={{ width: 180 }}> | |
| 162 | <ChatMarkdown text={`[${url}](${url})`} cwd="/repo/project" /> | |
| 163 | </div>, | |
| 164 | ); | |
| 165 | ||
| 166 | try { | |
| 167 | const link = page.getByRole("link", { name: url }); | |
| 168 | const leading = link.element().querySelector<HTMLElement>(".chat-markdown-link-leading"); | |
| 169 | const favicon = link.element().querySelector<HTMLElement>(".chat-markdown-link-favicon"); | |
| 170 | expect(leading).not.toBeNull(); | |
| 171 | expect(favicon).not.toBeNull(); | |
| 172 | expect(leading?.contains(favicon)).toBe(true); | |
| 173 | expect(leading?.textContent).toBe("https://"); | |
| 174 | expect(getComputedStyle(leading!).display).toBe("inline"); | |
| 175 | expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); | |
| 176 | expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); | |
| 177 | expect(link.element().textContent).toBe(url); | |
| 178 | expect(link.element().querySelectorAll("wbr").length).toBeGreaterThan(0); | |
| 179 | const markdownRoot = link.element().closest<HTMLElement>(".chat-markdown"); | |
| 180 | expect(markdownRoot).not.toBeNull(); | |
| 181 | expect(markdownRoot!.scrollWidth).toBeLessThanOrEqual(markdownRoot!.clientWidth); | |
| 182 | } finally { | |
| 183 | await screen.unmount(); | |
| 184 | } | |
| 185 | }); | |
| 186 | ||
| 187 | it("renders file links with the shared file tag chip treatment", async () => { | |
| 188 | const screen = await render( | |
| 189 | <ChatMarkdown text="[package.json](path/to/package.json)" cwd="/repo/project" />, | |
| 190 | ); | |
| 191 | ||
| 192 | try { | |
| 193 | const link = page.getByRole("link", { name: "package.json" }); | |
| 194 | await expect.element(link).toHaveClass(/chat-markdown-file-link/); | |
| 195 | const element = document.querySelector<HTMLElement>(".chat-markdown-file-link"); | |
| 196 | expect(element?.querySelector("img, svg")).not.toBeNull(); | |
| 197 | expect(getComputedStyle(element!).display).toBe("inline-flex"); | |
| 198 | expect(getComputedStyle(element!).textDecorationLine).toBe("none"); | |
| 199 | expect(getComputedStyle(element!).borderStyle).toBe("solid"); | |
| 200 | expect(getComputedStyle(element!).userSelect).not.toBe("none"); | |
| 201 | } finally { | |
| 202 | await screen.unmount(); | |
| 203 | } | |
| 204 | }); | |
| 205 | ||
| 206 | it("renders sanitized details with the design-system collapsible", async () => { | |
| 207 | const source = [ | |
| 208 | "<details open>", | |
| 209 | "<summary>Expandable details section</summary>", | |
| 210 | "", | |
| 211 | "This content includes **formatted text**.", | |
| 212 | "", | |
| 213 | '<span title="native tooltip should be stripped">Safe inline HTML</span>', | |
| 214 | "<script>window.__unsafeMarkdownScript = true</script>", | |
| 215 | "</details>", | |
| 216 | ].join("\n"); | |
| 217 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 218 | ||
| 219 | try { | |
| 220 | const details = document.querySelector<HTMLElement>("[data-markdown-details]"); | |
| 221 | const trigger = page.getByRole("button", { name: "Expandable details section" }); | |
| 222 | expect(details).not.toBeNull(); | |
| 223 | expect(details?.tagName).toBe("DIV"); | |
| 224 | await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); | |
| 225 | expect(details?.querySelector("strong")?.textContent).toBe("formatted text"); | |
| 226 | expect(details?.querySelector("script")).toBeNull(); | |
| 227 | expect(details?.querySelector("[title]")).toBeNull(); | |
| 228 | ||
| 229 | await trigger.click(); | |
| 230 | await expect.element(trigger).toHaveAttribute("aria-expanded", "false"); | |
| 231 | await trigger.click(); | |
| 232 | await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); | |
| 233 | } finally { | |
| 234 | await screen.unmount(); | |
| 235 | } | |
| 236 | }); | |
| 237 | ||
| 238 | it("renders footnotes as same-document references", async () => { | |
| 239 | const source = [ | |
| 240 | "A claim with supporting context.[^context]", | |
| 241 | "", | |
| 242 | "[^context]: Supporting **footnote text**.", | |
| 243 | ].join("\n"); | |
| 244 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 245 | ||
| 246 | try { | |
| 247 | const reference = document.querySelector<HTMLAnchorElement>( | |
| 248 | '.chat-markdown a[data-footnote-ref=""]', | |
| 249 | ); | |
| 250 | const footnotes = document.querySelector<HTMLElement>( | |
| 251 | ".chat-markdown section[data-footnotes]", | |
| 252 | ); | |
| 253 | expect(reference).not.toBeNull(); | |
| 254 | expect(reference?.getAttribute("href")).toMatch(/^#user-content-fn-/); | |
| 255 | expect(reference?.hasAttribute("target")).toBe(false); | |
| 256 | expect(footnotes).not.toBeNull(); | |
| 257 | expect(footnotes?.querySelector("strong")?.textContent).toBe("footnote text"); | |
| 258 | expect(footnotes?.querySelector<HTMLAnchorElement>("a[data-footnote-backref]")?.target).toBe( | |
| 259 | "", | |
| 260 | ); | |
| 261 | } finally { | |
| 262 | await screen.unmount(); | |
| 263 | } | |
| 264 | }); | |
| 265 | ||
| 266 | it("navigates hash links within the clicked markdown message", async () => { | |
| 267 | const source = [ | |
| 268 | "A claim with supporting context.[^context]", | |
| 269 | "", | |
| 270 | "[^context]: Supporting footnote text.", | |
| 271 | ].join("\n"); | |
| 272 | const originalUrl = window.location.href; | |
| 273 | const scrollIntoView = vi | |
| 274 | .spyOn(HTMLElement.prototype, "scrollIntoView") | |
| 275 | .mockImplementation(() => undefined); | |
| 276 | const screen = await render( | |
| 277 | <div> | |
| 278 | <ChatMarkdown text={source} cwd="/repo/project" /> | |
| 279 | <ChatMarkdown text={source} cwd="/repo/project" /> | |
| 280 | </div>, | |
| 281 | ); | |
| 282 | ||
| 283 | try { | |
| 284 | const markdownRoots = document.querySelectorAll<HTMLElement>(".chat-markdown"); | |
| 285 | const secondRoot = markdownRoots[1]; | |
| 286 | const secondReference = | |
| 287 | secondRoot?.querySelector<HTMLAnchorElement>('a[data-footnote-ref=""]'); | |
| 288 | const secondFootnote = secondRoot?.querySelector<HTMLElement>( | |
| 289 | "section[data-footnotes] li[id]", | |
| 290 | ); | |
| 291 | expect(secondReference).not.toBeNull(); | |
| 292 | expect(secondFootnote).not.toBeNull(); | |
| 293 | ||
| 294 | secondReference?.click(); | |
| 295 | ||
| 296 | expect(scrollIntoView).toHaveBeenCalledTimes(1); | |
| 297 | expect(scrollIntoView.mock.instances[0]).toBe(secondFootnote); | |
| 298 | expect(window.location.hash).toBe(secondReference?.hash); | |
| 299 | ||
| 300 | const secondBackref = secondRoot?.querySelector<HTMLAnchorElement>( | |
| 301 | "a[data-footnote-backref]", | |
| 302 | ); | |
| 303 | expect(secondBackref).not.toBeNull(); | |
| 304 | secondBackref?.click(); | |
| 305 | ||
| 306 | const secondReferenceTarget = secondReference?.closest<HTMLElement>("[id]"); | |
| 307 | expect(scrollIntoView).toHaveBeenCalledTimes(2); | |
| 308 | expect(scrollIntoView.mock.instances[1]).toBe(secondReferenceTarget); | |
| 309 | } finally { | |
| 310 | scrollIntoView.mockRestore(); | |
| 311 | window.history.replaceState(window.history.state, "", originalUrl); | |
| 312 | await screen.unmount(); | |
| 313 | } | |
| 314 | }); | |
| 315 | ||
| 316 | describe("code block chrome", () => { | |
| 317 | it("shows icon-only language titles, text fallbacks, and filename overrides", async () => { | |
| 318 | const source = [ | |
| 319 | "```ts", | |
| 320 | "const a = 1;", | |
| 321 | "```", | |
| 322 | "", | |
| 323 | '```ts title="src/main.ts"', | |
| 324 | "const b = 2;", | |
| 325 | "```", | |
| 326 | "", | |
| 327 | "```text", | |
| 328 | "plain", | |
| 329 | "```", | |
| 330 | ].join("\n"); | |
| 331 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 332 | ||
| 333 | try { | |
| 334 | const titles = [...document.querySelectorAll(".chat-markdown-codeblock-title")]; | |
| 335 | expect(titles).toHaveLength(3); | |
| 336 | ||
| 337 | // Language with a known icon: icon XOR text — never the redundant pair. | |
| 338 | const languageOnly = titles[0]!; | |
| 339 | const hasIcon = languageOnly.querySelector("img") != null; | |
| 340 | const hasText = (languageOnly.textContent ?? "").includes("ts"); | |
| 341 | expect(hasIcon || hasText).toBe(true); | |
| 342 | expect(hasIcon && hasText).toBe(false); | |
| 343 | if (hasIcon) { | |
| 344 | const languageTrigger = page.getByLabelText("Language: ts").first(); | |
| 345 | await languageTrigger.hover(); | |
| 346 | await vi.waitFor(() => { | |
| 347 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | |
| 348 | expect(tooltip?.textContent).toContain("ts"); | |
| 349 | }); | |
| 350 | } | |
| 351 | ||
| 352 | // Explicit filename: text always shown. | |
| 353 | expect(titles[1]!.textContent).toBe("src/main.ts"); | |
| 354 | ||
| 355 | // Unknown language: no icon attempt, text label. | |
| 356 | expect(titles[2]!.querySelector("img")).toBeNull(); | |
| 357 | expect(titles[2]!.textContent).toBe("text"); | |
| 358 | } finally { | |
| 359 | await screen.unmount(); | |
| 360 | } | |
| 361 | }); | |
| 362 | ||
| 363 | it("toggles line wrapping per block", async () => { | |
| 364 | const screen = await render( | |
| 365 | <ChatMarkdown text={'```ts\nconst x = "long";\n```'} cwd="/repo/project" />, | |
| 366 | ); | |
| 367 | ||
| 368 | try { | |
| 369 | const block = document.querySelector(".chat-markdown-codeblock"); | |
| 370 | expect(block?.getAttribute("data-wrap")).toBe("false"); | |
| 371 | ||
| 372 | const toggle = page.getByRole("button", { name: "Wrap lines" }); | |
| 373 | await expect.element(toggle).not.toHaveAttribute("title"); | |
| 374 | await toggle.hover(); | |
| 375 | await vi.waitFor(() => { | |
| 376 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | |
| 377 | expect(tooltip?.textContent).toContain("Wrap lines"); | |
| 378 | }); | |
| 379 | await toggle.click(); | |
| 380 | expect(block?.getAttribute("data-wrap")).toBe("true"); | |
| 381 | ||
| 382 | await page.getByRole("button", { name: "Disable line wrap" }).click(); | |
| 383 | expect(block?.getAttribute("data-wrap")).toBe("false"); | |
| 384 | } finally { | |
| 385 | await screen.unmount(); | |
| 386 | } | |
| 387 | }); | |
| 388 | }); | |
| 389 | ||
| 390 | it("scrolls wide tables horizontally instead of letter-wrapping cells", async () => { | |
| 391 | const header = `| ${Array.from({ length: 8 }, (_, i) => `ColumnHeading${i}`).join(" | ")} |`; | |
| 392 | const separator = `| ${Array.from({ length: 8 }, () => "---").join(" | ")} |`; | |
| 393 | const row = `| ${Array.from({ length: 8 }, () => "averylongunbrokencellvalue@example-domain.com").join(" | ")} |`; | |
| 394 | const screen = await render( | |
| 395 | <ChatMarkdown text={[header, separator, row].join("\n")} cwd="/repo/project" />, | |
| 396 | ); | |
| 397 | ||
| 398 | try { | |
| 399 | const viewport = document.querySelector( | |
| 400 | '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', | |
| 401 | ); | |
| 402 | expect(viewport).not.toBeNull(); | |
| 403 | expect(viewport!.querySelector("table")).not.toBeNull(); | |
| 404 | // Content exceeds the container — the scroll-fade viewport scrolls | |
| 405 | // horizontally rather than squishing columns. | |
| 406 | expect(viewport!.scrollWidth).toBeGreaterThan(viewport!.clientWidth); | |
| 407 | // And cells keep their longest word intact instead of breaking mid-word. | |
| 408 | const cell = viewport!.querySelector("td"); | |
| 409 | expect(cell!.getBoundingClientRect().width).toBeGreaterThan(100); | |
| 410 | } finally { | |
| 411 | await screen.unmount(); | |
| 412 | } | |
| 413 | }); | |
| 414 | ||
| 415 | describe("table chrome", () => { | |
| 416 | const longCell = | |
| 417 | "This service has been experiencing intermittent latency spikes during peak traffic hours and the on-call team is investigating."; | |
| 418 | ||
| 419 | it("truncates cells by default and expands them from the footer toggle", async () => { | |
| 420 | const source = ["| Name | Notes |", "| --- | --- |", `| api | ${longCell} |`].join("\n"); | |
| 421 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 422 | ||
| 423 | try { | |
| 424 | const container = document.querySelector(".chat-markdown-table-container"); | |
| 425 | expect(container?.getAttribute("data-expanded")).toBe("false"); | |
| 426 | ||
| 427 | const noteCell = [...document.querySelectorAll(".chat-markdown td")].at(-1)!; | |
| 428 | expect(getComputedStyle(noteCell).whiteSpace).toBe("nowrap"); | |
| 429 | expect(noteCell.scrollWidth).toBeGreaterThan(noteCell.clientWidth); | |
| 430 | ||
| 431 | const expandButton = page.getByRole("button", { name: "Expand table cells" }); | |
| 432 | await expect.element(expandButton).not.toHaveAttribute("title"); | |
| 433 | await expandButton.hover(); | |
| 434 | await vi.waitFor(() => { | |
| 435 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | |
| 436 | expect(tooltip?.textContent).toContain("Expand table cells"); | |
| 437 | }); | |
| 438 | await expandButton.click(); | |
| 439 | expect(container?.getAttribute("data-expanded")).toBe("true"); | |
| 440 | expect(getComputedStyle(noteCell).whiteSpace).not.toBe("nowrap"); | |
| 441 | ||
| 442 | await page.getByRole("button", { name: "Collapse table cells" }).click(); | |
| 443 | expect(container?.getAttribute("data-expanded")).toBe("false"); | |
| 444 | ||
| 445 | const copyButton = page.getByRole("button", { name: "Copy table" }); | |
| 446 | await expect.element(copyButton).not.toHaveAttribute("title"); | |
| 447 | await copyButton.hover(); | |
| 448 | await vi.waitFor(() => { | |
| 449 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | |
| 450 | expect(tooltip?.textContent).toContain("Copy table"); | |
| 451 | }); | |
| 452 | expect(document.querySelector(".chat-markdown [title]")).toBeNull(); | |
| 453 | } finally { | |
| 454 | await screen.unmount(); | |
| 455 | } | |
| 456 | }); | |
| 457 | ||
| 458 | it("retains column widths when cells expand", async () => { | |
| 459 | const source = [ | |
| 460 | "| ID | Owner | Status | Priority | Region | Summary | Long Description | Metrics | Payload | Notes |", | |
| 461 | "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", | |
| 462 | '| 1001 | Ada Lovelace | Active | High | us-west-2 | Payment workflow migration | This cell has enough text to wrap across several lines when expanded without shrinking its column. | Requests: 128,440; Error rate: 0.04%; P95: 212ms | `{ "feature": "billing", "version": 3 }` | Needs post-release monitoring for 24 hours. |', | |
| 463 | ].join("\n"); | |
| 464 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 465 | ||
| 466 | try { | |
| 467 | const viewport = document.querySelector( | |
| 468 | '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', | |
| 469 | )!; | |
| 470 | const table = viewport.querySelector("table")!; | |
| 471 | const collapsedWidths = [...table.querySelectorAll("thead th")].map( | |
| 472 | (cell) => cell.getBoundingClientRect().width, | |
| 473 | ); | |
| 474 | expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); | |
| 475 | ||
| 476 | await page.getByRole("button", { name: "Expand table cells" }).click(); | |
| 477 | ||
| 478 | const expandedWidths = [...table.querySelectorAll("thead th")].map( | |
| 479 | (cell) => cell.getBoundingClientRect().width, | |
| 480 | ); | |
| 481 | expect(expandedWidths).toHaveLength(collapsedWidths.length); | |
| 482 | expandedWidths.forEach((width, index) => { | |
| 483 | expect(width).toBeGreaterThanOrEqual(collapsedWidths[index]! - 1); | |
| 484 | }); | |
| 485 | expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); | |
| 486 | } finally { | |
| 487 | await screen.unmount(); | |
| 488 | } | |
| 489 | }); | |
| 490 | ||
| 491 | it("exports tables as markdown and csv", async () => { | |
| 492 | const source = [ | |
| 493 | "| Name | Count |", | |
| 494 | "| --- | ---: |", | |
| 495 | '| widget, "deluxe" | 2 |', | |
| 496 | "| plain | 1 |", | |
| 497 | ].join("\n"); | |
| 498 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 499 | ||
| 500 | try { | |
| 501 | const table = document.querySelector(".chat-markdown table")!; | |
| 502 | expect(serializeTableElementToMarkdown(table)).toBe( | |
| 503 | ["| Name | Count |", "| --- | ---: |", '| widget, "deluxe" | 2 |', "| plain | 1 |"].join( | |
| 504 | "\n", | |
| 505 | ), | |
| 506 | ); | |
| 507 | expect(serializeTableElementToCsv(table)).toBe( | |
| 508 | ["Name,Count", '"widget, ""deluxe""",2', "plain,1"].join("\n"), | |
| 509 | ); | |
| 510 | } finally { | |
| 511 | await screen.unmount(); | |
| 512 | } | |
| 513 | }); | |
| 514 | }); | |
| 515 | ||
| 516 | describe("copying rendered markdown", () => { | |
| 517 | function copySelectedMarkdown(): { text: string; html: string } { | |
| 518 | const root = document.querySelector(".chat-markdown"); | |
| 519 | if (!root) throw new Error("chat-markdown root not rendered"); | |
| 520 | const selection = window.getSelection(); | |
| 521 | if (!selection) throw new Error("selection unavailable"); | |
| 522 | selection.removeAllRanges(); | |
| 523 | const range = document.createRange(); | |
| 524 | range.selectNodeContents(root); | |
| 525 | selection.addRange(range); | |
| 526 | ||
| 527 | const clipboardData = new DataTransfer(); | |
| 528 | root.dispatchEvent( | |
| 529 | new ClipboardEvent("copy", { clipboardData, bubbles: true, cancelable: true }), | |
| 530 | ); | |
| 531 | selection.removeAllRanges(); | |
| 532 | return { | |
| 533 | text: clipboardData.getData("text/plain"), | |
| 534 | html: clipboardData.getData("text/html"), | |
| 535 | }; | |
| 536 | } | |
| 537 | ||
| 538 | it("round-trips links, emphasis, and inline code", async () => { | |
| 539 | const screen = await render( | |
| 540 | <ChatMarkdown | |
| 541 | text="Check out [Anthropic](https://anthropic.com), **bold**, *italic*, and `code`." | |
| 542 | cwd="/repo/project" | |
| 543 | />, | |
| 544 | ); | |
| 545 | ||
| 546 | try { | |
| 547 | const { text, html } = copySelectedMarkdown(); | |
| 548 | expect(text).toBe( | |
| 549 | "Check out [Anthropic](https://anthropic.com), **bold**, *italic*, and `code`.", | |
| 550 | ); | |
| 551 | expect(html).toContain('href="https://anthropic.com"'); | |
| 552 | } finally { | |
| 553 | await screen.unmount(); | |
| 554 | } | |
| 555 | }); | |
| 556 | ||
| 557 | it("round-trips block structure: headings, lists, quotes, and fences", async () => { | |
| 558 | const source = [ | |
| 559 | "## Heading", | |
| 560 | "", | |
| 561 | "- first", | |
| 562 | "- second", | |
| 563 | " - nested", | |
| 564 | "", | |
| 565 | "1. one", | |
| 566 | "2. two", | |
| 567 | "", | |
| 568 | "- [x] done", | |
| 569 | "- [ ] todo", | |
| 570 | "", | |
| 571 | "> quoted", | |
| 572 | "", | |
| 573 | "```ts", | |
| 574 | "const x = 1;", | |
| 575 | "", | |
| 576 | "const y = 2;", | |
| 577 | "```", | |
| 578 | ].join("\n"); | |
| 579 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 580 | ||
| 581 | try { | |
| 582 | const { text } = copySelectedMarkdown(); | |
| 583 | expect(text).toBe(source); | |
| 584 | } finally { | |
| 585 | await screen.unmount(); | |
| 586 | } | |
| 587 | }); | |
| 588 | ||
| 589 | it("round-trips tables with alignment", async () => { | |
| 590 | const source = ["| Name | Count |", "| --- | ---: |", "| a | 1 |", "| b | 2 |"].join("\n"); | |
| 591 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 592 | ||
| 593 | try { | |
| 594 | const { text } = copySelectedMarkdown(); | |
| 595 | expect(text).toBe(source); | |
| 596 | } finally { | |
| 597 | await screen.unmount(); | |
| 598 | } | |
| 599 | }); | |
| 600 | ||
| 601 | it("round-trips details rendered through the collapsible", async () => { | |
| 602 | const source = [ | |
| 603 | "<details open>", | |
| 604 | "<summary>Expandable details section</summary>", | |
| 605 | "", | |
| 606 | "This content includes **formatted text**.", | |
| 607 | "</details>", | |
| 608 | ].join("\n"); | |
| 609 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 610 | ||
| 611 | try { | |
| 612 | const { text } = copySelectedMarkdown(); | |
| 613 | expect(text).toBe(source); | |
| 614 | } finally { | |
| 615 | await screen.unmount(); | |
| 616 | } | |
| 617 | }); | |
| 618 | ||
| 619 | it("excludes the code block header chrome from copied markdown", async () => { | |
| 620 | const source = ["```ts", "const x = 1;", "```"].join("\n"); | |
| 621 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | |
| 622 | ||
| 623 | try { | |
| 624 | const { text } = copySelectedMarkdown(); | |
| 625 | expect(text).toBe(source); | |
| 626 | } finally { | |
| 627 | await screen.unmount(); | |
| 628 | } | |
| 629 | }); | |
| 630 | ||
| 631 | it("copies file links as markdown and skips UI affordances", async () => { | |
| 632 | const filePath = "/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts"; | |
| 633 | const screen = await render( | |
| 634 | <ChatMarkdown | |
| 635 | text={`See [PermissionRule.ts](file://${filePath}) for details.`} | |
| 636 | cwd="/repo/project" | |
| 637 | />, | |
| 638 | ); | |
| 639 | ||
| 640 | try { | |
| 641 | const { text, html } = copySelectedMarkdown(); | |
| 642 | expect(text).toBe( | |
| 643 | `See [PermissionRule.ts](/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts) for details.`, | |
| 644 | ); | |
| 645 | expect(html).toContain("PermissionRule.ts"); | |
| 646 | expect(html).not.toContain("<img"); | |
| 647 | } finally { | |
| 648 | await screen.unmount(); | |
| 649 | } | |
| 650 | }); | |
| 651 | ||
| 652 | it("copies skill and file chips with source encodings that recreate composer chips", async () => { | |
| 653 | const source = | |
| 654 | "Use $agent-browser with [package.json](path/to/package.json) before continuing."; | |
| 655 | const screen = await render( | |
| 656 | <ChatMarkdown | |
| 657 | text={source} | |
| 658 | cwd="/repo/project" | |
| 659 | skills={[{ name: "agent-browser", displayName: "Agent Browser" }]} | |
| 660 | />, | |
| 661 | ); | |
| 662 | ||
| 663 | try { | |
| 664 | const root = document.querySelector(".chat-markdown")!; | |
| 665 | const selection = window.getSelection()!; | |
| 666 | selection.removeAllRanges(); | |
| 667 | const range = document.createRange(); | |
| 668 | range.selectNodeContents(root); | |
| 669 | selection.addRange(range); | |
| 670 | expect(selection.toString()).toContain("Agent Browser"); | |
| 671 | expect(selection.toString()).toContain("package.json"); | |
| 672 | selection.removeAllRanges(); | |
| 673 | ||
| 674 | const { text, html } = copySelectedMarkdown(); | |
| 675 | expect(text).toBe(source); | |
| 676 | expect(html).toContain("Agent Browser"); | |
| 677 | expect(html).toContain("package.json"); | |
| 678 | expect(html).not.toContain("<img"); | |
| 679 | } finally { | |
| 680 | await screen.unmount(); | |
| 681 | } | |
| 682 | }); | |
| 683 | }); | |
| 141 | 684 | }); |
apps/web/src/components/ChatMarkdown.tsx+672-34| ... | ... | @@ -1,9 +1,18 @@ |
| 1 | 1 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; |
| 2 | import { CheckIcon, CopyIcon } from "lucide-react"; | |
| 2 | import { | |
| 3 | CheckIcon, | |
| 4 | ChevronRightIcon, | |
| 5 | CopyIcon, | |
| 6 | GlobeIcon, | |
| 7 | Maximize2Icon, | |
| 8 | Minimize2Icon, | |
| 9 | WrapTextIcon, | |
| 10 | } from "lucide-react"; | |
| 3 | 11 | import type { ServerProviderSkill } from "@t3tools/contracts"; |
| 4 | 12 | import React, { |
| 5 | 13 | Children, |
| 6 | 14 | Suspense, |
| 15 | type ClipboardEvent as ReactClipboardEvent, | |
| 7 | 16 | type MouseEvent as ReactMouseEvent, |
| 8 | 17 | isValidElement, |
| 9 | 18 | use, |
| ... | ... | @@ -18,16 +27,34 @@ import React, { |
| 18 | 27 | import type { Components } from "react-markdown"; |
| 19 | 28 | import ReactMarkdown from "react-markdown"; |
| 20 | 29 | import { defaultUrlTransform } from "react-markdown"; |
| 30 | import rehypeRaw from "rehype-raw"; | |
| 31 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | |
| 32 | import remarkBreaks from "remark-breaks"; | |
| 21 | 33 | import remarkGfm from "remark-gfm"; |
| 22 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; | |
| 23 | 34 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 35 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; | |
| 36 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; | |
| 37 | import { | |
| 38 | getVscodeIconUrlForEntry, | |
| 39 | hasSpecificVscodeIconForFileName, | |
| 40 | syntheticFileNameForLanguageId, | |
| 41 | } from "../vscode-icons"; | |
| 24 | 42 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 43 | import { Button } from "./ui/button"; | |
| 44 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; | |
| 45 | import { ScrollArea } from "./ui/scroll-area"; | |
| 46 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | |
| 25 | 47 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 26 | 48 | import { openInPreferredEditor } from "../editorPreferences"; |
| 27 | 49 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 28 | 50 | import { fnv1a32 } from "../lib/diffRendering"; |
| 29 | 51 | import { LRUCache } from "../lib/lruCache"; |
| 30 | 52 | import { useTheme } from "../hooks/useTheme"; |
| 53 | import { | |
| 54 | chatMarkdownClipboardPayload, | |
| 55 | serializeTableElementToCsv, | |
| 56 | serializeTableElementToMarkdown, | |
| 57 | } from "../markdown-clipboard"; | |
| 31 | 58 | import { |
| 32 | 59 | normalizeMarkdownLinkDestination, |
| 33 | 60 | resolveMarkdownFileLinkMeta, |
| ... | ... | @@ -62,6 +89,9 @@ interface ChatMarkdownProps { |
| 62 | 89 | cwd: string | undefined; |
| 63 | 90 | isStreaming?: boolean; |
| 64 | 91 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 92 | className?: string; | |
| 93 | /** Treat single newlines as hard breaks — chat-style user input. */ | |
| 94 | lineBreaks?: boolean; | |
| 65 | 95 | } |
| 66 | 96 | |
| 67 | 97 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| ... | ... | @@ -74,6 +104,18 @@ const highlightedCodeCache = new LRUCache<string>( |
| 74 | 104 | MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, |
| 75 | 105 | ); |
| 76 | 106 | const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>(); |
| 107 | const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | |
| 108 | ...defaultSchema, | |
| 109 | attributes: { | |
| 110 | ...defaultSchema.attributes, | |
| 111 | "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), | |
| 112 | code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], | |
| 113 | }, | |
| 114 | protocols: { | |
| 115 | ...defaultSchema.protocols, | |
| 116 | href: [...(defaultSchema.protocols?.href ?? []), "file"], | |
| 117 | }, | |
| 118 | } satisfies Parameters<typeof rehypeSanitize>[0]; | |
| 77 | 119 | |
| 78 | 120 | function extractFenceLanguage(className: string | undefined): string { |
| 79 | 121 | const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); |
| ... | ... | @@ -82,6 +124,64 @@ function extractFenceLanguage(className: string | undefined): string { |
| 82 | 124 | return raw === "gitignore" ? "ini" : raw; |
| 83 | 125 | } |
| 84 | 126 | |
| 127 | const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; | |
| 128 | const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; | |
| 129 | ||
| 130 | /** Pulls a filename out of fence meta: ```ts title="x.ts" / ```ts src/main.ts */ | |
| 131 | function extractFenceTitle(meta: string | undefined): string | null { | |
| 132 | if (!meta) return null; | |
| 133 | const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); | |
| 134 | const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; | |
| 135 | if (attrTitle) return attrTitle; | |
| 136 | return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; | |
| 137 | } | |
| 138 | ||
| 139 | function extractPreCodeMeta(node: unknown): string | undefined { | |
| 140 | const children = ( | |
| 141 | node as | |
| 142 | | { | |
| 143 | children?: Array<{ | |
| 144 | type?: string; | |
| 145 | tagName?: string; | |
| 146 | data?: { meta?: unknown }; | |
| 147 | properties?: { dataCodeMeta?: unknown }; | |
| 148 | }>; | |
| 149 | } | |
| 150 | | undefined | |
| 151 | )?.children; | |
| 152 | const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); | |
| 153 | const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; | |
| 154 | return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; | |
| 155 | } | |
| 156 | ||
| 157 | type MarkdownAstNode = { | |
| 158 | type?: string; | |
| 159 | meta?: unknown; | |
| 160 | data?: { | |
| 161 | hProperties?: Record<string, unknown>; | |
| 162 | }; | |
| 163 | children?: MarkdownAstNode[]; | |
| 164 | }; | |
| 165 | ||
| 166 | function remarkPreserveCodeMeta() { | |
| 167 | return (tree: MarkdownAstNode) => { | |
| 168 | const visit = (node: MarkdownAstNode) => { | |
| 169 | if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) { | |
| 170 | node.data = { | |
| 171 | ...node.data, | |
| 172 | hProperties: { | |
| 173 | ...node.data?.hProperties, | |
| 174 | dataCodeMeta: node.meta.trim(), | |
| 175 | }, | |
| 176 | }; | |
| 177 | } | |
| 178 | node.children?.forEach(visit); | |
| 179 | }; | |
| 180 | ||
| 181 | visit(tree); | |
| 182 | }; | |
| 183 | } | |
| 184 | ||
| 85 | 185 | function nodeToPlainText(node: ReactNode): string { |
| 86 | 186 | if (typeof node === "string" || typeof node === "number") { |
| 87 | 187 | return String(node); |
| ... | ... | @@ -146,9 +246,254 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { |
| 146 | 246 | return promise; |
| 147 | 247 | } |
| 148 | 248 | |
| 149 | function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { | |
| 249 | function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { | |
| 250 | const containerRef = useRef<HTMLDivElement | null>(null); | |
| 251 | const tableRef = useRef<HTMLTableElement | null>(null); | |
| 252 | const [expanded, setExpanded] = useState(false); | |
| 253 | const [copied, setCopied] = useState(false); | |
| 254 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 255 | const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; | |
| 256 | const copyLabel = copied ? "Copied" : "Copy table"; | |
| 257 | ||
| 258 | function toggleExpanded() { | |
| 259 | const table = tableRef.current; | |
| 260 | if (!table) return; | |
| 261 | ||
| 262 | if (!expanded) { | |
| 263 | const rows = [...table.rows]; | |
| 264 | const columnWidths = rows.reduce<number[]>((widths, row) => { | |
| 265 | [...row.cells].forEach((cell, columnIndex) => { | |
| 266 | widths[columnIndex] = Math.max( | |
| 267 | widths[columnIndex] ?? 0, | |
| 268 | cell.getBoundingClientRect().width, | |
| 269 | ); | |
| 270 | }); | |
| 271 | return widths; | |
| 272 | }, []); | |
| 273 | ||
| 274 | [...(table.tHead?.rows[0]?.cells ?? [])].forEach((cell, columnIndex) => { | |
| 275 | cell.style.minWidth = `${columnWidths[columnIndex] ?? cell.getBoundingClientRect().width}px`; | |
| 276 | }); | |
| 277 | } | |
| 278 | ||
| 279 | setExpanded((value) => !value); | |
| 280 | } | |
| 281 | ||
| 282 | const handleCopy = useCallback((format: "markdown" | "csv") => { | |
| 283 | const table = containerRef.current?.querySelector("table"); | |
| 284 | if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { | |
| 285 | return; | |
| 286 | } | |
| 287 | const text = | |
| 288 | format === "markdown" | |
| 289 | ? serializeTableElementToMarkdown(table) | |
| 290 | : serializeTableElementToCsv(table); | |
| 291 | void navigator.clipboard | |
| 292 | .writeText(text) | |
| 293 | .then(() => { | |
| 294 | if (copiedTimerRef.current != null) { | |
| 295 | clearTimeout(copiedTimerRef.current); | |
| 296 | } | |
| 297 | setCopied(true); | |
| 298 | copiedTimerRef.current = setTimeout(() => { | |
| 299 | setCopied(false); | |
| 300 | copiedTimerRef.current = null; | |
| 301 | }, 1200); | |
| 302 | }) | |
| 303 | .catch(() => undefined); | |
| 304 | }, []); | |
| 305 | ||
| 306 | useEffect( | |
| 307 | () => () => { | |
| 308 | if (copiedTimerRef.current != null) { | |
| 309 | clearTimeout(copiedTimerRef.current); | |
| 310 | copiedTimerRef.current = null; | |
| 311 | } | |
| 312 | }, | |
| 313 | [], | |
| 314 | ); | |
| 315 | ||
| 316 | return ( | |
| 317 | <div | |
| 318 | ref={containerRef} | |
| 319 | className="chat-markdown-table-container" | |
| 320 | data-expanded={expanded ? "true" : "false"} | |
| 321 | > | |
| 322 | <ScrollArea | |
| 323 | chainVerticalScroll | |
| 324 | scrollFade | |
| 325 | hideScrollbars | |
| 326 | className="w-full max-w-full rounded-none" | |
| 327 | > | |
| 328 | <table ref={tableRef} {...props}> | |
| 329 | {children} | |
| 330 | </table> | |
| 331 | </ScrollArea> | |
| 332 | <div className="chat-markdown-table-footer select-none"> | |
| 333 | <Tooltip> | |
| 334 | <TooltipTrigger | |
| 335 | render={ | |
| 336 | <Button | |
| 337 | type="button" | |
| 338 | variant="ghost" | |
| 339 | size="icon-xs" | |
| 340 | className="chat-markdown-chrome-action" | |
| 341 | aria-pressed={expanded} | |
| 342 | onClick={toggleExpanded} | |
| 343 | aria-label={expandLabel} | |
| 344 | /> | |
| 345 | } | |
| 346 | > | |
| 347 | {expanded ? <Minimize2Icon className="size-3" /> : <Maximize2Icon className="size-3" />} | |
| 348 | </TooltipTrigger> | |
| 349 | <TooltipPopup side="top">{expandLabel}</TooltipPopup> | |
| 350 | </Tooltip> | |
| 351 | <Menu> | |
| 352 | <Tooltip> | |
| 353 | <TooltipTrigger | |
| 354 | render={ | |
| 355 | <MenuTrigger | |
| 356 | render={ | |
| 357 | <Button | |
| 358 | type="button" | |
| 359 | variant="ghost" | |
| 360 | size="icon-xs" | |
| 361 | className="chat-markdown-chrome-action" | |
| 362 | aria-label={copyLabel} | |
| 363 | /> | |
| 364 | } | |
| 365 | /> | |
| 366 | } | |
| 367 | > | |
| 368 | {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} | |
| 369 | </TooltipTrigger> | |
| 370 | <TooltipPopup side="top">{copyLabel}</TooltipPopup> | |
| 371 | </Tooltip> | |
| 372 | <MenuPopup align="end"> | |
| 373 | <MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem> | |
| 374 | <MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem> | |
| 375 | </MenuPopup> | |
| 376 | </Menu> | |
| 377 | </div> | |
| 378 | </div> | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| 382 | function MarkdownDetails({ | |
| 383 | children, | |
| 384 | open = false, | |
| 385 | }: Pick<React.ComponentProps<"details">, "children" | "open">) { | |
| 386 | const [isOpen, setIsOpen] = useState(open); | |
| 387 | const childNodes = Children.toArray(children); | |
| 388 | const summaryIndex = childNodes.findIndex( | |
| 389 | (child) => isValidElement(child) && child.type === "summary", | |
| 390 | ); | |
| 391 | const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null; | |
| 392 | const summary = | |
| 393 | isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children | |
| 394 | ? summaryNode.props.children | |
| 395 | : "Details"; | |
| 396 | const content = childNodes.filter((_, index) => index !== summaryIndex); | |
| 397 | ||
| 398 | return ( | |
| 399 | <Collapsible | |
| 400 | defaultOpen={open} | |
| 401 | onOpenChange={setIsOpen} | |
| 402 | className="chat-markdown-details my-2 border-y border-border/60" | |
| 403 | data-markdown-details="" | |
| 404 | data-markdown-details-open={isOpen ? "true" : "false"} | |
| 405 | > | |
| 406 | <CollapsibleTrigger | |
| 407 | className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90" | |
| 408 | data-markdown-details-summary="" | |
| 409 | > | |
| 410 | <ChevronRightIcon | |
| 411 | className="size-4 shrink-0 text-muted-foreground transition-transform" | |
| 412 | aria-hidden | |
| 413 | /> | |
| 414 | <span>{summary}</span> | |
| 415 | </CollapsibleTrigger> | |
| 416 | <CollapsiblePanel> | |
| 417 | <div className="pb-3 ps-6 text-foreground/80" data-markdown-details-content=""> | |
| 418 | {content} | |
| 419 | </div> | |
| 420 | </CollapsiblePanel> | |
| 421 | </Collapsible> | |
| 422 | ); | |
| 423 | } | |
| 424 | ||
| 425 | /** | |
| 426 | * Filename titles render icon + text; language-only titles render just the | |
| 427 | * icon (redundant next to its own name) and fall back to the language text | |
| 428 | * when no specific icon exists or it fails to load. | |
| 429 | */ | |
| 430 | function MarkdownCodeBlockTitleContent({ | |
| 431 | fenceTitle, | |
| 432 | language, | |
| 433 | theme, | |
| 434 | }: { | |
| 435 | fenceTitle: string | null; | |
| 436 | language: string; | |
| 437 | theme: "light" | "dark"; | |
| 438 | }) { | |
| 439 | const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null); | |
| 440 | ||
| 441 | if (fenceTitle) { | |
| 442 | return ( | |
| 443 | <> | |
| 444 | <VscodeEntryIcon pathValue={fenceTitle} kind="file" theme={theme} className="size-3.5" /> | |
| 445 | <span className="truncate">{fenceTitle}</span> | |
| 446 | </> | |
| 447 | ); | |
| 448 | } | |
| 449 | ||
| 450 | const fileName = syntheticFileNameForLanguageId(language); | |
| 451 | const iconUrl = hasSpecificVscodeIconForFileName(fileName, theme) | |
| 452 | ? getVscodeIconUrlForEntry(fileName, "file", theme) | |
| 453 | : null; | |
| 454 | if (!iconUrl || failedIconUrl === iconUrl) { | |
| 455 | return <span className="truncate">{language}</span>; | |
| 456 | } | |
| 457 | return ( | |
| 458 | <Tooltip> | |
| 459 | <TooltipTrigger | |
| 460 | render={ | |
| 461 | <span className="inline-flex shrink-0 rounded-sm" aria-label={`Language: ${language}`} /> | |
| 462 | } | |
| 463 | > | |
| 464 | <img | |
| 465 | src={iconUrl} | |
| 466 | alt="" | |
| 467 | aria-hidden | |
| 468 | className="size-3.5 shrink-0" | |
| 469 | loading="lazy" | |
| 470 | draggable={false} | |
| 471 | onError={() => setFailedIconUrl(iconUrl)} | |
| 472 | /> | |
| 473 | </TooltipTrigger> | |
| 474 | <TooltipPopup side="top">{language}</TooltipPopup> | |
| 475 | </Tooltip> | |
| 476 | ); | |
| 477 | } | |
| 478 | ||
| 479 | function MarkdownCodeBlock({ | |
| 480 | code, | |
| 481 | language, | |
| 482 | fenceTitle, | |
| 483 | theme, | |
| 484 | children, | |
| 485 | }: { | |
| 486 | code: string; | |
| 487 | language: string; | |
| 488 | fenceTitle: string | null; | |
| 489 | theme: "light" | "dark"; | |
| 490 | children: ReactNode; | |
| 491 | }) { | |
| 150 | 492 | const [copied, setCopied] = useState(false); |
| 493 | const [wrapped, setWrapped] = useState(false); | |
| 151 | 494 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 495 | const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; | |
| 496 | const copyLabel = copied ? "Copied" : "Copy code"; | |
| 152 | 497 | const handleCopy = useCallback(() => { |
| 153 | 498 | if (typeof navigator === "undefined" || navigator.clipboard == null) { |
| 154 | 499 | return; |
| ... | ... | @@ -179,16 +524,57 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo |
| 179 | 524 | ); |
| 180 | 525 | |
| 181 | 526 | return ( |
| 182 | <div className="chat-markdown-codeblock leading-snug"> | |
| 183 | <button | |
| 184 | type="button" | |
| 185 | className="chat-markdown-copy-button" | |
| 186 | onClick={handleCopy} | |
| 187 | title={copied ? "Copied" : "Copy code"} | |
| 188 | aria-label={copied ? "Copied" : "Copy code"} | |
| 189 | > | |
| 190 | {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} | |
| 191 | </button> | |
| 527 | <div | |
| 528 | className="chat-markdown-codeblock leading-snug" | |
| 529 | data-language={language} | |
| 530 | data-wrap={wrapped ? "true" : "false"} | |
| 531 | > | |
| 532 | <div className="chat-markdown-codeblock-header select-none"> | |
| 533 | <span className="chat-markdown-codeblock-title"> | |
| 534 | <MarkdownCodeBlockTitleContent | |
| 535 | fenceTitle={fenceTitle} | |
| 536 | language={language} | |
| 537 | theme={theme} | |
| 538 | /> | |
| 539 | </span> | |
| 540 | <span className="flex items-center gap-0.5"> | |
| 541 | <Tooltip> | |
| 542 | <TooltipTrigger | |
| 543 | render={ | |
| 544 | <Button | |
| 545 | type="button" | |
| 546 | variant="ghost" | |
| 547 | size="icon-xs" | |
| 548 | className="chat-markdown-chrome-action" | |
| 549 | aria-pressed={wrapped} | |
| 550 | onClick={() => setWrapped((value) => !value)} | |
| 551 | aria-label={wrapLabel} | |
| 552 | /> | |
| 553 | } | |
| 554 | > | |
| 555 | <WrapTextIcon className="size-3" /> | |
| 556 | </TooltipTrigger> | |
| 557 | <TooltipPopup side="top">{wrapLabel}</TooltipPopup> | |
| 558 | </Tooltip> | |
| 559 | <Tooltip> | |
| 560 | <TooltipTrigger | |
| 561 | render={ | |
| 562 | <Button | |
| 563 | type="button" | |
| 564 | variant="ghost" | |
| 565 | size="icon-xs" | |
| 566 | className="chat-markdown-chrome-action" | |
| 567 | onClick={handleCopy} | |
| 568 | aria-label={copyLabel} | |
| 569 | /> | |
| 570 | } | |
| 571 | > | |
| 572 | {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} | |
| 573 | </TooltipTrigger> | |
| 574 | <TooltipPopup side="top">{copyLabel}</TooltipPopup> | |
| 575 | </Tooltip> | |
| 576 | </span> | |
| 577 | </div> | |
| 192 | 578 | {children} |
| 193 | 579 | </div> |
| 194 | 580 | ); |
| ... | ... | @@ -280,17 +666,15 @@ interface MarkdownFileLinkProps { |
| 280 | 666 | href: string; |
| 281 | 667 | targetPath: string; |
| 282 | 668 | displayPath: string; |
| 283 | filePath: string; | |
| 284 | 669 | label: string; |
| 670 | copyMarkdown: string; | |
| 285 | 671 | theme: "light" | "dark"; |
| 286 | 672 | className?: string | undefined; |
| 287 | 673 | } |
| 288 | 674 | |
| 289 | 675 | const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; |
| 290 | 676 | const MARKDOWN_FILE_LINK_CLASS_NAME = |
| 291 | "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; | |
| 292 | const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; | |
| 293 | const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; | |
| 677 | "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; | |
| 294 | 678 | |
| 295 | 679 | function pathParentSegments(path: string): string[] { |
| 296 | 680 | const normalized = path.replaceAll("\\", "/"); |
| ... | ... | @@ -367,12 +751,196 @@ function normalizeMarkdownLinkHrefKey(href: string): string { |
| 367 | 751 | return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; |
| 368 | 752 | } |
| 369 | 753 | |
| 754 | const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; | |
| 755 | ||
| 756 | /** Hosts whose favicon request already failed this session — skip straight to the globe. */ | |
| 757 | const failedFaviconHosts = new Set<string>(); | |
| 758 | ||
| 759 | function resolveExternalLinkHost(href: string | undefined): string | null { | |
| 760 | if (!href) return null; | |
| 761 | try { | |
| 762 | const url = new URL(href); | |
| 763 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; | |
| 764 | return url.hostname || null; | |
| 765 | } catch { | |
| 766 | return null; | |
| 767 | } | |
| 768 | } | |
| 769 | ||
| 770 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { | |
| 771 | const [failedHost, setFailedHost] = useState<string | null>(null); | |
| 772 | return ( | |
| 773 | <span className="chat-markdown-link-favicon" aria-hidden> | |
| 774 | {failedHost === host || failedFaviconHosts.has(host) ? ( | |
| 775 | <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} /> | |
| 776 | ) : ( | |
| 777 | <img | |
| 778 | src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`} | |
| 779 | alt="" | |
| 780 | loading="lazy" | |
| 781 | draggable={false} | |
| 782 | className={cn(MARKDOWN_LINK_FAVICON_CLASS_NAME, "rounded-sm")} | |
| 783 | onError={() => { | |
| 784 | failedFaviconHosts.add(host); | |
| 785 | setFailedHost(host); | |
| 786 | }} | |
| 787 | /> | |
| 788 | )} | |
| 789 | </span> | |
| 790 | ); | |
| 791 | }); | |
| 792 | ||
| 793 | function leadingExternalLinkTextLength(text: string): number { | |
| 794 | const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; | |
| 795 | if (protocol) return protocol.length; | |
| 796 | return Math.min(text.length, 1); | |
| 797 | } | |
| 798 | ||
| 799 | function breakableExternalLinkText(text: string): ReactNode[] { | |
| 800 | return Array.from(text, (character, index) => ( | |
| 801 | <React.Fragment key={`${index}:${character}`}> | |
| 802 | {character} | |
| 803 | <wbr /> | |
| 804 | </React.Fragment> | |
| 805 | )); | |
| 806 | } | |
| 807 | ||
| 808 | function plainHastText(node: unknown): string | null { | |
| 809 | if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { | |
| 810 | return null; | |
| 811 | } | |
| 812 | const parts = node.children.map((child) => { | |
| 813 | if ( | |
| 814 | child && | |
| 815 | typeof child === "object" && | |
| 816 | "type" in child && | |
| 817 | child.type === "text" && | |
| 818 | "value" in child && | |
| 819 | typeof child.value === "string" | |
| 820 | ) { | |
| 821 | return child.value; | |
| 822 | } | |
| 823 | return null; | |
| 824 | }); | |
| 825 | return parts.every((part) => part !== null) ? parts.join("") : null; | |
| 826 | } | |
| 827 | ||
| 828 | const SANITIZED_FRAGMENT_PREFIX = "user-content-"; | |
| 829 | ||
| 830 | function decodeMarkdownFragmentId(href: string): string { | |
| 831 | const encodedId = href.slice(1); | |
| 832 | try { | |
| 833 | return decodeURIComponent(encodedId); | |
| 834 | } catch { | |
| 835 | return encodedId; | |
| 836 | } | |
| 837 | } | |
| 838 | ||
| 839 | function normalizeSanitizedFragmentId(id: string): string { | |
| 840 | let normalizedId = id; | |
| 841 | while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { | |
| 842 | normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); | |
| 843 | } | |
| 844 | return normalizedId; | |
| 845 | } | |
| 846 | ||
| 847 | function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { | |
| 848 | const decodedId = decodeMarkdownFragmentId(href); | |
| 849 | const normalizedId = normalizeSanitizedFragmentId(decodedId); | |
| 850 | const matchesFragment = (element: HTMLElement) => | |
| 851 | element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; | |
| 852 | const markdownRoot = anchor.closest<HTMLElement>(".chat-markdown"); | |
| 853 | if (markdownRoot) { | |
| 854 | const localTargets = Array.from(markdownRoot.querySelectorAll<HTMLElement>("[id]")); | |
| 855 | const localTarget = localTargets.find(matchesFragment); | |
| 856 | if (localTarget) return localTarget; | |
| 857 | } | |
| 858 | ||
| 859 | return ( | |
| 860 | document.getElementById(decodedId) ?? | |
| 861 | Array.from(document.querySelectorAll<HTMLElement>("[id]")).find(matchesFragment) ?? | |
| 862 | null | |
| 863 | ); | |
| 864 | } | |
| 865 | ||
| 866 | function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>, href: string) { | |
| 867 | if ( | |
| 868 | event.defaultPrevented || | |
| 869 | event.button !== 0 || | |
| 870 | event.metaKey || | |
| 871 | event.ctrlKey || | |
| 872 | event.shiftKey || | |
| 873 | event.altKey | |
| 874 | ) { | |
| 875 | return; | |
| 876 | } | |
| 877 | ||
| 878 | const target = findMarkdownFragmentTarget(event.currentTarget, href); | |
| 879 | if (!target) return; | |
| 880 | ||
| 881 | event.preventDefault(); | |
| 882 | const nextUrl = new URL(window.location.href); | |
| 883 | nextUrl.hash = href.slice(1); | |
| 884 | window.history.pushState(window.history.state, "", nextUrl); | |
| 885 | target.scrollIntoView({ block: "nearest" }); | |
| 886 | } | |
| 887 | ||
| 888 | function MarkdownExternalLinkContent({ | |
| 889 | host, | |
| 890 | plainText, | |
| 891 | children, | |
| 892 | }: { | |
| 893 | host: string; | |
| 894 | plainText: string | null; | |
| 895 | children: ReactNode; | |
| 896 | }) { | |
| 897 | if (plainText) { | |
| 898 | const leadingLength = leadingExternalLinkTextLength(plainText); | |
| 899 | return ( | |
| 900 | <> | |
| 901 | <span className="chat-markdown-link-leading"> | |
| 902 | <MarkdownLinkFavicon host={host} /> | |
| 903 | {plainText.slice(0, leadingLength)} | |
| 904 | </span> | |
| 905 | {breakableExternalLinkText(plainText.slice(leadingLength))} | |
| 906 | </> | |
| 907 | ); | |
| 908 | } | |
| 909 | ||
| 910 | const childNodes = Children.toArray(children); | |
| 911 | const firstChild = childNodes[0]; | |
| 912 | ||
| 913 | if (typeof firstChild === "string" && firstChild.length > 0) { | |
| 914 | const leadingLength = leadingExternalLinkTextLength(firstChild); | |
| 915 | return ( | |
| 916 | <> | |
| 917 | <span className="chat-markdown-link-leading"> | |
| 918 | <MarkdownLinkFavicon host={host} /> | |
| 919 | {firstChild.slice(0, leadingLength)} | |
| 920 | </span> | |
| 921 | {breakableExternalLinkText(firstChild.slice(leadingLength))} | |
| 922 | {childNodes.slice(1)} | |
| 923 | </> | |
| 924 | ); | |
| 925 | } | |
| 926 | ||
| 927 | return ( | |
| 928 | <> | |
| 929 | <span className="chat-markdown-link-leading"> | |
| 930 | <MarkdownLinkFavicon host={host} /> | |
| 931 | {firstChild} | |
| 932 | </span> | |
| 933 | {childNodes.slice(1)} | |
| 934 | </> | |
| 935 | ); | |
| 936 | } | |
| 937 | ||
| 370 | 938 | const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 371 | 939 | href, |
| 372 | 940 | targetPath, |
| 373 | 941 | displayPath, |
| 374 | filePath, | |
| 375 | 942 | label, |
| 943 | copyMarkdown, | |
| 376 | 944 | theme, |
| 377 | 945 | className, |
| 378 | 946 | }: MarkdownFileLinkProps) { |
| ... | ... | @@ -467,7 +1035,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 467 | 1035 | render={ |
| 468 | 1036 | <a |
| 469 | 1037 | href={href} |
| 470 | className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, className)} | |
| 1038 | className={cn(CHAT_FILE_TAG_CHIP_CLASS_NAME, MARKDOWN_FILE_LINK_CLASS_NAME, className)} | |
| 1039 | data-markdown-copy={copyMarkdown} | |
| 471 | 1040 | onClick={(event) => { |
| 472 | 1041 | event.preventDefault(); |
| 473 | 1042 | event.stopPropagation(); |
| ... | ... | @@ -475,13 +1044,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 475 | 1044 | }} |
| 476 | 1045 | onContextMenu={handleContextMenu} |
| 477 | 1046 | > |
| 478 | <VscodeEntryIcon | |
| 479 | pathValue={filePath} | |
| 480 | kind="file" | |
| 481 | theme={theme} | |
| 482 | className={cn(MARKDOWN_FILE_LINK_ICON_CLASS_NAME, "text-current")} | |
| 483 | /> | |
| 484 | <span className={MARKDOWN_FILE_LINK_LABEL_CLASS_NAME}>{label}</span> | |
| 1047 | <FileTagChipContent path={targetPath} label={label} theme={theme} selectable /> | |
| 485 | 1048 | </a> |
| 486 | 1049 | } |
| 487 | 1050 | /> |
| ... | ... | @@ -505,8 +1068,8 @@ function areMarkdownFileLinkPropsEqual( |
| 505 | 1068 | previous.href === next.href && |
| 506 | 1069 | previous.targetPath === next.targetPath && |
| 507 | 1070 | previous.displayPath === next.displayPath && |
| 508 | previous.filePath === next.filePath && | |
| 509 | 1071 | previous.label === next.label && |
| 1072 | previous.copyMarkdown === next.copyMarkdown && | |
| 510 | 1073 | previous.theme === next.theme && |
| 511 | 1074 | previous.className === next.className |
| 512 | 1075 | ); |
| ... | ... | @@ -517,6 +1080,8 @@ function ChatMarkdown({ |
| 517 | 1080 | cwd, |
| 518 | 1081 | isStreaming = false, |
| 519 | 1082 | skills = EMPTY_MARKDOWN_SKILLS, |
| 1083 | className, | |
| 1084 | lineBreaks = false, | |
| 520 | 1085 | }: ChatMarkdownProps) { |
| 521 | 1086 | const { resolvedTheme } = useTheme(); |
| 522 | 1087 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| ... | ... | @@ -542,6 +1107,17 @@ function ChatMarkdown({ |
| 542 | 1107 | const markdownUrlTransform = useCallback((href: string) => { |
| 543 | 1108 | return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); |
| 544 | 1109 | }, []); |
| 1110 | // Re-emit highlighted content as markdown so copying out of the rendered | |
| 1111 | // view keeps links, emphasis, lists, and code fences intact. | |
| 1112 | const handleCopy = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => { | |
| 1113 | const selection = window.getSelection(); | |
| 1114 | if (!selection || selection.isCollapsed || !event.clipboardData) return; | |
| 1115 | const payload = chatMarkdownClipboardPayload(selection); | |
| 1116 | if (!payload) return; | |
| 1117 | event.preventDefault(); | |
| 1118 | event.clipboardData.setData("text/plain", payload.text); | |
| 1119 | event.clipboardData.setData("text/html", payload.html); | |
| 1120 | }, []); | |
| 545 | 1121 | const markdownComponents = useMemo<Components>( |
| 546 | 1122 | () => ({ |
| 547 | 1123 | p({ node: _node, children, ...props }) { |
| ... | ... | @@ -550,11 +1126,49 @@ function ChatMarkdown({ |
| 550 | 1126 | li({ node: _node, children, ...props }) { |
| 551 | 1127 | return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>; |
| 552 | 1128 | }, |
| 553 | a({ node: _node, href, ...props }) { | |
| 1129 | a({ node, href, children, ...props }) { | |
| 554 | 1130 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; |
| 555 | 1131 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; |
| 556 | 1132 | if (!fileLinkMeta) { |
| 557 | return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />; | |
| 1133 | const faviconHost = resolveExternalLinkHost(href); | |
| 1134 | const isSameDocumentLink = href?.startsWith("#") ?? false; | |
| 1135 | const onClick = props.onClick; | |
| 1136 | const link = ( | |
| 1137 | <a | |
| 1138 | {...props} | |
| 1139 | href={href} | |
| 1140 | target={isSameDocumentLink ? undefined : "_blank"} | |
| 1141 | rel={isSameDocumentLink ? undefined : "noopener noreferrer"} | |
| 1142 | onClick={(event) => { | |
| 1143 | onClick?.(event); | |
| 1144 | if (isSameDocumentLink && href) { | |
| 1145 | handleMarkdownFragmentClick(event, href); | |
| 1146 | } | |
| 1147 | }} | |
| 1148 | > | |
| 1149 | {faviconHost ? ( | |
| 1150 | <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}> | |
| 1151 | {children} | |
| 1152 | </MarkdownExternalLinkContent> | |
| 1153 | ) : ( | |
| 1154 | children | |
| 1155 | )} | |
| 1156 | </a> | |
| 1157 | ); | |
| 1158 | if (!faviconHost || !href) { | |
| 1159 | return link; | |
| 1160 | } | |
| 1161 | return ( | |
| 1162 | <Tooltip> | |
| 1163 | <TooltipTrigger render={link} /> | |
| 1164 | <TooltipPopup | |
| 1165 | side="top" | |
| 1166 | className="max-w-[min(36rem,calc(100vw-2rem))] whitespace-normal leading-tight wrap-anywhere" | |
| 1167 | > | |
| 1168 | {href} | |
| 1169 | </TooltipPopup> | |
| 1170 | </Tooltip> | |
| 1171 | ); | |
| 558 | 1172 | } |
| 559 | 1173 | |
| 560 | 1174 | const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); |
| ... | ... | @@ -573,21 +1187,34 @@ function ChatMarkdown({ |
| 573 | 1187 | href={fileLinkMeta.targetPath} |
| 574 | 1188 | targetPath={fileLinkMeta.targetPath} |
| 575 | 1189 | displayPath={fileLinkMeta.displayPath} |
| 576 | filePath={fileLinkMeta.filePath} | |
| 577 | 1190 | label={labelParts.join(" · ")} |
| 1191 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} | |
| 578 | 1192 | theme={resolvedTheme} |
| 579 | 1193 | className={props.className} |
| 580 | 1194 | /> |
| 581 | 1195 | ); |
| 582 | 1196 | }, |
| 583 | pre({ node: _node, children, ...props }) { | |
| 1197 | table({ node: _node, ...props }) { | |
| 1198 | return <MarkdownTable {...props} />; | |
| 1199 | }, | |
| 1200 | details({ node: _node, children, open: detailsOpen }) { | |
| 1201 | return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>; | |
| 1202 | }, | |
| 1203 | pre({ node, children, ...props }) { | |
| 584 | 1204 | const codeBlock = extractCodeBlock(children); |
| 585 | 1205 | if (!codeBlock) { |
| 586 | 1206 | return <pre {...props}>{children}</pre>; |
| 587 | 1207 | } |
| 588 | 1208 | |
| 1209 | const language = extractFenceLanguage(codeBlock.className); | |
| 1210 | const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); | |
| 589 | 1211 | return ( |
| 590 | <MarkdownCodeBlock code={codeBlock.code}> | |
| 1212 | <MarkdownCodeBlock | |
| 1213 | code={codeBlock.code} | |
| 1214 | language={language} | |
| 1215 | fenceTitle={fenceTitle} | |
| 1216 | theme={resolvedTheme} | |
| 1217 | > | |
| 591 | 1218 | <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}> |
| 592 | 1219 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 593 | 1220 | <SuspenseShikiCodeBlock |
| ... | ... | @@ -613,9 +1240,20 @@ function ChatMarkdown({ |
| 613 | 1240 | ); |
| 614 | 1241 | |
| 615 | 1242 | return ( |
| 616 | <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80"> | |
| 1243 | <div | |
| 1244 | className={cn( | |
| 1245 | "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80", | |
| 1246 | className, | |
| 1247 | )} | |
| 1248 | onCopy={handleCopy} | |
| 1249 | > | |
| 617 | 1250 | <ReactMarkdown |
| 618 | remarkPlugins={[remarkGfm]} | |
| 1251 | remarkPlugins={ | |
| 1252 | lineBreaks | |
| 1253 | ? [remarkGfm, remarkBreaks, remarkPreserveCodeMeta] | |
| 1254 | : [remarkGfm, remarkPreserveCodeMeta] | |
| 1255 | } | |
| 1256 | rehypePlugins={[rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA]]} | |
| 619 | 1257 | components={markdownComponents} |
| 620 | 1258 | urlTransform={markdownUrlTransform} |
| 621 | 1259 | > |
apps/web/src/components/ChatView.browser.tsx+54-8| ... | ... | @@ -2341,7 +2341,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2341 | 2341 | const runButton = await waitForElement( |
| 2342 | 2342 | () => |
| 2343 | 2343 | Array.from(document.querySelectorAll("button")).find( |
| 2344 | (button) => button.title === "Run Lint", | |
| 2344 | (button) => button.getAttribute("aria-label") === "Run Lint", | |
| 2345 | 2345 | ) as HTMLButtonElement | null, |
| 2346 | 2346 | "Unable to find Run Lint button.", |
| 2347 | 2347 | ); |
| ... | ... | @@ -2420,7 +2420,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2420 | 2420 | const runButton = await waitForElement( |
| 2421 | 2421 | () => |
| 2422 | 2422 | Array.from(document.querySelectorAll("button")).find( |
| 2423 | (button) => button.title === "Run Test", | |
| 2423 | (button) => button.getAttribute("aria-label") === "Run Test", | |
| 2424 | 2424 | ) as HTMLButtonElement | null, |
| 2425 | 2425 | "Unable to find Run Test button.", |
| 2426 | 2426 | ); |
| ... | ... | @@ -3160,7 +3160,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3160 | 3160 | |
| 3161 | 3161 | try { |
| 3162 | 3162 | const initialModeButton = await waitForInteractionModeButton("Build"); |
| 3163 | expect(initialModeButton.title).toContain("enter plan mode"); | |
| 3163 | expect(initialModeButton.getAttribute("aria-label")).toContain("enter plan mode"); | |
| 3164 | expect(initialModeButton.hasAttribute("title")).toBe(false); | |
| 3164 | 3165 | |
| 3165 | 3166 | window.dispatchEvent( |
| 3166 | 3167 | new KeyboardEvent("keydown", { |
| ... | ... | @@ -3172,7 +3173,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3172 | 3173 | ); |
| 3173 | 3174 | await waitForLayout(); |
| 3174 | 3175 | |
| 3175 | expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); | |
| 3176 | expect((await waitForInteractionModeButton("Build")).getAttribute("aria-label")).toContain( | |
| 3177 | "enter plan mode", | |
| 3178 | ); | |
| 3176 | 3179 | |
| 3177 | 3180 | const composerEditor = await waitForComposerEditor(); |
| 3178 | 3181 | composerEditor.focus(); |
| ... | ... | @@ -3187,7 +3190,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3187 | 3190 | |
| 3188 | 3191 | await vi.waitFor( |
| 3189 | 3192 | async () => { |
| 3190 | expect((await waitForInteractionModeButton("Plan")).title).toContain( | |
| 3193 | expect((await waitForInteractionModeButton("Plan")).getAttribute("aria-label")).toContain( | |
| 3191 | 3194 | "return to normal build mode", |
| 3192 | 3195 | ); |
| 3193 | 3196 | }, |
| ... | ... | @@ -3205,7 +3208,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3205 | 3208 | |
| 3206 | 3209 | await vi.waitFor( |
| 3207 | 3210 | async () => { |
| 3208 | expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); | |
| 3211 | expect( | |
| 3212 | (await waitForInteractionModeButton("Build")).getAttribute("aria-label"), | |
| 3213 | ).toContain("enter plan mode"); | |
| 3209 | 3214 | }, |
| 3210 | 3215 | { timeout: 8_000, interval: 16 }, |
| 3211 | 3216 | ); |
| ... | ... | @@ -3587,13 +3592,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3587 | 3592 | }, |
| 3588 | 3593 | { timeout: 8_000, interval: 16 }, |
| 3589 | 3594 | ); |
| 3590 | await waitForComposerText("hi @package.json there"); | |
| 3595 | await waitForComposerText("hi [package.json](package.json) there"); | |
| 3591 | 3596 | await setComposerSelectionByTextOffsets({ |
| 3592 | 3597 | start: "hi package.json ".length, |
| 3593 | 3598 | end: "hi package.json there".length, |
| 3594 | 3599 | }); |
| 3595 | 3600 | await pressComposerKey("("); |
| 3596 | await waitForComposerText("hi @package.json (there)"); | |
| 3601 | await waitForComposerText("hi [package.json](package.json) (there)"); | |
| 3597 | 3602 | } finally { |
| 3598 | 3603 | await mounted.cleanup(); |
| 3599 | 3604 | } |
| ... | ... | @@ -3625,6 +3630,47 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3625 | 3630 | } |
| 3626 | 3631 | }); |
| 3627 | 3632 | |
| 3633 | it("stores selected file tags as markdown links while keeping the composer chip", async () => { | |
| 3634 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "@pack"); | |
| 3635 | ||
| 3636 | const mounted = await mountChatView({ | |
| 3637 | viewport: DEFAULT_VIEWPORT, | |
| 3638 | snapshot: createSnapshotForTargetUser({ | |
| 3639 | targetMessageId: "msg-user-file-tag-encoding" as MessageId, | |
| 3640 | targetText: "file tag encoding", | |
| 3641 | }), | |
| 3642 | resolveRpc: (body) => { | |
| 3643 | if (body._tag !== WS_METHODS.projectsSearchEntries) { | |
| 3644 | return undefined; | |
| 3645 | } | |
| 3646 | return { | |
| 3647 | entries: [ | |
| 3648 | { | |
| 3649 | path: "path/to/package.json", | |
| 3650 | kind: "file", | |
| 3651 | parentPath: "path/to", | |
| 3652 | }, | |
| 3653 | ], | |
| 3654 | truncated: false, | |
| 3655 | }; | |
| 3656 | }, | |
| 3657 | }); | |
| 3658 | ||
| 3659 | try { | |
| 3660 | const item = await waitForComposerMenuItem("path:file:path/to/package.json"); | |
| 3661 | item.click(); | |
| 3662 | ||
| 3663 | await waitForComposerText("[package.json](path/to/package.json) "); | |
| 3664 | const chip = await waitForElement( | |
| 3665 | () => document.querySelector<HTMLElement>('[data-composer-mention-chip="true"]'), | |
| 3666 | "Unable to find rendered composer file chip.", | |
| 3667 | ); | |
| 3668 | expect(chip.textContent).toContain("package.json"); | |
| 3669 | } finally { | |
| 3670 | await mounted.cleanup(); | |
| 3671 | } | |
| 3672 | }); | |
| 3673 | ||
| 3628 | 3674 | it("shows runtime mode descriptions in the desktop composer access select", async () => { |
| 3629 | 3675 | setDraftThreadWithoutWorktree(); |
| 3630 | 3676 |
apps/web/src/components/CommandPalette.tsx+70-53| ... | ... | @@ -1570,61 +1570,78 @@ function OpenCommandPaletteDialog() { |
| 1570 | 1570 | onKeyDown={handleKeyDown} |
| 1571 | 1571 | /> |
| 1572 | 1572 | {addProjectCloneFlow?.step === "repository" ? ( |
| 1573 | <Button | |
| 1574 | variant="outline" | |
| 1575 | size="xs" | |
| 1576 | tabIndex={-1} | |
| 1577 | className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" | |
| 1578 | aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 1579 | disabled={!canSubmitRemoteProjectFlow} | |
| 1580 | onMouseDown={(event) => { | |
| 1581 | event.preventDefault(); | |
| 1582 | }} | |
| 1583 | onClick={() => { | |
| 1584 | void submitAddProjectCloneFlow(); | |
| 1585 | }} | |
| 1586 | title={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 1587 | > | |
| 1588 | <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span> | |
| 1589 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 1590 | <Kbd>Enter</Kbd> | |
| 1591 | </KbdGroup> | |
| 1592 | </Button> | |
| 1593 | ) : isBrowsing ? ( | |
| 1594 | <Button | |
| 1595 | variant="outline" | |
| 1596 | size="xs" | |
| 1597 | tabIndex={-1} | |
| 1598 | className={cn( | |
| 1599 | "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", | |
| 1600 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", | |
| 1601 | )} | |
| 1602 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} | |
| 1603 | disabled={ | |
| 1604 | relativePathNeedsActiveProject || (isCloneDestinationStep && isRemoteProjectPending) | |
| 1605 | } | |
| 1606 | onMouseDown={(event) => { | |
| 1607 | event.preventDefault(); | |
| 1608 | }} | |
| 1609 | onClick={() => { | |
| 1610 | if (relativePathNeedsActiveProject) { | |
| 1611 | return; | |
| 1573 | <Tooltip> | |
| 1574 | <TooltipTrigger | |
| 1575 | render={ | |
| 1576 | <Button | |
| 1577 | variant="outline" | |
| 1578 | size="xs" | |
| 1579 | tabIndex={-1} | |
| 1580 | className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" | |
| 1581 | aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 1582 | disabled={!canSubmitRemoteProjectFlow} | |
| 1583 | onMouseDown={(event) => { | |
| 1584 | event.preventDefault(); | |
| 1585 | }} | |
| 1586 | onClick={() => { | |
| 1587 | void submitAddProjectCloneFlow(); | |
| 1588 | }} | |
| 1589 | /> | |
| 1612 | 1590 | } |
| 1613 | if (isCloneDestinationStep) { | |
| 1614 | void submitAddProjectCloneFlow(resolvedAddProjectPath); | |
| 1615 | } else { | |
| 1616 | void handleAddProject(resolvedAddProjectPath); | |
| 1591 | > | |
| 1592 | <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span> | |
| 1593 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 1594 | <Kbd>Enter</Kbd> | |
| 1595 | </KbdGroup> | |
| 1596 | </TooltipTrigger> | |
| 1597 | <TooltipPopup side="top"> | |
| 1598 | {remoteProjectButtonLabel ?? "Continue"} (Enter) | |
| 1599 | </TooltipPopup> | |
| 1600 | </Tooltip> | |
| 1601 | ) : isBrowsing ? ( | |
| 1602 | <Tooltip> | |
| 1603 | <TooltipTrigger | |
| 1604 | render={ | |
| 1605 | <Button | |
| 1606 | variant="outline" | |
| 1607 | size="xs" | |
| 1608 | tabIndex={-1} | |
| 1609 | className={cn( | |
| 1610 | "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", | |
| 1611 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", | |
| 1612 | )} | |
| 1613 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} | |
| 1614 | disabled={ | |
| 1615 | relativePathNeedsActiveProject || | |
| 1616 | (isCloneDestinationStep && isRemoteProjectPending) | |
| 1617 | } | |
| 1618 | onMouseDown={(event) => { | |
| 1619 | event.preventDefault(); | |
| 1620 | }} | |
| 1621 | onClick={() => { | |
| 1622 | if (relativePathNeedsActiveProject) { | |
| 1623 | return; | |
| 1624 | } | |
| 1625 | if (isCloneDestinationStep) { | |
| 1626 | void submitAddProjectCloneFlow(resolvedAddProjectPath); | |
| 1627 | } else { | |
| 1628 | void handleAddProject(resolvedAddProjectPath); | |
| 1629 | } | |
| 1630 | }} | |
| 1631 | /> | |
| 1617 | 1632 | } |
| 1618 | }} | |
| 1619 | title={`${submitActionLabel} (${addShortcutLabel})`} | |
| 1620 | > | |
| 1621 | <span> | |
| 1622 | {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} | |
| 1623 | </span> | |
| 1624 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 1625 | <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> | |
| 1626 | </KbdGroup> | |
| 1627 | </Button> | |
| 1633 | > | |
| 1634 | <span> | |
| 1635 | {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} | |
| 1636 | </span> | |
| 1637 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 1638 | <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> | |
| 1639 | </KbdGroup> | |
| 1640 | </TooltipTrigger> | |
| 1641 | <TooltipPopup side="top"> | |
| 1642 | {submitActionLabel} ({addShortcutLabel}) | |
| 1643 | </TooltipPopup> | |
| 1644 | </Tooltip> | |
| 1628 | 1645 | ) : null} |
| 1629 | 1646 | </div> |
| 1630 | 1647 | <CommandPanel className="max-h-[min(28rem,70vh)]"> |
apps/web/src/components/ComposerPromptEditor.tsx+7-15| ... | ... | @@ -6,7 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; |
| 6 | 6 | import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; |
| 7 | 7 | import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; |
| 8 | 8 | import { type ServerProviderSkill } from "@t3tools/contracts"; |
| 9 | import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger"; | |
| 9 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | |
| 10 | 10 | import { |
| 11 | 11 | $applyNodeReplacement, |
| 12 | 12 | $createRangeSelection, |
| ... | ... | @@ -64,14 +64,14 @@ import { |
| 64 | 64 | type TerminalContextDraft, |
| 65 | 65 | } from "~/lib/terminalContext"; |
| 66 | 66 | import { cn } from "~/lib/utils"; |
| 67 | import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; | |
| 67 | import { basenameOfPath } from "~/vscode-icons"; | |
| 68 | 68 | import { |
| 69 | COMPOSER_INLINE_CHIP_CLASS_NAME, | |
| 70 | 69 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, |
| 71 | 70 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, |
| 72 | 71 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, |
| 73 | 72 | SKILL_CHIP_ICON_SVG, |
| 74 | 73 | } from "./composerInlineChip"; |
| 74 | import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; | |
| 75 | 75 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 76 | 76 | import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; |
| 77 | 77 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | ... | @@ -132,19 +132,12 @@ function ComposerMentionDecorator(props: { path: string }) { |
| 132 | 132 | const theme = resolvedThemeFromDocument(); |
| 133 | 133 | const chip = ( |
| 134 | 134 | <span |
| 135 | className={COMPOSER_INLINE_CHIP_CLASS_NAME} | |
| 135 | className={FILE_TAG_CHIP_CLASS_NAME} | |
| 136 | 136 | contentEditable={false} |
| 137 | 137 | spellCheck={false} |
| 138 | 138 | data-composer-mention-chip="true" |
| 139 | 139 | > |
| 140 | <img | |
| 141 | alt="" | |
| 142 | aria-hidden="true" | |
| 143 | className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} | |
| 144 | loading="lazy" | |
| 145 | src={getVscodeIconUrlForEntry(props.path, inferEntryKindFromPath(props.path), theme)} | |
| 146 | /> | |
| 147 | <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{basenameOfPath(props.path)}</span> | |
| 140 | <FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} /> | |
| 148 | 141 | </span> |
| 149 | 142 | ); |
| 150 | 143 | |
| ... | ... | @@ -175,8 +168,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> { |
| 175 | 168 | |
| 176 | 169 | constructor(path: string, key?: NodeKey) { |
| 177 | 170 | super(key); |
| 178 | const normalizedPath = path.startsWith("@") ? path.slice(1) : path; | |
| 179 | this.__path = normalizedPath; | |
| 171 | this.__path = path; | |
| 180 | 172 | } |
| 181 | 173 | |
| 182 | 174 | override exportJSON(): SerializedComposerMentionNode { |
| ... | ... | @@ -199,7 +191,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> { |
| 199 | 191 | } |
| 200 | 192 | |
| 201 | 193 | override getTextContent(): string { |
| 202 | return `@${serializeComposerMentionPath(this.__path)}`; | |
| 194 | return serializeComposerFileLink(this.__path); | |
| 203 | 195 | } |
| 204 | 196 | |
| 205 | 197 | override isInline(): true { |
apps/web/src/components/DiffPanel.tsx+108-71| ... | ... | @@ -42,6 +42,7 @@ import { useSettings } from "../hooks/useSettings"; |
| 42 | 42 | import { formatShortTimestamp } from "../timestampFormat"; |
| 43 | 43 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| 44 | 44 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | |
| 45 | 46 | |
| 46 | 47 | type DiffRenderMode = "stacked" | "split"; |
| 47 | 48 | type DiffThemeType = "light" | "dark"; |
| ... | ... | @@ -460,35 +461,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 460 | 461 | </div> |
| 461 | 462 | </button> |
| 462 | 463 | {orderedTurnDiffSummaries.map((summary) => ( |
| 463 | <button | |
| 464 | key={summary.turnId} | |
| 465 | type="button" | |
| 466 | className="shrink-0 rounded-md" | |
| 467 | onClick={() => selectTurn(summary.turnId)} | |
| 468 | title={summary.turnId} | |
| 469 | data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} | |
| 470 | > | |
| 471 | <div | |
| 472 | className={cn( | |
| 473 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 474 | summary.turnId === selectedTurn?.turnId | |
| 475 | ? "border-border bg-accent text-accent-foreground" | |
| 476 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 477 | )} | |
| 464 | <Tooltip key={summary.turnId}> | |
| 465 | <TooltipTrigger | |
| 466 | render={ | |
| 467 | <button | |
| 468 | type="button" | |
| 469 | aria-label={summary.turnId} | |
| 470 | className="shrink-0 rounded-md" | |
| 471 | onClick={() => selectTurn(summary.turnId)} | |
| 472 | data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} | |
| 473 | /> | |
| 474 | } | |
| 478 | 475 | > |
| 479 | <div className="flex items-center gap-1"> | |
| 480 | <span className="text-[10px] leading-tight font-medium"> | |
| 481 | Turn{" "} | |
| 482 | {summary.checkpointTurnCount ?? | |
| 483 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | |
| 484 | "?"} | |
| 485 | </span> | |
| 486 | <span className="text-[9px] leading-tight opacity-70"> | |
| 487 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} | |
| 488 | </span> | |
| 476 | <div | |
| 477 | className={cn( | |
| 478 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 479 | summary.turnId === selectedTurn?.turnId | |
| 480 | ? "border-border bg-accent text-accent-foreground" | |
| 481 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 482 | )} | |
| 483 | > | |
| 484 | <div className="flex items-center gap-1"> | |
| 485 | <span className="text-[10px] leading-tight font-medium"> | |
| 486 | Turn{" "} | |
| 487 | {summary.checkpointTurnCount ?? | |
| 488 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | |
| 489 | "?"} | |
| 490 | </span> | |
| 491 | <span className="text-[9px] leading-tight opacity-70"> | |
| 492 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} | |
| 493 | </span> | |
| 494 | </div> | |
| 489 | 495 | </div> |
| 490 | </div> | |
| 491 | </button> | |
| 496 | </TooltipTrigger> | |
| 497 | <TooltipPopup side="top">{summary.turnId}</TooltipPopup> | |
| 498 | </Tooltip> | |
| 492 | 499 | ))} |
| 493 | 500 | </div> |
| 494 | 501 | </div> |
| ... | ... | @@ -512,30 +519,50 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 512 | 519 | <Columns2Icon className="size-3" /> |
| 513 | 520 | </Toggle> |
| 514 | 521 | </ToggleGroup> |
| 515 | <Toggle | |
| 516 | aria-label={diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"} | |
| 517 | title={diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} | |
| 518 | variant="outline" | |
| 519 | size="xs" | |
| 520 | pressed={diffWordWrap} | |
| 521 | onPressedChange={(pressed) => { | |
| 522 | setDiffWordWrap(Boolean(pressed)); | |
| 523 | }} | |
| 524 | > | |
| 525 | <TextWrapIcon className="size-3" /> | |
| 526 | </Toggle> | |
| 527 | <Toggle | |
| 528 | aria-label={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} | |
| 529 | title={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} | |
| 530 | variant="outline" | |
| 531 | size="xs" | |
| 532 | pressed={diffIgnoreWhitespace} | |
| 533 | onPressedChange={(pressed) => { | |
| 534 | setDiffIgnoreWhitespace(Boolean(pressed)); | |
| 535 | }} | |
| 536 | > | |
| 537 | <PilcrowIcon className="size-3" /> | |
| 538 | </Toggle> | |
| 522 | <Tooltip> | |
| 523 | <TooltipTrigger | |
| 524 | render={ | |
| 525 | <Toggle | |
| 526 | aria-label={ | |
| 527 | diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping" | |
| 528 | } | |
| 529 | variant="outline" | |
| 530 | size="xs" | |
| 531 | pressed={diffWordWrap} | |
| 532 | onPressedChange={(pressed) => { | |
| 533 | setDiffWordWrap(Boolean(pressed)); | |
| 534 | }} | |
| 535 | /> | |
| 536 | } | |
| 537 | > | |
| 538 | <TextWrapIcon className="size-3" /> | |
| 539 | </TooltipTrigger> | |
| 540 | <TooltipPopup side="top"> | |
| 541 | {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} | |
| 542 | </TooltipPopup> | |
| 543 | </Tooltip> | |
| 544 | <Tooltip> | |
| 545 | <TooltipTrigger | |
| 546 | render={ | |
| 547 | <Toggle | |
| 548 | aria-label={ | |
| 549 | diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes" | |
| 550 | } | |
| 551 | variant="outline" | |
| 552 | size="xs" | |
| 553 | pressed={diffIgnoreWhitespace} | |
| 554 | onPressedChange={(pressed) => { | |
| 555 | setDiffIgnoreWhitespace(Boolean(pressed)); | |
| 556 | }} | |
| 557 | /> | |
| 558 | } | |
| 559 | > | |
| 560 | <PilcrowIcon className="size-3" /> | |
| 561 | </TooltipTrigger> | |
| 562 | <TooltipPopup side="top"> | |
| 563 | {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} | |
| 564 | </TooltipPopup> | |
| 565 | </Tooltip> | |
| 539 | 566 | </div> |
| 540 | 567 | </> |
| 541 | 568 | ); |
| ... | ... | @@ -609,26 +636,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 609 | 636 | <FileDiff |
| 610 | 637 | fileDiff={fileDiff} |
| 611 | 638 | renderHeaderPrefix={() => ( |
| 612 | <button | |
| 613 | type="button" | |
| 614 | className={cn( | |
| 615 | "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden", | |
| 616 | getDiffCollapseIconClassName(fileDiff), | |
| 617 | )} | |
| 618 | aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`} | |
| 619 | aria-expanded={!collapsed} | |
| 620 | title={collapsed ? "Expand diff" : "Collapse diff"} | |
| 621 | onClick={(event) => { | |
| 622 | event.stopPropagation(); | |
| 623 | toggleDiffFileCollapsed(fileKey); | |
| 624 | }} | |
| 625 | > | |
| 626 | {collapsed ? ( | |
| 627 | <ChevronRightIcon className="size-4" /> | |
| 628 | ) : ( | |
| 629 | <ChevronDownIcon className="size-4" /> | |
| 630 | )} | |
| 631 | </button> | |
| 639 | <Tooltip> | |
| 640 | <TooltipTrigger | |
| 641 | render={ | |
| 642 | <button | |
| 643 | type="button" | |
| 644 | className={cn( | |
| 645 | "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden", | |
| 646 | getDiffCollapseIconClassName(fileDiff), | |
| 647 | )} | |
| 648 | aria-label={ | |
| 649 | collapsed ? `Expand ${filePath}` : `Collapse ${filePath}` | |
| 650 | } | |
| 651 | aria-expanded={!collapsed} | |
| 652 | onClick={(event) => { | |
| 653 | event.stopPropagation(); | |
| 654 | toggleDiffFileCollapsed(fileKey); | |
| 655 | }} | |
| 656 | /> | |
| 657 | } | |
| 658 | > | |
| 659 | {collapsed ? ( | |
| 660 | <ChevronRightIcon className="size-4" /> | |
| 661 | ) : ( | |
| 662 | <ChevronDownIcon className="size-4" /> | |
| 663 | )} | |
| 664 | </TooltipTrigger> | |
| 665 | <TooltipPopup side="top"> | |
| 666 | {collapsed ? "Expand diff" : "Collapse diff"} | |
| 667 | </TooltipPopup> | |
| 668 | </Tooltip> | |
| 632 | 669 | )} |
| 633 | 670 | options={{ |
| 634 | 671 | collapsed, |
apps/web/src/components/ProjectScriptsControl.tsx+32-17| ... | ... | @@ -53,6 +53,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 53 | 53 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 54 | 54 | import { Switch } from "./ui/switch"; |
| 55 | 55 | import { Textarea } from "./ui/textarea"; |
| 56 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | |
| 56 | 57 | |
| 57 | 58 | const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ |
| 58 | 59 | { id: "play", label: "Play" }, |
| ... | ... | @@ -219,17 +220,24 @@ export default function ProjectScriptsControl({ |
| 219 | 220 | <> |
| 220 | 221 | {primaryScript ? ( |
| 221 | 222 | <Group aria-label="Project scripts"> |
| 222 | <Button | |
| 223 | size="xs" | |
| 224 | variant="outline" | |
| 225 | onClick={() => onRunScript(primaryScript)} | |
| 226 | title={`Run ${primaryScript.name}`} | |
| 227 | > | |
| 228 | <ScriptIcon icon={primaryScript.icon} /> | |
| 229 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | |
| 230 | {primaryScript.name} | |
| 231 | </span> | |
| 232 | </Button> | |
| 223 | <Tooltip> | |
| 224 | <TooltipTrigger | |
| 225 | render={ | |
| 226 | <Button | |
| 227 | size="xs" | |
| 228 | variant="outline" | |
| 229 | aria-label={`Run ${primaryScript.name}`} | |
| 230 | onClick={() => onRunScript(primaryScript)} | |
| 231 | /> | |
| 232 | } | |
| 233 | > | |
| 234 | <ScriptIcon icon={primaryScript.icon} /> | |
| 235 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | |
| 236 | {primaryScript.name} | |
| 237 | </span> | |
| 238 | </TooltipTrigger> | |
| 239 | <TooltipPopup side="top">Run {primaryScript.name}</TooltipPopup> | |
| 240 | </Tooltip> | |
| 233 | 241 | <GroupSeparator className="hidden @3xl/header-actions:block" /> |
| 234 | 242 | <Menu highlightItemOnHover={false}> |
| 235 | 243 | <MenuTrigger |
| ... | ... | @@ -289,12 +297,19 @@ export default function ProjectScriptsControl({ |
| 289 | 297 | </Menu> |
| 290 | 298 | </Group> |
| 291 | 299 | ) : ( |
| 292 | <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action"> | |
| 293 | <PlusIcon className="size-3.5" /> | |
| 294 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | |
| 295 | Add action | |
| 296 | </span> | |
| 297 | </Button> | |
| 300 | <Tooltip> | |
| 301 | <TooltipTrigger | |
| 302 | render={ | |
| 303 | <Button size="xs" variant="outline" aria-label="Add action" onClick={openAddDialog} /> | |
| 304 | } | |
| 305 | > | |
| 306 | <PlusIcon className="size-3.5" /> | |
| 307 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | |
| 308 | Add action | |
| 309 | </span> | |
| 310 | </TooltipTrigger> | |
| 311 | <TooltipPopup side="top">Add action</TooltipPopup> | |
| 312 | </Tooltip> | |
| 298 | 313 | )} |
| 299 | 314 | |
| 300 | 315 | <Dialog |
apps/web/src/components/Sidebar.tsx+47-28| ... | ... | @@ -608,14 +608,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 608 | 608 | </div> |
| 609 | 609 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> |
| 610 | 610 | {terminalStatus && ( |
| 611 | <span | |
| 612 | role="img" | |
| 613 | aria-label={terminalStatus.label} | |
| 614 | title={terminalStatus.label} | |
| 615 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | |
| 616 | > | |
| 617 | <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> | |
| 618 | </span> | |
| 611 | <Tooltip> | |
| 612 | <TooltipTrigger | |
| 613 | render={ | |
| 614 | <span | |
| 615 | role="img" | |
| 616 | aria-label={terminalStatus.label} | |
| 617 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | |
| 618 | /> | |
| 619 | } | |
| 620 | > | |
| 621 | <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> | |
| 622 | </TooltipTrigger> | |
| 623 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | |
| 624 | </Tooltip> | |
| 619 | 625 | )} |
| 620 | 626 | <div |
| 621 | 627 | className={`flex min-w-12 justify-end ${ |
| ... | ... | @@ -691,12 +697,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 691 | 697 | </Tooltip> |
| 692 | 698 | )} |
| 693 | 699 | {jumpLabel ? ( |
| 694 | <span | |
| 695 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | |
| 696 | title={jumpLabel} | |
| 697 | > | |
| 698 | {jumpLabel} | |
| 699 | </span> | |
| 700 | <Tooltip> | |
| 701 | <TooltipTrigger | |
| 702 | render={ | |
| 703 | <span | |
| 704 | aria-label={jumpLabel} | |
| 705 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | |
| 706 | /> | |
| 707 | } | |
| 708 | > | |
| 709 | {jumpLabel} | |
| 710 | </TooltipTrigger> | |
| 711 | <TooltipPopup side="top">{jumpLabel}</TooltipPopup> | |
| 712 | </Tooltip> | |
| 700 | 713 | ) : ( |
| 701 | 714 | <span |
| 702 | 715 | className={`text-[10px] ${ |
| ... | ... | @@ -1994,20 +2007,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1994 | 2007 | onContextMenu={handleProjectButtonContextMenu} |
| 1995 | 2008 | > |
| 1996 | 2009 | {!projectExpanded && projectStatus ? ( |
| 1997 | <span | |
| 1998 | aria-hidden="true" | |
| 1999 | title={projectStatus.label} | |
| 2000 | className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} | |
| 2001 | > | |
| 2002 | <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> | |
| 2003 | <span | |
| 2004 | className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ | |
| 2005 | projectStatus.pulse ? "animate-pulse" : "" | |
| 2006 | }`} | |
| 2007 | /> | |
| 2008 | </span> | |
| 2009 | <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" /> | |
| 2010 | </span> | |
| 2010 | <Tooltip> | |
| 2011 | <TooltipTrigger | |
| 2012 | render={ | |
| 2013 | <span | |
| 2014 | aria-label={projectStatus.label} | |
| 2015 | className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} | |
| 2016 | /> | |
| 2017 | } | |
| 2018 | > | |
| 2019 | <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> | |
| 2020 | <span | |
| 2021 | className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ | |
| 2022 | projectStatus.pulse ? "animate-pulse" : "" | |
| 2023 | }`} | |
| 2024 | /> | |
| 2025 | </span> | |
| 2026 | <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" /> | |
| 2027 | </TooltipTrigger> | |
| 2028 | <TooltipPopup side="top">{projectStatus.label}</TooltipPopup> | |
| 2029 | </Tooltip> | |
| 2011 | 2030 | ) : ( |
| 2012 | 2031 | <ChevronRightIcon |
| 2013 | 2032 | className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ |
apps/web/src/components/ThreadStatusIndicators.tsx+48-29| ... | ... | @@ -102,32 +102,45 @@ export function ThreadStatusLabel({ |
| 102 | 102 | }) { |
| 103 | 103 | if (compact) { |
| 104 | 104 | return ( |
| 105 | <span | |
| 106 | title={status.label} | |
| 107 | className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`} | |
| 105 | <Tooltip> | |
| 106 | <TooltipTrigger | |
| 107 | render={ | |
| 108 | <span | |
| 109 | aria-label={status.label} | |
| 110 | className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`} | |
| 111 | /> | |
| 112 | } | |
| 113 | > | |
| 114 | <span | |
| 115 | className={`size-[9px] rounded-full ${status.dotClass} ${ | |
| 116 | status.pulse ? "animate-pulse" : "" | |
| 117 | }`} | |
| 118 | /> | |
| 119 | </TooltipTrigger> | |
| 120 | <TooltipPopup side="top">{status.label}</TooltipPopup> | |
| 121 | </Tooltip> | |
| 122 | ); | |
| 123 | } | |
| 124 | ||
| 125 | return ( | |
| 126 | <Tooltip> | |
| 127 | <TooltipTrigger | |
| 128 | render={ | |
| 129 | <span | |
| 130 | aria-label={status.label} | |
| 131 | className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`} | |
| 132 | /> | |
| 133 | } | |
| 108 | 134 | > |
| 109 | 135 | <span |
| 110 | className={`size-[9px] rounded-full ${status.dotClass} ${ | |
| 136 | className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ | |
| 111 | 137 | status.pulse ? "animate-pulse" : "" |
| 112 | 138 | }`} |
| 113 | 139 | /> |
| 114 | <span className="sr-only">{status.label}</span> | |
| 115 | </span> | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | return ( | |
| 120 | <span | |
| 121 | title={status.label} | |
| 122 | className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`} | |
| 123 | > | |
| 124 | <span | |
| 125 | className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ | |
| 126 | status.pulse ? "animate-pulse" : "" | |
| 127 | }`} | |
| 128 | /> | |
| 129 | <span className="hidden md:inline">{status.label}</span> | |
| 130 | </span> | |
| 140 | <span className="hidden md:inline">{status.label}</span> | |
| 141 | </TooltipTrigger> | |
| 142 | <TooltipPopup side="top">{status.label}</TooltipPopup> | |
| 143 | </Tooltip> | |
| 131 | 144 | ); |
| 132 | 145 | } |
| 133 | 146 | |
| ... | ... | @@ -220,14 +233,20 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma |
| 220 | 233 | return ( |
| 221 | 234 | <span className="inline-flex shrink-0 items-center gap-1.5"> |
| 222 | 235 | {terminalStatus ? ( |
| 223 | <span | |
| 224 | role="img" | |
| 225 | aria-label={terminalStatus.label} | |
| 226 | title={terminalStatus.label} | |
| 227 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | |
| 228 | > | |
| 229 | <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> | |
| 230 | </span> | |
| 236 | <Tooltip> | |
| 237 | <TooltipTrigger | |
| 238 | render={ | |
| 239 | <span | |
| 240 | role="img" | |
| 241 | aria-label={terminalStatus.label} | |
| 242 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | |
| 243 | /> | |
| 244 | } | |
| 245 | > | |
| 246 | <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> | |
| 247 | </TooltipTrigger> | |
| 248 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | |
| 249 | </Tooltip> | |
| 231 | 250 | ) : null} |
| 232 | 251 | {isRemoteThread ? ( |
| 233 | 252 | <Tooltip> |
apps/web/src/components/chat/ChatComposer.tsx+91-72| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 18 | 18 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger"; | |
| 20 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | |
| 21 | 21 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 22 | 22 | import { |
| 23 | 23 | memo, |
| ... | ... | @@ -187,6 +187,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 187 | 187 | }) { |
| 188 | 188 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; |
| 189 | 189 | const RuntimeModeIcon = runtimeModeOption.icon; |
| 190 | const interactionModeTooltip = | |
| 191 | props.interactionMode === "plan" | |
| 192 | ? "Plan mode — click to return to normal build mode" | |
| 193 | : "Default mode — click to enter plan mode"; | |
| 194 | const planSidebarTooltip = props.planSidebarOpen | |
| 195 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` | |
| 196 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; | |
| 190 | 197 | |
| 191 | 198 | return ( |
| 192 | 199 | <> |
| ... | ... | @@ -194,86 +201,98 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 194 | 201 | |
| 195 | 202 | {props.showInteractionModeToggle ? ( |
| 196 | 203 | <> |
| 197 | <Button | |
| 198 | variant="ghost" | |
| 199 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 200 | size="sm" | |
| 201 | type="button" | |
| 202 | onClick={props.onToggleInteractionMode} | |
| 203 | title={ | |
| 204 | props.interactionMode === "plan" | |
| 205 | ? "Plan mode — click to return to normal build mode" | |
| 206 | : "Default mode — click to enter plan mode" | |
| 207 | } | |
| 208 | > | |
| 209 | <BotIcon /> | |
| 210 | <span className="sr-only sm:not-sr-only"> | |
| 211 | {props.interactionMode === "plan" ? "Plan" : "Build"} | |
| 212 | </span> | |
| 213 | </Button> | |
| 204 | <Tooltip> | |
| 205 | <TooltipTrigger | |
| 206 | render={ | |
| 207 | <Button | |
| 208 | variant="ghost" | |
| 209 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 210 | size="sm" | |
| 211 | type="button" | |
| 212 | onClick={props.onToggleInteractionMode} | |
| 213 | aria-label={interactionModeTooltip} | |
| 214 | /> | |
| 215 | } | |
| 216 | > | |
| 217 | <BotIcon /> | |
| 218 | <span className="sr-only sm:not-sr-only"> | |
| 219 | {props.interactionMode === "plan" ? "Plan" : "Build"} | |
| 220 | </span> | |
| 221 | </TooltipTrigger> | |
| 222 | <TooltipPopup side="top">{interactionModeTooltip}</TooltipPopup> | |
| 223 | </Tooltip> | |
| 214 | 224 | |
| 215 | 225 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 216 | 226 | </> |
| 217 | 227 | ) : null} |
| 218 | 228 | |
| 219 | <Select | |
| 220 | value={props.runtimeMode} | |
| 221 | onValueChange={(value) => props.onRuntimeModeChange(value!)} | |
| 222 | > | |
| 223 | <SelectTrigger | |
| 224 | variant="ghost" | |
| 225 | size="sm" | |
| 226 | className="font-medium" | |
| 227 | aria-label="Runtime mode" | |
| 228 | title={runtimeModeOption.description} | |
| 229 | <Tooltip> | |
| 230 | <Select | |
| 231 | value={props.runtimeMode} | |
| 232 | onValueChange={(value) => props.onRuntimeModeChange(value!)} | |
| 229 | 233 | > |
| 230 | <RuntimeModeIcon className="size-4" /> | |
| 231 | <SelectValue>{runtimeModeOption.label}</SelectValue> | |
| 232 | </SelectTrigger> | |
| 233 | <SelectPopup alignItemWithTrigger={false}> | |
| 234 | {runtimeModeOptions.map((mode) => { | |
| 235 | const option = runtimeModeConfig[mode]; | |
| 236 | const OptionIcon = option.icon; | |
| 237 | return ( | |
| 238 | <SelectItem key={mode} value={mode} className="min-w-64 py-2"> | |
| 239 | <div className="grid min-w-0 gap-0.5"> | |
| 240 | <span className="inline-flex items-center gap-1.5 font-medium text-foreground"> | |
| 241 | <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 242 | {option.label} | |
| 243 | </span> | |
| 244 | <span className="text-muted-foreground text-xs leading-4"> | |
| 245 | {option.description} | |
| 246 | </span> | |
| 247 | </div> | |
| 248 | </SelectItem> | |
| 249 | ); | |
| 250 | })} | |
| 251 | </SelectPopup> | |
| 252 | </Select> | |
| 234 | <TooltipTrigger | |
| 235 | render={ | |
| 236 | <SelectTrigger | |
| 237 | variant="ghost" | |
| 238 | size="sm" | |
| 239 | className="font-medium" | |
| 240 | aria-label="Runtime mode" | |
| 241 | /> | |
| 242 | } | |
| 243 | > | |
| 244 | <RuntimeModeIcon className="size-4" /> | |
| 245 | <SelectValue>{runtimeModeOption.label}</SelectValue> | |
| 246 | </TooltipTrigger> | |
| 247 | <SelectPopup alignItemWithTrigger={false}> | |
| 248 | {runtimeModeOptions.map((mode) => { | |
| 249 | const option = runtimeModeConfig[mode]; | |
| 250 | const OptionIcon = option.icon; | |
| 251 | return ( | |
| 252 | <SelectItem key={mode} value={mode} className="min-w-64 py-2"> | |
| 253 | <div className="grid min-w-0 gap-0.5"> | |
| 254 | <span className="inline-flex items-center gap-1.5 font-medium text-foreground"> | |
| 255 | <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 256 | {option.label} | |
| 257 | </span> | |
| 258 | <span className="text-muted-foreground text-xs leading-4"> | |
| 259 | {option.description} | |
| 260 | </span> | |
| 261 | </div> | |
| 262 | </SelectItem> | |
| 263 | ); | |
| 264 | })} | |
| 265 | </SelectPopup> | |
| 266 | </Select> | |
| 267 | <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup> | |
| 268 | </Tooltip> | |
| 253 | 269 | |
| 254 | 270 | {props.showPlanToggle ? ( |
| 255 | 271 | <> |
| 256 | 272 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 257 | <Button | |
| 258 | variant="ghost" | |
| 259 | className={cn( | |
| 260 | "shrink-0 whitespace-nowrap px-2 sm:px-3", | |
| 261 | props.planSidebarOpen | |
| 262 | ? "text-blue-400 hover:text-blue-300" | |
| 263 | : "text-muted-foreground/70 hover:text-foreground/80", | |
| 264 | )} | |
| 265 | size="sm" | |
| 266 | type="button" | |
| 267 | onClick={props.onTogglePlanSidebar} | |
| 268 | title={ | |
| 269 | props.planSidebarOpen | |
| 270 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` | |
| 271 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar` | |
| 272 | } | |
| 273 | > | |
| 274 | <ListTodoIcon /> | |
| 275 | <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> | |
| 276 | </Button> | |
| 273 | <Tooltip> | |
| 274 | <TooltipTrigger | |
| 275 | render={ | |
| 276 | <Button | |
| 277 | variant="ghost" | |
| 278 | className={cn( | |
| 279 | "shrink-0 whitespace-nowrap px-2 sm:px-3", | |
| 280 | props.planSidebarOpen | |
| 281 | ? "text-blue-400 hover:text-blue-300" | |
| 282 | : "text-muted-foreground/70 hover:text-foreground/80", | |
| 283 | )} | |
| 284 | size="sm" | |
| 285 | type="button" | |
| 286 | onClick={props.onTogglePlanSidebar} | |
| 287 | aria-label={planSidebarTooltip} | |
| 288 | /> | |
| 289 | } | |
| 290 | > | |
| 291 | <ListTodoIcon /> | |
| 292 | <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> | |
| 293 | </TooltipTrigger> | |
| 294 | <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup> | |
| 295 | </Tooltip> | |
| 277 | 296 | </> |
| 278 | 297 | ) : null} |
| 279 | 298 | </> |
| ... | ... | @@ -1472,7 +1491,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1472 | 1491 | const { snapshot, trigger } = resolveActiveComposerTrigger(); |
| 1473 | 1492 | if (!trigger) return; |
| 1474 | 1493 | if (item.type === "path") { |
| 1475 | const replacement = `@${serializeComposerMentionPath(item.path)} `; | |
| 1494 | const replacement = `${serializeComposerFileLink(item.path)} `; | |
| 1476 | 1495 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| 1477 | 1496 | snapshot.value, |
| 1478 | 1497 | trigger.rangeEnd, |
apps/web/src/components/chat/ChatHeader.tsx+13-6| ... | ... | @@ -92,12 +92,19 @@ export const ChatHeader = memo(function ChatHeader({ |
| 92 | 92 | <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| 93 | 93 | <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3"> |
| 94 | 94 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> |
| 95 | <h2 | |
| 96 | className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground" | |
| 97 | title={activeThreadTitle} | |
| 98 | > | |
| 99 | {activeThreadTitle} | |
| 100 | </h2> | |
| 95 | <Tooltip> | |
| 96 | <TooltipTrigger | |
| 97 | render={ | |
| 98 | <h2 | |
| 99 | aria-label={activeThreadTitle} | |
| 100 | className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground" | |
| 101 | > | |
| 102 | {activeThreadTitle} | |
| 103 | </h2> | |
| 104 | } | |
| 105 | /> | |
| 106 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> | |
| 107 | </Tooltip> | |
| 101 | 108 | {activeProjectName && ( |
| 102 | 109 | <Badge |
| 103 | 110 | variant="outline" |
apps/web/src/components/chat/FileTagChip.tsx created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import { inferEntryKindFromPath } from "../../vscode-icons"; | |
| 2 | import { | |
| 3 | CHAT_INLINE_CHIP_CLASS_NAME, | |
| 4 | CHAT_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 5 | COMPOSER_INLINE_CHIP_CLASS_NAME, | |
| 6 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | |
| 7 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 8 | } from "../composerInlineChip"; | |
| 9 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; | |
| 10 | ||
| 11 | export const FILE_TAG_CHIP_CLASS_NAME = COMPOSER_INLINE_CHIP_CLASS_NAME; | |
| 12 | export const CHAT_FILE_TAG_CHIP_CLASS_NAME = CHAT_INLINE_CHIP_CLASS_NAME; | |
| 13 | ||
| 14 | export function FileTagChipContent(props: { | |
| 15 | path: string; | |
| 16 | label: string; | |
| 17 | theme: "light" | "dark"; | |
| 18 | selectable?: boolean; | |
| 19 | }) { | |
| 20 | return ( | |
| 21 | <> | |
| 22 | <VscodeEntryIcon | |
| 23 | pathValue={props.path} | |
| 24 | kind={inferEntryKindFromPath(props.path)} | |
| 25 | theme={props.theme} | |
| 26 | className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} | |
| 27 | /> | |
| 28 | <span | |
| 29 | className={ | |
| 30 | props.selectable | |
| 31 | ? CHAT_INLINE_CHIP_LABEL_CLASS_NAME | |
| 32 | : COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME | |
| 33 | } | |
| 34 | > | |
| 35 | {props.label} | |
| 36 | </span> | |
| 37 | </> | |
| 38 | ); | |
| 39 | } |
apps/web/src/components/chat/MessagesTimeline.browser.tsx+125| ... | ... | @@ -94,6 +94,21 @@ function buildUserTimelineEntry(text: string) { |
| 94 | 94 | }; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | function buildAssistantTimelineEntry(text: string) { | |
| 98 | return { | |
| 99 | id: "entry-assistant-1", | |
| 100 | kind: "message" as const, | |
| 101 | createdAt: MESSAGE_CREATED_AT, | |
| 102 | message: { | |
| 103 | id: "message-assistant-1" as never, | |
| 104 | role: "assistant" as const, | |
| 105 | text, | |
| 106 | createdAt: MESSAGE_CREATED_AT, | |
| 107 | streaming: false, | |
| 108 | }, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 97 | 112 | describe("MessagesTimeline", () => { |
| 98 | 113 | afterEach(() => { |
| 99 | 114 | scrollToEndSpy.mockReset(); |
| ... | ... | @@ -128,6 +143,59 @@ describe("MessagesTimeline", () => { |
| 128 | 143 | .element(page.getByText("Send a message to start the conversation.")) |
| 129 | 144 | .not.toBeInTheDocument(); |
| 130 | 145 | await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); |
| 146 | expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull(); | |
| 147 | } finally { | |
| 148 | await screen.unmount(); | |
| 149 | } | |
| 150 | }); | |
| 151 | ||
| 152 | it("uses accessible tooltips instead of native titles for work entry details", async () => { | |
| 153 | const screen = await render( | |
| 154 | <MessagesTimeline | |
| 155 | {...buildProps()} | |
| 156 | workspaceRoot="/repo" | |
| 157 | timelineEntries={[ | |
| 158 | { | |
| 159 | id: "work-command", | |
| 160 | kind: "work", | |
| 161 | createdAt: MESSAGE_CREATED_AT, | |
| 162 | entry: { | |
| 163 | id: "work-command", | |
| 164 | createdAt: MESSAGE_CREATED_AT, | |
| 165 | label: "command", | |
| 166 | detail: "Inspecting generated output", | |
| 167 | command: "git diff -- apps/web/src/components/ChatMarkdown.tsx", | |
| 168 | rawCommand: "git diff -- apps/web/src/components/ChatMarkdown.tsx --stat", | |
| 169 | changedFiles: ["/repo/apps/web/src/components/ChatMarkdown.tsx"], | |
| 170 | tone: "tool", | |
| 171 | }, | |
| 172 | }, | |
| 173 | ]} | |
| 174 | />, | |
| 175 | ); | |
| 176 | ||
| 177 | try { | |
| 178 | expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull(); | |
| 179 | ||
| 180 | const commandTrigger = page.getByLabelText( | |
| 181 | "Command - git diff -- apps/web/src/components/ChatMarkdown.tsx", | |
| 182 | ); | |
| 183 | await commandTrigger.hover(); | |
| 184 | await vi.waitFor(() => { | |
| 185 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | |
| 186 | expect(tooltip?.textContent).toContain( | |
| 187 | "git diff -- apps/web/src/components/ChatMarkdown.tsx --stat", | |
| 188 | ); | |
| 189 | }); | |
| 190 | ||
| 191 | const fileTrigger = page.getByLabelText("repo/apps/web/src/components/ChatMarkdown.tsx", { | |
| 192 | exact: true, | |
| 193 | }); | |
| 194 | await fileTrigger.hover(); | |
| 195 | await vi.waitFor(() => { | |
| 196 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | |
| 197 | expect(tooltip?.textContent).toContain("apps/web/src/components/ChatMarkdown.tsx"); | |
| 198 | }); | |
| 131 | 199 | } finally { |
| 132 | 200 | await screen.unmount(); |
| 133 | 201 | } |
| ... | ... | @@ -261,4 +329,61 @@ describe("MessagesTimeline", () => { |
| 261 | 329 | await screen.unmount(); |
| 262 | 330 | } |
| 263 | 331 | }); |
| 332 | ||
| 333 | it("renders user messages as markdown with chat-style line breaks", async () => { | |
| 334 | const screen = await render( | |
| 335 | <MessagesTimeline | |
| 336 | {...buildProps()} | |
| 337 | timelineEntries={[ | |
| 338 | buildUserTimelineEntry( | |
| 339 | "## Plan\nuse **bold** and [a link](https://example.com)\nsecond line", | |
| 340 | ), | |
| 341 | ]} | |
| 342 | />, | |
| 343 | ); | |
| 344 | ||
| 345 | try { | |
| 346 | await expect.element(page.getByRole("heading", { level: 2, name: "Plan" })).toBeVisible(); | |
| 347 | await expect | |
| 348 | .element(page.getByRole("link", { name: "a link" })) | |
| 349 | .toHaveAttribute("href", "https://example.com"); | |
| 350 | ||
| 351 | const messageBody = document.querySelector("[data-user-message-body='true']"); | |
| 352 | expect(messageBody?.querySelector("strong")?.textContent).toBe("bold"); | |
| 353 | // remark-breaks: the single newline between the inline runs is a <br>. | |
| 354 | expect(messageBody?.querySelectorAll("p br").length).toBe(1); | |
| 355 | } finally { | |
| 356 | await screen.unmount(); | |
| 357 | } | |
| 358 | }); | |
| 359 | ||
| 360 | it("renders markdown file tags in user and assistant messages", async () => { | |
| 361 | const fileLink = "[package.json](path/to/package.json)"; | |
| 362 | const screen = await render( | |
| 363 | <MessagesTimeline | |
| 364 | {...buildProps()} | |
| 365 | markdownCwd="/repo/project" | |
| 366 | timelineEntries={[ | |
| 367 | buildUserTimelineEntry(`Review ${fileLink}`), | |
| 368 | buildAssistantTimelineEntry(`I reviewed ${fileLink}`), | |
| 369 | ]} | |
| 370 | />, | |
| 371 | ); | |
| 372 | ||
| 373 | try { | |
| 374 | const userFileLink = document.querySelector( | |
| 375 | '[data-message-role="user"] .chat-markdown-file-link', | |
| 376 | ); | |
| 377 | const assistantFileLink = document.querySelector( | |
| 378 | '[data-message-role="assistant"] .chat-markdown-file-link', | |
| 379 | ); | |
| 380 | ||
| 381 | expect(userFileLink?.textContent).toContain("package.json"); | |
| 382 | expect(userFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json"); | |
| 383 | expect(assistantFileLink?.textContent).toContain("package.json"); | |
| 384 | expect(assistantFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json"); | |
| 385 | } finally { | |
| 386 | await screen.unmount(); | |
| 387 | } | |
| 388 | }); | |
| 264 | 389 | }); |
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-1| ... | ... | @@ -188,7 +188,8 @@ describe("MessagesTimeline", () => { |
| 188 | 188 | |
| 189 | 189 | expect(markup).toContain("Terminal 1 lines 1-5"); |
| 190 | 190 | expect(markup).toContain("lucide-terminal"); |
| 191 | expect(markup).toContain("yoo what&#x27;s "); | |
| 191 | expect(markup).toContain("yoo what&#x27;s</p>"); | |
| 192 | expect(markup).toContain('<span aria-hidden="true"> </span>'); | |
| 192 | 193 | expect(markup).toContain("Show full message"); |
| 193 | 194 | }, 20_000); |
| 194 | 195 |
apps/web/src/components/chat/MessagesTimeline.tsx+124-70| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | use, |
| 11 | 11 | useCallback, |
| 12 | 12 | useEffect, |
| 13 | Fragment, | |
| 13 | 14 | useMemo, |
| 14 | 15 | useRef, |
| 15 | 16 | useState, |
| ... | ... | @@ -380,6 +381,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 380 | 381 | text={displayedUserMessage.visibleText} |
| 381 | 382 | terminalContexts={terminalContexts} |
| 382 | 383 | skills={ctx.skills} |
| 384 | markdownCwd={ctx.markdownCwd} | |
| 383 | 385 | footer={ |
| 384 | 386 | <> |
| 385 | 387 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> |
| ... | ... | @@ -404,16 +406,23 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { |
| 404 | 406 | const activity = use(TimelineRowActivityCtx); |
| 405 | 407 | |
| 406 | 408 | return ( |
| 407 | <Button | |
| 408 | type="button" | |
| 409 | size="xs" | |
| 410 | variant="outline" | |
| 411 | disabled={activity.isRevertingCheckpoint || activity.isWorking} | |
| 412 | onClick={() => ctx.onRevertUserMessage(messageId)} | |
| 413 | title="Revert to this message" | |
| 414 | > | |
| 415 | <Undo2Icon className="size-3" /> | |
| 416 | </Button> | |
| 409 | <Tooltip> | |
| 410 | <TooltipTrigger | |
| 411 | render={ | |
| 412 | <Button | |
| 413 | type="button" | |
| 414 | size="xs" | |
| 415 | variant="outline" | |
| 416 | disabled={activity.isRevertingCheckpoint || activity.isWorking} | |
| 417 | onClick={() => ctx.onRevertUserMessage(messageId)} | |
| 418 | aria-label="Revert to this message" | |
| 419 | /> | |
| 420 | } | |
| 421 | > | |
| 422 | <Undo2Icon className="size-3" /> | |
| 423 | </TooltipTrigger> | |
| 424 | <TooltipPopup side="top">Revert to this message</TooltipPopup> | |
| 425 | </Tooltip> | |
| 417 | 426 | ); |
| 418 | 427 | } |
| 419 | 428 | |
| ... | ... | @@ -781,6 +790,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop |
| 781 | 790 | text: string; |
| 782 | 791 | terminalContexts: ParsedTerminalContextEntry[]; |
| 783 | 792 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 793 | markdownCwd: string | undefined; | |
| 784 | 794 | footer?: ReactNode; |
| 785 | 795 | }) { |
| 786 | 796 | const [expanded, setExpanded] = useState(false); |
| ... | ... | @@ -810,6 +820,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop |
| 810 | 820 | text={props.text} |
| 811 | 821 | terminalContexts={props.terminalContexts} |
| 812 | 822 | skills={props.skills} |
| 823 | markdownCwd={props.markdownCwd} | |
| 813 | 824 | /> |
| 814 | 825 | </div> |
| 815 | 826 | ) : null} |
| ... | ... | @@ -847,7 +858,34 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 847 | 858 | text: string; |
| 848 | 859 | terminalContexts: ParsedTerminalContextEntry[]; |
| 849 | 860 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 861 | markdownCwd: string | undefined; | |
| 850 | 862 | }) { |
| 863 | const renderInlineMarkdownSegment = (text: string, key: string) => { | |
| 864 | const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; | |
| 865 | const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); | |
| 866 | const trailingWhitespace = /\s+$/.exec(textWithoutLeadingWhitespace)?.[0] ?? ""; | |
| 867 | const content = textWithoutLeadingWhitespace.slice( | |
| 868 | 0, | |
| 869 | textWithoutLeadingWhitespace.length - trailingWhitespace.length, | |
| 870 | ); | |
| 871 | ||
| 872 | return ( | |
| 873 | <Fragment key={key}> | |
| 874 | {leadingWhitespace ? <span aria-hidden="true">{leadingWhitespace}</span> : null} | |
| 875 | {content ? ( | |
| 876 | <ChatMarkdown | |
| 877 | text={content} | |
| 878 | cwd={props.markdownCwd} | |
| 879 | skills={props.skills} | |
| 880 | className="text-foreground" | |
| 881 | lineBreaks | |
| 882 | /> | |
| 883 | ) : null} | |
| 884 | {trailingWhitespace ? <span aria-hidden="true">{trailingWhitespace}</span> : null} | |
| 885 | </Fragment> | |
| 886 | ); | |
| 887 | }; | |
| 888 | ||
| 851 | 889 | const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); |
| 852 | 890 | if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { |
| 853 | 891 | return ( |
| ... | ... | @@ -855,8 +893,14 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 855 | 893 | {reviewCommentSegments.map((segment) => |
| 856 | 894 | segment.kind === "text" ? ( |
| 857 | 895 | segment.text.trim().length > 0 ? ( |
| 858 | <div key={segment.id} className="whitespace-pre-wrap wrap-break-word"> | |
| 859 | <SkillInlineText text={segment.text.trim()} skills={props.skills} /> | |
| 896 | <div key={segment.id} className="wrap-break-word"> | |
| 897 | <ChatMarkdown | |
| 898 | text={segment.text.trim()} | |
| 899 | cwd={props.markdownCwd} | |
| 900 | skills={props.skills} | |
| 901 | className="text-foreground" | |
| 902 | lineBreaks | |
| 903 | /> | |
| 860 | 904 | </div> |
| 861 | 905 | ) : null |
| 862 | 906 | ) : ( |
| ... | ... | @@ -887,9 +931,10 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 887 | 931 | } |
| 888 | 932 | if (matchIndex > cursor) { |
| 889 | 933 | inlineNodes.push( |
| 890 | <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}> | |
| 891 | <SkillInlineText text={props.text.slice(cursor, matchIndex)} skills={props.skills} /> | |
| 892 | </span>, | |
| 934 | renderInlineMarkdownSegment( | |
| 935 | props.text.slice(cursor, matchIndex), | |
| 936 | `user-terminal-context-inline-before:${context.header}:${cursor}`, | |
| 937 | ), | |
| 893 | 938 | ); |
| 894 | 939 | } |
| 895 | 940 | inlineNodes.push( |
| ... | ... | @@ -904,9 +949,10 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 904 | 949 | if (inlineNodes.length > 0) { |
| 905 | 950 | if (cursor < props.text.length) { |
| 906 | 951 | inlineNodes.push( |
| 907 | <span key={`user-message-terminal-context-inline-rest:${cursor}`}> | |
| 908 | <SkillInlineText text={props.text.slice(cursor)} skills={props.skills} /> | |
| 909 | </span>, | |
| 952 | renderInlineMarkdownSegment( | |
| 953 | props.text.slice(cursor), | |
| 954 | `user-message-terminal-context-inline-rest:${cursor}`, | |
| 955 | ), | |
| 910 | 956 | ); |
| 911 | 957 | } |
| 912 | 958 | |
| ... | ... | @@ -934,9 +980,14 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 934 | 980 | |
| 935 | 981 | if (props.text.length > 0) { |
| 936 | 982 | inlineNodes.push( |
| 937 | <span key="user-message-terminal-context-inline-text"> | |
| 938 | <SkillInlineText text={props.text} skills={props.skills} /> | |
| 939 | </span>, | |
| 983 | <ChatMarkdown | |
| 984 | key="user-message-terminal-context-inline-text" | |
| 985 | text={props.text} | |
| 986 | cwd={props.markdownCwd} | |
| 987 | skills={props.skills} | |
| 988 | className="text-foreground" | |
| 989 | lineBreaks | |
| 990 | />, | |
| 940 | 991 | ); |
| 941 | 992 | } else if (inlinePrefix.length === 0) { |
| 942 | 993 | return null; |
| ... | ... | @@ -954,9 +1005,13 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 954 | 1005 | } |
| 955 | 1006 | |
| 956 | 1007 | return ( |
| 957 | <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground"> | |
| 958 | <SkillInlineText text={props.text} skills={props.skills} /> | |
| 959 | </div> | |
| 1008 | <ChatMarkdown | |
| 1009 | text={props.text} | |
| 1010 | cwd={props.markdownCwd} | |
| 1011 | skills={props.skills} | |
| 1012 | className="text-foreground" | |
| 1013 | lineBreaks | |
| 1014 | /> | |
| 960 | 1015 | ); |
| 961 | 1016 | }); |
| 962 | 1017 | |
| ... | ... | @@ -1203,49 +1258,40 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 1203 | 1258 | <div className="min-w-0 flex-1 overflow-hidden"> |
| 1204 | 1259 | {rawCommand ? ( |
| 1205 | 1260 | <div className="max-w-full"> |
| 1206 | <p | |
| 1207 | className={cn( | |
| 1208 | "truncate text-xs leading-5", | |
| 1209 | workToneClass(workEntry.tone), | |
| 1210 | preview ? "text-muted-foreground/70" : "", | |
| 1211 | )} | |
| 1212 | title={displayText} | |
| 1213 | > | |
| 1214 | <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}> | |
| 1215 | {heading} | |
| 1216 | </span> | |
| 1217 | {preview && ( | |
| 1218 | <Tooltip> | |
| 1219 | <TooltipTrigger | |
| 1220 | closeDelay={0} | |
| 1221 | delay={75} | |
| 1222 | render={ | |
| 1223 | <span className="max-w-full cursor-default text-muted-foreground/55 transition-colors hover:text-muted-foreground/75 focus-visible:text-muted-foreground/75"> | |
| 1224 | {" "} | |
| 1225 | - {preview} | |
| 1226 | </span> | |
| 1227 | } | |
| 1261 | <Tooltip> | |
| 1262 | <TooltipTrigger | |
| 1263 | closeDelay={0} | |
| 1264 | delay={75} | |
| 1265 | render={ | |
| 1266 | <p | |
| 1267 | className={cn( | |
| 1268 | "truncate rounded-sm text-xs leading-5", | |
| 1269 | workToneClass(workEntry.tone), | |
| 1270 | preview ? "text-muted-foreground/70" : "", | |
| 1271 | )} | |
| 1272 | aria-label={displayText} | |
| 1228 | 1273 | /> |
| 1229 | <TooltipPopup | |
| 1230 | align="start" | |
| 1231 | className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0" | |
| 1232 | side="top" | |
| 1233 | > | |
| 1234 | <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap"> | |
| 1235 | {rawCommand} | |
| 1236 | </div> | |
| 1237 | </TooltipPopup> | |
| 1238 | </Tooltip> | |
| 1239 | )} | |
| 1240 | </p> | |
| 1274 | } | |
| 1275 | > | |
| 1276 | <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}> | |
| 1277 | {heading} | |
| 1278 | </span> | |
| 1279 | {preview && <span className="text-muted-foreground/55"> - {preview}</span>} | |
| 1280 | </TooltipTrigger> | |
| 1281 | <TooltipPopup | |
| 1282 | align="start" | |
| 1283 | className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0" | |
| 1284 | side="top" | |
| 1285 | > | |
| 1286 | <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap"> | |
| 1287 | {rawCommand} | |
| 1288 | </div> | |
| 1289 | </TooltipPopup> | |
| 1290 | </Tooltip> | |
| 1241 | 1291 | </div> |
| 1242 | 1292 | ) : ( |
| 1243 | 1293 | <Tooltip> |
| 1244 | <TooltipTrigger | |
| 1245 | className="block min-w-0 w-full text-left" | |
| 1246 | title={displayText} | |
| 1247 | aria-label={displayText} | |
| 1248 | > | |
| 1294 | <TooltipTrigger className="block min-w-0 w-full text-left" aria-label={displayText}> | |
| 1249 | 1295 | <p |
| 1250 | 1296 | className={cn( |
| 1251 | 1297 | "truncate text-[11px] leading-5", |
| ... | ... | @@ -1273,13 +1319,21 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 1273 | 1319 | {workEntry.changedFiles?.slice(0, 4).map((filePath) => { |
| 1274 | 1320 | const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); |
| 1275 | 1321 | return ( |
| 1276 | <span | |
| 1277 | key={`${workEntry.id}:${filePath}`} | |
| 1278 | className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75" | |
| 1279 | title={displayPath} | |
| 1280 | > | |
| 1281 | {displayPath} | |
| 1282 | </span> | |
| 1322 | <Tooltip key={`${workEntry.id}:${filePath}`}> | |
| 1323 | <TooltipTrigger | |
| 1324 | render={ | |
| 1325 | <span | |
| 1326 | className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75" | |
| 1327 | aria-label={displayPath} | |
| 1328 | /> | |
| 1329 | } | |
| 1330 | > | |
| 1331 | {displayPath} | |
| 1332 | </TooltipTrigger> | |
| 1333 | <TooltipPopup side="top" className="max-w-[min(40rem,calc(100vw-2rem))]"> | |
| 1334 | <span className="font-mono text-[11px] whitespace-nowrap">{displayPath}</span> | |
| 1335 | </TooltipPopup> | |
| 1336 | </Tooltip> | |
| 1283 | 1337 | ); |
| 1284 | 1338 | })} |
| 1285 | 1339 | {(workEntry.changedFiles?.length ?? 0) > 4 && ( |
apps/web/src/components/chat/ProviderStatusBanner.tsx+9-3| ... | ... | @@ -3,6 +3,7 @@ import { memo } from "react"; |
| 3 | 3 | import { InfoIcon } from "lucide-react"; |
| 4 | 4 | import { cn } from "~/lib/utils"; |
| 5 | 5 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 6 | 7 | |
| 7 | 8 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 8 | 9 | status, |
| ... | ... | @@ -39,9 +40,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 39 | 40 | <InfoIcon className="size-4 shrink-0" aria-hidden /> |
| 40 | 41 | <div className="flex min-w-0 flex-col gap-1"> |
| 41 | 42 | <div className="font-medium">{title}</div> |
| 42 | <div className="line-clamp-3 text-muted-foreground" title={message}> | |
| 43 | {message} | |
| 44 | </div> | |
| 43 | <Tooltip> | |
| 44 | <TooltipTrigger | |
| 45 | render={<div className="line-clamp-3 text-muted-foreground">{message}</div>} | |
| 46 | /> | |
| 47 | <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap"> | |
| 48 | {message} | |
| 49 | </TooltipPopup> | |
| 50 | </Tooltip> | |
| 45 | 51 | </div> |
| 46 | 52 | </div> |
| 47 | 53 | </div> |
apps/web/src/components/chat/SkillInlineText.tsx+11-6| ... | ... | @@ -3,11 +3,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | 4 | import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; |
| 5 | 5 | import { |
| 6 | CHAT_INLINE_CHIP_CLASS_NAME, | |
| 7 | CHAT_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 6 | 8 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, |
| 7 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 8 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, | |
| 9 | 9 | SKILL_CHIP_ICON_SVG, |
| 10 | 10 | } from "../composerInlineChip"; |
| 11 | import { cn } from "~/lib/utils"; | |
| 11 | 12 | |
| 12 | 13 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; |
| 13 | 14 | |
| ... | ... | @@ -70,15 +71,19 @@ export function renderSkillInlineMarkdownChildren( |
| 70 | 71 | |
| 71 | 72 | function SkillChip(props: { skill: InlineSkill; rawText: string }) { |
| 72 | 73 | return ( |
| 73 | <span className="inline-flex align-middle leading-none"> | |
| 74 | <span className="sr-only">{props.rawText}</span> | |
| 75 | <span aria-hidden="true" className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}> | |
| 74 | <span className="inline-flex align-middle leading-none" data-markdown-copy={props.rawText}> | |
| 75 | <span | |
| 76 | className={cn( | |
| 77 | CHAT_INLINE_CHIP_CLASS_NAME, | |
| 78 | "border-fuchsia-500/25 bg-fuchsia-500/12 text-fuchsia-700 dark:text-fuchsia-300", | |
| 79 | )} | |
| 80 | > | |
| 76 | 81 | <span |
| 77 | 82 | aria-hidden="true" |
| 78 | 83 | className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} |
| 79 | 84 | dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} |
| 80 | 85 | /> |
| 81 | <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}> | |
| 86 | <span className={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}> | |
| 82 | 87 | {formatProviderSkillDisplayName(props.skill)} |
| 83 | 88 | </span> |
| 84 | 89 | </span> |
apps/web/src/components/chat/ThreadErrorBanner.tsx+9-3| ... | ... | @@ -2,6 +2,7 @@ import { memo } from "react"; |
| 2 | 2 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; |
| 3 | 3 | import { Button } from "../ui/button"; |
| 4 | 4 | import { CircleAlertIcon, XIcon } from "lucide-react"; |
| 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 5 | 6 | |
| 6 | 7 | export const ThreadErrorBanner = memo(function ThreadErrorBanner({ |
| 7 | 8 | error, |
| ... | ... | @@ -15,9 +16,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ |
| 15 | 16 | <div className="pt-3 mx-auto max-w-3xl"> |
| 16 | 17 | <Alert variant="error"> |
| 17 | 18 | <CircleAlertIcon /> |
| 18 | <AlertDescription className="line-clamp-3" title={error}> | |
| 19 | {error} | |
| 20 | </AlertDescription> | |
| 19 | <Tooltip> | |
| 20 | <TooltipTrigger render={<AlertDescription className="line-clamp-3" />}> | |
| 21 | {error} | |
| 22 | </TooltipTrigger> | |
| 23 | <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap"> | |
| 24 | {error} | |
| 25 | </TooltipPopup> | |
| 26 | </Tooltip> | |
| 21 | 27 | {onDismiss && ( |
| 22 | 28 | <AlertAction> |
| 23 | 29 | <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}> |
apps/web/src/components/composerInlineChip.ts+9-3| ... | ... | @@ -1,9 +1,15 @@ |
| 1 | export const COMPOSER_INLINE_CHIP_CLASS_NAME = | |
| 2 | "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; | |
| 1 | const INLINE_CHIP_CLASS_NAME = | |
| 2 | "inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; | |
| 3 | ||
| 4 | export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME; | |
| 5 | ||
| 6 | export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`; | |
| 3 | 7 | |
| 4 | 8 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; |
| 5 | 9 | |
| 6 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight"; | |
| 10 | export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; | |
| 11 | ||
| 12 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; | |
| 7 | 13 | |
| 8 | 14 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = |
| 9 | 15 | "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; |
apps/web/src/components/settings/ConnectionsSettings.tsx+57-28| ... | ... | @@ -274,7 +274,6 @@ function ConnectionStatusDot({ |
| 274 | 274 | const dot = ( |
| 275 | 275 | <button |
| 276 | 276 | type="button" |
| 277 | title={tooltipText} | |
| 278 | 277 | aria-label={tooltipText} |
| 279 | 278 | className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden" |
| 280 | 279 | > |
| ... | ... | @@ -881,11 +880,16 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 881 | 880 | ) : null} |
| 882 | 881 | </Popover> |
| 883 | 882 | </div> |
| 884 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> | |
| 885 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} | |
| 886 | <span aria-hidden> · </span> | |
| 887 | <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" /> | |
| 888 | </p> | |
| 883 | <Tooltip> | |
| 884 | <TooltipTrigger | |
| 885 | render={<p aria-label={expiresAbsolute} className="text-xs text-muted-foreground" />} | |
| 886 | > | |
| 887 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} | |
| 888 | <span aria-hidden> · </span> | |
| 889 | <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" /> | |
| 890 | </TooltipTrigger> | |
| 891 | <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup> | |
| 892 | </Tooltip> | |
| 889 | 893 | {shareablePairingUrl === null ? ( |
| 890 | 894 | <p className="text-[11px] text-muted-foreground/70"> |
| 891 | 895 | Copy the token and pair from another client using this backend&apos;s reachable host. |
| ... | ... | @@ -898,17 +902,26 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 898 | 902 | <> |
| 899 | 903 | {shareablePairingUrl ? ( |
| 900 | 904 | <Group aria-label="Copy selected endpoint"> |
| 901 | <Button | |
| 902 | size="xs" | |
| 903 | variant="outline" | |
| 904 | className="max-w-56" | |
| 905 | title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`} | |
| 906 | onClick={handleCopyDefaultLink} | |
| 907 | > | |
| 908 | <span className="truncate"> | |
| 905 | <Tooltip> | |
| 906 | <TooltipTrigger | |
| 907 | render={ | |
| 908 | <Button | |
| 909 | size="xs" | |
| 910 | variant="outline" | |
| 911 | className="max-w-56" | |
| 912 | aria-label={`Copy pairing URL for: ${defaultEndpointCopyLabel}`} | |
| 913 | onClick={handleCopyDefaultLink} | |
| 914 | /> | |
| 915 | } | |
| 916 | > | |
| 917 | <span className="truncate"> | |
| 918 | Copy pairing URL for: {defaultEndpointCopyLabel} | |
| 919 | </span> | |
| 920 | </TooltipTrigger> | |
| 921 | <TooltipPopup side="top"> | |
| 909 | 922 | Copy pairing URL for: {defaultEndpointCopyLabel} |
| 910 | </span> | |
| 911 | </Button> | |
| 923 | </TooltipPopup> | |
| 924 | </Tooltip> | |
| 912 | 925 | <GroupSeparator /> |
| 913 | 926 | <Menu> |
| 914 | 927 | <MenuTrigger |
| ... | ... | @@ -1353,12 +1366,19 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ |
| 1353 | 1366 | {endpoint.label} |
| 1354 | 1367 | </h3> |
| 1355 | 1368 | {shouldShowEndpointUrl ? ( |
| 1356 | <p | |
| 1357 | className="min-w-0 truncate text-xs leading-5 text-muted-foreground" | |
| 1358 | title={endpoint.httpBaseUrl} | |
| 1359 | > | |
| 1360 | {endpoint.httpBaseUrl} | |
| 1361 | </p> | |
| 1369 | <Tooltip> | |
| 1370 | <TooltipTrigger | |
| 1371 | render={ | |
| 1372 | <p | |
| 1373 | aria-label={endpoint.httpBaseUrl} | |
| 1374 | className="min-w-0 truncate text-xs leading-5 text-muted-foreground" | |
| 1375 | /> | |
| 1376 | } | |
| 1377 | > | |
| 1378 | {endpoint.httpBaseUrl} | |
| 1379 | </TooltipTrigger> | |
| 1380 | <TooltipPopup side="top">{endpoint.httpBaseUrl}</TooltipPopup> | |
| 1381 | </Tooltip> | |
| 1362 | 1382 | ) : null} |
| 1363 | 1383 | {!isAvailable ? ( |
| 1364 | 1384 | <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground"> |
| ... | ... | @@ -3116,12 +3136,21 @@ export function ConnectionsSettings() { |
| 3116 | 3136 | ) : null} |
| 3117 | 3137 | <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2"> |
| 3118 | 3138 | <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p> |
| 3119 | <p | |
| 3120 | className="mt-1 truncate text-sm text-foreground" | |
| 3121 | title={pendingTailscaleServeBaseUrl ?? undefined} | |
| 3122 | > | |
| 3123 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | |
| 3124 | </p> | |
| 3139 | <Tooltip> | |
| 3140 | <TooltipTrigger | |
| 3141 | render={ | |
| 3142 | <p | |
| 3143 | aria-label={pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | |
| 3144 | className="mt-1 truncate text-sm text-foreground" | |
| 3145 | /> | |
| 3146 | } | |
| 3147 | > | |
| 3148 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | |
| 3149 | </TooltipTrigger> | |
| 3150 | <TooltipPopup side="top"> | |
| 3151 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | |
| 3152 | </TooltipPopup> | |
| 3153 | </Tooltip> | |
| 3125 | 3154 | </div> |
| 3126 | 3155 | </DialogPanel> |
| 3127 | 3156 | <DialogFooter> |
apps/web/src/components/settings/KeybindingsSettings.tsx+13-3| ... | ... | @@ -805,9 +805,19 @@ function KeybindingTableRow({ |
| 805 | 805 | <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40"> |
| 806 | 806 | <div className="min-w-0 pr-4"> |
| 807 | 807 | <div className="flex min-w-0 items-center gap-1.5"> |
| 808 | <div className="truncate text-[13px] font-medium text-foreground" title={row.command}> | |
| 809 | {commandLabel(row.command)} | |
| 810 | </div> | |
| 808 | <Tooltip> | |
| 809 | <TooltipTrigger | |
| 810 | render={ | |
| 811 | <div | |
| 812 | aria-label={row.command} | |
| 813 | className="truncate text-[13px] font-medium text-foreground" | |
| 814 | /> | |
| 815 | } | |
| 816 | > | |
| 817 | {commandLabel(row.command)} | |
| 818 | </TooltipTrigger> | |
| 819 | <TooltipPopup side="top">{row.command}</TooltipPopup> | |
| 820 | </Tooltip> | |
| 811 | 821 | </div> |
| 812 | 822 | </div> |
| 813 | 823 | <div className="flex min-w-0 items-center gap-2 pr-4"> |
apps/web/src/components/ui/sidebar.tsx+31-25| ... | ... | @@ -572,31 +572,37 @@ function SidebarRail({ |
| 572 | 572 | }, []); |
| 573 | 573 | |
| 574 | 574 | return ( |
| 575 | <button | |
| 576 | aria-label={railLabel} | |
| 577 | className={cn( | |
| 578 | /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */ | |
| 579 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none", | |
| 580 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | |
| 581 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | |
| 582 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | |
| 583 | "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | |
| 584 | "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | |
| 585 | className, | |
| 586 | )} | |
| 587 | data-sidebar="rail" | |
| 588 | data-slot="sidebar-rail" | |
| 589 | onClick={handleClick} | |
| 590 | onPointerCancel={handlePointerCancel} | |
| 591 | onPointerDown={handlePointerDown} | |
| 592 | onPointerMove={handlePointerMove} | |
| 593 | onPointerUp={handlePointerUp} | |
| 594 | ref={railRef} | |
| 595 | tabIndex={-1} | |
| 596 | title={railTitle} | |
| 597 | type="button" | |
| 598 | {...props} | |
| 599 | /> | |
| 575 | <Tooltip> | |
| 576 | <TooltipTrigger | |
| 577 | render={ | |
| 578 | <button | |
| 579 | aria-label={railLabel} | |
| 580 | className={cn( | |
| 581 | /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */ | |
| 582 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none", | |
| 583 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | |
| 584 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | |
| 585 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | |
| 586 | "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | |
| 587 | "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | |
| 588 | className, | |
| 589 | )} | |
| 590 | data-sidebar="rail" | |
| 591 | data-slot="sidebar-rail" | |
| 592 | onClick={handleClick} | |
| 593 | onPointerCancel={handlePointerCancel} | |
| 594 | onPointerDown={handlePointerDown} | |
| 595 | onPointerMove={handlePointerMove} | |
| 596 | onPointerUp={handlePointerUp} | |
| 597 | ref={railRef} | |
| 598 | tabIndex={-1} | |
| 599 | type="button" | |
| 600 | {...props} | |
| 601 | /> | |
| 602 | } | |
| 603 | /> | |
| 604 | <TooltipPopup side="right">{railTitle}</TooltipPopup> | |
| 605 | </Tooltip> | |
| 600 | 606 | ); |
| 601 | 607 | } |
| 602 | 608 |
apps/web/src/components/ui/toast.tsx+61-45| ... | ... | @@ -35,6 +35,7 @@ import { |
| 35 | 35 | shouldHideCollapsedToastContent, |
| 36 | 36 | shouldRenderThreadScopedToast, |
| 37 | 37 | } from "./toast.logic"; |
| 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip"; | |
| 38 | 39 | |
| 39 | 40 | export type ThreadToastData = { |
| 40 | 41 | threadRef?: ScopedThreadRef | null; |
| ... | ... | @@ -113,16 +114,24 @@ function handleToastDismissClick( |
| 113 | 114 | |
| 114 | 115 | function CopyErrorButton({ text }: { text: string }) { |
| 115 | 116 | const { copyToClipboard, isCopied } = useCopyToClipboard(); |
| 117 | const label = isCopied ? "Copied error" : "Copy error"; | |
| 116 | 118 | |
| 117 | 119 | return ( |
| 118 | <button | |
| 119 | className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground" | |
| 120 | onClick={() => copyToClipboard(text)} | |
| 121 | title="Copy error" | |
| 122 | type="button" | |
| 123 | > | |
| 124 | {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} | |
| 125 | </button> | |
| 120 | <Tooltip> | |
| 121 | <TooltipTrigger | |
| 122 | render={ | |
| 123 | <button | |
| 124 | aria-label={label} | |
| 125 | className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground" | |
| 126 | onClick={() => copyToClipboard(text)} | |
| 127 | type="button" | |
| 128 | /> | |
| 129 | } | |
| 130 | > | |
| 131 | {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} | |
| 132 | </TooltipTrigger> | |
| 133 | <TooltipPopup side="top">{label}</TooltipPopup> | |
| 134 | </Tooltip> | |
| 126 | 135 | ); |
| 127 | 136 | } |
| 128 | 137 | |
| ... | ... | @@ -205,43 +214,50 @@ function ToastDescriptionAndExpandable({ |
| 205 | 214 | |
| 206 | 215 | return ( |
| 207 | 216 | <> |
| 208 | <div | |
| 209 | aria-expanded={open} | |
| 210 | className={cn( | |
| 211 | "group flex min-w-0 w-full cursor-pointer select-none items-start gap-1.5 rounded-sm text-left outline-none ring-offset-background", | |
| 212 | "transition-colors hover:bg-muted/40", | |
| 213 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 214 | )} | |
| 215 | onClick={toggle} | |
| 216 | onKeyDown={onKeyDown} | |
| 217 | role="button" | |
| 218 | tabIndex={0} | |
| 219 | title={open ? collapseLabel : expandLabel} | |
| 220 | > | |
| 221 | <div className="min-w-0 flex-1"> | |
| 222 | <Toast.Description | |
| 223 | className={cn( | |
| 224 | "min-w-0 select-none wrap-break-word text-muted-foreground", | |
| 225 | errorDescriptionClampClass(toastType, toastDescription), | |
| 226 | "underline-offset-2 decoration-muted-foreground/60 group-hover:underline", | |
| 227 | )} | |
| 228 | data-slot="toast-description" | |
| 229 | /> | |
| 230 | </div> | |
| 231 | {open ? ( | |
| 232 | <ChevronUpIcon | |
| 233 | aria-hidden | |
| 234 | className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80" | |
| 235 | strokeWidth={2.25} | |
| 236 | /> | |
| 237 | ) : ( | |
| 238 | <ChevronDownIcon | |
| 239 | aria-hidden | |
| 240 | className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80" | |
| 241 | strokeWidth={2.25} | |
| 242 | /> | |
| 243 | )} | |
| 244 | </div> | |
| 217 | <Tooltip> | |
| 218 | <TooltipTrigger | |
| 219 | render={ | |
| 220 | <div | |
| 221 | aria-label={open ? collapseLabel : expandLabel} | |
| 222 | aria-expanded={open} | |
| 223 | className={cn( | |
| 224 | "group flex min-w-0 w-full cursor-pointer select-none items-start gap-1.5 rounded-sm text-left outline-none ring-offset-background", | |
| 225 | "transition-colors hover:bg-muted/40", | |
| 226 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 227 | )} | |
| 228 | onClick={toggle} | |
| 229 | onKeyDown={onKeyDown} | |
| 230 | role="button" | |
| 231 | tabIndex={0} | |
| 232 | /> | |
| 233 | } | |
| 234 | > | |
| 235 | <div className="min-w-0 flex-1"> | |
| 236 | <Toast.Description | |
| 237 | className={cn( | |
| 238 | "min-w-0 select-none wrap-break-word text-muted-foreground", | |
| 239 | errorDescriptionClampClass(toastType, toastDescription), | |
| 240 | "underline-offset-2 decoration-muted-foreground/60 group-hover:underline", | |
| 241 | )} | |
| 242 | data-slot="toast-description" | |
| 243 | /> | |
| 244 | </div> | |
| 245 | {open ? ( | |
| 246 | <ChevronUpIcon | |
| 247 | aria-hidden | |
| 248 | className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80" | |
| 249 | strokeWidth={2.25} | |
| 250 | /> | |
| 251 | ) : ( | |
| 252 | <ChevronDownIcon | |
| 253 | aria-hidden | |
| 254 | className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80" | |
| 255 | strokeWidth={2.25} | |
| 256 | /> | |
| 257 | )} | |
| 258 | </TooltipTrigger> | |
| 259 | <TooltipPopup side="top">{open ? collapseLabel : expandLabel}</TooltipPopup> | |
| 260 | </Tooltip> | |
| 245 | 261 | {open ? <div className={toastExpandablePanelClassName}>{expandableContent}</div> : null} |
| 246 | 262 | </> |
| 247 | 263 | ); |
apps/web/src/components/ui/tooltip.tsx+1-1| ... | ... | @@ -31,7 +31,7 @@ function TooltipPopup({ |
| 31 | 31 | <TooltipPrimitive.Positioner |
| 32 | 32 | align={align} |
| 33 | 33 | anchor={anchor} |
| 34 | className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none" | |
| 34 | className="pointer-events-none z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none" | |
| 35 | 35 | data-slot="tooltip-positioner" |
| 36 | 36 | side={side} |
| 37 | 37 | sideOffset={sideOffset} |
apps/web/src/composer-editor-mentions.test.ts+59-6| ... | ... | @@ -10,7 +10,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 10 | 10 | it("splits mention tokens followed by whitespace into mention segments", () => { |
| 11 | 11 | expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md please")).toEqual([ |
| 12 | 12 | { type: "text", text: "Inspect " }, |
| 13 | { type: "mention", path: "AGENTS.md" }, | |
| 13 | { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, | |
| 14 | 14 | { type: "text", text: " please" }, |
| 15 | 15 | ]); |
| 16 | 16 | }); |
| ... | ... | @@ -24,7 +24,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 24 | 24 | it("keeps newlines around mention tokens", () => { |
| 25 | 25 | expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([ |
| 26 | 26 | { type: "text", text: "one\n" }, |
| 27 | { type: "mention", path: "src/index.ts" }, | |
| 27 | { type: "mention", path: "src/index.ts", source: "@src/index.ts" }, | |
| 28 | 28 | { type: "text", text: " \ntwo" }, |
| 29 | 29 | ]); |
| 30 | 30 | }); |
| ... | ... | @@ -32,7 +32,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 32 | 32 | it("splits quoted mention tokens containing whitespace", () => { |
| 33 | 33 | expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([ |
| 34 | 34 | { type: "text", text: "Inspect " }, |
| 35 | { type: "mention", path: "My File.md" }, | |
| 35 | { type: "mention", path: "My File.md", source: '@"My File.md"' }, | |
| 36 | 36 | { type: "text", text: " please" }, |
| 37 | 37 | ]); |
| 38 | 38 | }); |
| ... | ... | @@ -40,11 +40,53 @@ describe("splitPromptIntoComposerSegments", () => { |
| 40 | 40 | it("unescapes quoted mention token content", () => { |
| 41 | 41 | expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([ |
| 42 | 42 | { type: "text", text: "Inspect " }, |
| 43 | { type: "mention", path: 'docs/My "File".md' }, | |
| 43 | { | |
| 44 | type: "mention", | |
| 45 | path: 'docs/My "File".md', | |
| 46 | source: '@"docs/My \\"File\\".md"', | |
| 47 | }, | |
| 44 | 48 | { type: "text", text: " please" }, |
| 45 | 49 | ]); |
| 46 | 50 | }); |
| 47 | 51 | |
| 52 | it("splits generated markdown file links into mention segments", () => { | |
| 53 | expect( | |
| 54 | splitPromptIntoComposerSegments( | |
| 55 | "Inspect [package.json](path/to/package.json) before continuing", | |
| 56 | ), | |
| 57 | ).toEqual([ | |
| 58 | { type: "text", text: "Inspect " }, | |
| 59 | { | |
| 60 | type: "mention", | |
| 61 | path: "path/to/package.json", | |
| 62 | source: "[package.json](path/to/package.json)", | |
| 63 | }, | |
| 64 | { type: "text", text: " before continuing" }, | |
| 65 | ]); | |
| 66 | }); | |
| 67 | ||
| 68 | it("does not turn normal web links into file mention segments", () => { | |
| 69 | expect( | |
| 70 | splitPromptIntoComposerSegments("Read [the docs](https://example.com/docs) first"), | |
| 71 | ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]); | |
| 72 | }); | |
| 73 | ||
| 74 | it("decodes reserved path characters from generated links", () => { | |
| 75 | expect( | |
| 76 | splitPromptIntoComposerSegments( | |
| 77 | "Inspect [config#draft?.json](config%23draft%3F.json) before continuing", | |
| 78 | ), | |
| 79 | ).toEqual([ | |
| 80 | { type: "text", text: "Inspect " }, | |
| 81 | { | |
| 82 | type: "mention", | |
| 83 | path: "config#draft?.json", | |
| 84 | source: "[config#draft?.json](config%23draft%3F.json)", | |
| 85 | }, | |
| 86 | { type: "text", text: " before continuing" }, | |
| 87 | ]); | |
| 88 | }); | |
| 89 | ||
| 48 | 90 | it("splits skill tokens followed by whitespace into skill segments", () => { |
| 49 | 91 | expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ |
| 50 | 92 | { type: "text", text: "Use " }, |
| ... | ... | @@ -67,7 +109,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 67 | 109 | ).toEqual([ |
| 68 | 110 | { type: "text", text: "Inspect " }, |
| 69 | 111 | { type: "terminal-context", context: null }, |
| 70 | { type: "mention", path: "AGENTS.md" }, | |
| 112 | { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, | |
| 71 | 113 | { type: "text", text: " please" }, |
| 72 | 114 | ]); |
| 73 | 115 | }); |
| ... | ... | @@ -94,7 +136,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 94 | 136 | { type: "terminal-context", context: null }, |
| 95 | 137 | { type: "skill", name: "review-follow-up" }, |
| 96 | 138 | { type: "text", text: " after " }, |
| 97 | { type: "mention", path: "AGENTS.md" }, | |
| 139 | { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, | |
| 98 | 140 | { type: "text", text: " " }, |
| 99 | 141 | ]); |
| 100 | 142 | }); |
| ... | ... | @@ -151,4 +193,15 @@ describe("selectionTouchesMentionBoundary", () => { |
| 151 | 193 | ), |
| 152 | 194 | ).toBe(true); |
| 153 | 195 | }); |
| 196 | ||
| 197 | it("returns true when selection includes whitespace after a markdown file link", () => { | |
| 198 | const prompt = "hi [package.json](path/to/package.json) there"; | |
| 199 | expect( | |
| 200 | selectionTouchesMentionBoundary( | |
| 201 | prompt, | |
| 202 | "hi [package.json](path/to/package.json)".length, | |
| 203 | prompt.length, | |
| 204 | ), | |
| 205 | ).toBe(true); | |
| 206 | }); | |
| 154 | 207 | }); |
apps/web/src/composer-editor-mentions.ts+32-1| ... | ... | @@ -11,6 +11,7 @@ export type ComposerPromptSegment = |
| 11 | 11 | | { |
| 12 | 12 | type: "mention"; |
| 13 | 13 | path: string; |
| 14 | source: string; | |
| 14 | 15 | } |
| 15 | 16 | | { |
| 16 | 17 | type: "skill"; |
| ... | ... | @@ -23,6 +24,9 @@ export type ComposerPromptSegment = |
| 23 | 24 | |
| 24 | 25 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; |
| 25 | 26 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; |
| 27 | const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; | |
| 28 | const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; | |
| 29 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | |
| 26 | 30 | |
| 27 | 31 | function rangeIncludesIndex(start: number, end: number, index: number): boolean { |
| 28 | 32 | return start <= index && index < end; |
| ... | ... | @@ -57,6 +61,29 @@ type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>; |
| 57 | 61 | function collectMentionTokenMatches(text: string): MentionTokenMatch[] { |
| 58 | 62 | const matches: MentionTokenMatch[] = []; |
| 59 | 63 | |
| 64 | for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { | |
| 65 | const fullMatch = match[0]; | |
| 66 | const prefix = match[1] ?? ""; | |
| 67 | const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); | |
| 68 | const encodedPath = match[3] ?? ""; | |
| 69 | let path = encodedPath; | |
| 70 | try { | |
| 71 | path = decodeURIComponent(encodedPath); | |
| 72 | } catch { | |
| 73 | // Keep the source value when malformed percent escapes are present. | |
| 74 | } | |
| 75 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | |
| 76 | const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | |
| 77 | const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); | |
| 78 | if (!path || hasExternalScheme || label !== basename) { | |
| 79 | continue; | |
| 80 | } | |
| 81 | const matchIndex = match.index ?? 0; | |
| 82 | const start = matchIndex + prefix.length; | |
| 83 | const end = start + fullMatch.length - prefix.length; | |
| 84 | matches.push({ type: "mention", value: path, start, end }); | |
| 85 | } | |
| 86 | ||
| 60 | 87 | for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { |
| 61 | 88 | const fullMatch = match[0]; |
| 62 | 89 | const prefix = match[1] ?? ""; |
| ... | ... | @@ -189,7 +216,11 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen |
| 189 | 216 | } |
| 190 | 217 | |
| 191 | 218 | if (match.type === "mention") { |
| 192 | segments.push({ type: "mention", path: match.value }); | |
| 219 | segments.push({ | |
| 220 | type: "mention", | |
| 221 | path: match.value, | |
| 222 | source: text.slice(match.start, match.end), | |
| 223 | }); | |
| 193 | 224 | } else { |
| 194 | 225 | segments.push({ type: "skill", name: match.value }); |
| 195 | 226 | } |
apps/web/src/composer-logic.test.ts+20| ... | ... | @@ -167,6 +167,16 @@ describe("expandCollapsedComposerCursor", () => { |
| 167 | 167 | ); |
| 168 | 168 | }); |
| 169 | 169 | |
| 170 | it("maps collapsed markdown file links to their expanded source offsets", () => { | |
| 171 | const text = "what's in [AGENTS.md](AGENTS.md) please"; | |
| 172 | const collapsedCursorAfterMention = "what's in ".length + 2; | |
| 173 | const expandedCursorAfterMention = "what's in [AGENTS.md](AGENTS.md) ".length; | |
| 174 | ||
| 175 | expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe( | |
| 176 | expandedCursorAfterMention, | |
| 177 | ); | |
| 178 | }); | |
| 179 | ||
| 170 | 180 | it("allows path trigger detection to close after selecting a mention", () => { |
| 171 | 181 | const text = "what's in my @AGENTS.md "; |
| 172 | 182 | const collapsedCursorAfterMention = "what's in my ".length + 2; |
| ... | ... | @@ -211,6 +221,16 @@ describe("collapseExpandedComposerCursor", () => { |
| 211 | 221 | ); |
| 212 | 222 | }); |
| 213 | 223 | |
| 224 | it("maps expanded markdown file link cursors back to collapsed offsets", () => { | |
| 225 | const text = "what's in [AGENTS.md](AGENTS.md) please"; | |
| 226 | const collapsedCursorAfterMention = "what's in ".length + 2; | |
| 227 | const expandedCursorAfterMention = "what's in [AGENTS.md](AGENTS.md) ".length; | |
| 228 | ||
| 229 | expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe( | |
| 230 | collapsedCursorAfterMention, | |
| 231 | ); | |
| 232 | }); | |
| 233 | ||
| 214 | 234 | it("keeps replacement cursors aligned when another mention already exists earlier", () => { |
| 215 | 235 | const text = "open @AGENTS.md then @src/index.ts "; |
| 216 | 236 | const expandedCursor = text.length; |
apps/web/src/composer-logic.ts+2-3| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger"; | |
| 2 | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 3 | 2 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 4 | 3 | |
| ... | ... | @@ -55,7 +54,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 55 | 54 | |
| 56 | 55 | for (const segment of segments) { |
| 57 | 56 | if (segment.type === "mention") { |
| 58 | const expandedLength = serializeComposerMentionPath(segment.path).length + 1; | |
| 57 | const expandedLength = segment.source.length; | |
| 59 | 58 | if (remaining <= 1) { |
| 60 | 59 | return expandedCursor + (remaining === 0 ? 0 : expandedLength); |
| 61 | 60 | } |
| ... | ... | @@ -143,7 +142,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number |
| 143 | 142 | |
| 144 | 143 | for (const segment of segments) { |
| 145 | 144 | if (segment.type === "mention") { |
| 146 | const expandedLength = serializeComposerMentionPath(segment.path).length + 1; | |
| 145 | const expandedLength = segment.source.length; | |
| 147 | 146 | if (remaining === 0) { |
| 148 | 147 | return collapsedCursor; |
| 149 | 148 | } |
apps/web/src/index.css+198-69| ... | ... | @@ -305,10 +305,44 @@ label:has(> select#reasoning-effort) select { |
| 305 | 305 | .chat-markdown ol, |
| 306 | 306 | .chat-markdown blockquote, |
| 307 | 307 | .chat-markdown pre, |
| 308 | .chat-markdown table { | |
| 308 | .chat-markdown .chat-markdown-table-container { | |
| 309 | 309 | margin: 0.65rem 0; |
| 310 | 310 | } |
| 311 | 311 | |
| 312 | .chat-markdown h1, | |
| 313 | .chat-markdown h2, | |
| 314 | .chat-markdown h3, | |
| 315 | .chat-markdown h4, | |
| 316 | .chat-markdown h5, | |
| 317 | .chat-markdown h6 { | |
| 318 | margin: 1.25rem 0 0.5rem; | |
| 319 | font-weight: 600; | |
| 320 | line-height: 1.3; | |
| 321 | color: var(--foreground); | |
| 322 | } | |
| 323 | ||
| 324 | .chat-markdown h1 { | |
| 325 | font-size: 1.25rem; | |
| 326 | } | |
| 327 | ||
| 328 | .chat-markdown h2 { | |
| 329 | font-size: 1.125rem; | |
| 330 | } | |
| 331 | ||
| 332 | .chat-markdown h3 { | |
| 333 | font-size: 1rem; | |
| 334 | } | |
| 335 | ||
| 336 | .chat-markdown h4, | |
| 337 | .chat-markdown h5, | |
| 338 | .chat-markdown h6 { | |
| 339 | font-size: 0.875rem; | |
| 340 | } | |
| 341 | ||
| 342 | .chat-markdown h6 { | |
| 343 | color: var(--muted-foreground); | |
| 344 | } | |
| 345 | ||
| 312 | 346 | .chat-markdown ul { |
| 313 | 347 | padding-left: 1.25rem; |
| 314 | 348 | list-style-type: disc; |
| ... | ... | @@ -339,14 +373,39 @@ label:has(> select#reasoning-effort) select { |
| 339 | 373 | margin-top: 0.25rem; |
| 340 | 374 | } |
| 341 | 375 | |
| 376 | /* GitHub-style task lists: the checkbox replaces the list marker. */ | |
| 377 | .chat-markdown li.task-list-item { | |
| 378 | list-style-type: none; | |
| 379 | } | |
| 380 | ||
| 381 | .chat-markdown li.task-list-item input[type="checkbox"] { | |
| 382 | margin: 0 0.35em 0.15em -1.25rem; | |
| 383 | vertical-align: middle; | |
| 384 | } | |
| 385 | ||
| 342 | 386 | .chat-markdown a { |
| 343 | 387 | color: var(--info-foreground); |
| 344 | text-decoration: underline; | |
| 345 | text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent); | |
| 388 | text-decoration: none; | |
| 346 | 389 | } |
| 347 | 390 | |
| 348 | .chat-markdown a:hover { | |
| 349 | opacity: 0.8; | |
| 391 | .chat-markdown a:not(.chat-markdown-file-link):hover, | |
| 392 | .chat-markdown a:not(.chat-markdown-file-link):focus-visible { | |
| 393 | background-image: radial-gradient(circle, currentcolor 0.75px, transparent 1px); | |
| 394 | background-position: left bottom; | |
| 395 | background-repeat: repeat-x; | |
| 396 | background-size: 4px 2px; | |
| 397 | } | |
| 398 | ||
| 399 | .chat-markdown .chat-markdown-link-favicon { | |
| 400 | @apply inline-flex; | |
| 401 | width: 14px; | |
| 402 | height: 14px; | |
| 403 | margin-inline: 0.25em 0.2em; | |
| 404 | vertical-align: -0.125em; | |
| 405 | } | |
| 406 | ||
| 407 | .chat-markdown .chat-markdown-link-leading { | |
| 408 | white-space: nowrap; | |
| 350 | 409 | } |
| 351 | 410 | |
| 352 | 411 | .chat-markdown blockquote { |
| ... | ... | @@ -355,6 +414,35 @@ label:has(> select#reasoning-effort) select { |
| 355 | 414 | color: var(--muted-foreground); |
| 356 | 415 | } |
| 357 | 416 | |
| 417 | .chat-markdown section[data-footnotes] { | |
| 418 | margin-top: 1.25rem; | |
| 419 | border-top: 1px solid var(--border); | |
| 420 | padding-top: 0.75rem; | |
| 421 | color: var(--muted-foreground); | |
| 422 | font-size: 0.75rem; | |
| 423 | } | |
| 424 | ||
| 425 | .chat-markdown section[data-footnotes] ol { | |
| 426 | margin: 0; | |
| 427 | padding-left: 1.25rem; | |
| 428 | } | |
| 429 | ||
| 430 | .chat-markdown section[data-footnotes] li + li { | |
| 431 | margin-top: 0.35rem; | |
| 432 | } | |
| 433 | ||
| 434 | .chat-markdown a[data-footnote-ref], | |
| 435 | .chat-markdown a[data-footnote-backref] { | |
| 436 | display: inline-flex; | |
| 437 | min-width: 1rem; | |
| 438 | align-items: center; | |
| 439 | justify-content: center; | |
| 440 | border-radius: 0.25rem; | |
| 441 | text-decoration: none; | |
| 442 | font-size: 0.6875rem; | |
| 443 | font-weight: 600; | |
| 444 | } | |
| 445 | ||
| 358 | 446 | .chat-markdown :not(pre) > code { |
| 359 | 447 | border: 1px solid var(--border); |
| 360 | 448 | border-radius: 0.375rem; |
| ... | ... | @@ -364,45 +452,19 @@ label:has(> select#reasoning-effort) select { |
| 364 | 452 | font-size: 0.75rem; |
| 365 | 453 | } |
| 366 | 454 | |
| 367 | .chat-markdown-file-link { | |
| 368 | display: inline-flex; | |
| 369 | align-items: center; | |
| 370 | gap: 0.28rem; | |
| 371 | border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); | |
| 372 | border-radius: 0.375rem; | |
| 373 | background: color-mix(in srgb, var(--muted) 88%, var(--background)); | |
| 374 | padding: 0.08rem 0.34rem; | |
| 455 | .chat-markdown a.chat-markdown-file-link { | |
| 375 | 456 | color: var(--foreground); |
| 376 | font-family: var(--font-mono, ui-monospace, monospace); | |
| 377 | font-size: 0.75rem; | |
| 378 | line-height: 1.15; | |
| 379 | vertical-align: text-bottom; | |
| 380 | transition: | |
| 381 | background-color 120ms ease, | |
| 382 | border-color 120ms ease, | |
| 383 | color 120ms ease, | |
| 384 | opacity 120ms ease; | |
| 457 | text-decoration: none; | |
| 385 | 458 | } |
| 386 | 459 | |
| 387 | .chat-markdown-file-link:hover { | |
| 388 | opacity: 1; | |
| 460 | .chat-markdown a.chat-markdown-file-link:hover { | |
| 389 | 461 | color: var(--foreground); |
| 390 | border-color: color-mix(in srgb, var(--border) 65%, var(--foreground)); | |
| 391 | background: color-mix(in srgb, var(--muted) 72%, var(--background)); | |
| 462 | text-decoration: none; | |
| 392 | 463 | } |
| 393 | 464 | |
| 394 | .chat-markdown-file-link:focus-visible { | |
| 465 | .chat-markdown a.chat-markdown-file-link:focus-visible { | |
| 395 | 466 | outline: none; |
| 396 | box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent); | |
| 397 | } | |
| 398 | ||
| 399 | .chat-markdown-file-link-icon { | |
| 400 | opacity: 0.72; | |
| 401 | } | |
| 402 | ||
| 403 | .chat-markdown-file-link-label { | |
| 404 | color: color-mix(in srgb, var(--foreground) 88%, transparent); | |
| 405 | line-height: 1.1; | |
| 467 | box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent); | |
| 406 | 468 | } |
| 407 | 469 | |
| 408 | 470 | .chat-markdown pre { |
| ... | ... | @@ -421,6 +483,24 @@ label:has(> select#reasoning-effort) select { |
| 421 | 483 | font-size: 0.75rem; |
| 422 | 484 | } |
| 423 | 485 | |
| 486 | .chat-markdown pre { | |
| 487 | scrollbar-width: thin; | |
| 488 | scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; | |
| 489 | } | |
| 490 | ||
| 491 | .chat-markdown pre::-webkit-scrollbar { | |
| 492 | height: 7px; | |
| 493 | } | |
| 494 | ||
| 495 | .chat-markdown pre::-webkit-scrollbar-track { | |
| 496 | background: transparent; | |
| 497 | } | |
| 498 | ||
| 499 | .chat-markdown pre::-webkit-scrollbar-thumb { | |
| 500 | border-radius: 999px; | |
| 501 | background: color-mix(in srgb, var(--border) 78%, transparent); | |
| 502 | } | |
| 503 | ||
| 424 | 504 | .markdown-file-link-tooltip-scroll { |
| 425 | 505 | scrollbar-width: thin; |
| 426 | 506 | scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; |
| ... | ... | @@ -440,66 +520,115 @@ label:has(> select#reasoning-effort) select { |
| 440 | 520 | } |
| 441 | 521 | |
| 442 | 522 | .chat-markdown .chat-markdown-codeblock { |
| 443 | --chat-markdown-codeblock-copy-button-space: 1.5rem; | |
| 444 | position: relative; | |
| 445 | 523 | margin: 0.65rem 0; |
| 524 | overflow: hidden; | |
| 525 | border: 1px solid var(--border); | |
| 526 | border-radius: 0.75rem; | |
| 527 | background: color-mix(in srgb, var(--muted) 78%, var(--background)); | |
| 446 | 528 | } |
| 447 | 529 | |
| 448 | .chat-markdown .chat-markdown-codeblock pre { | |
| 449 | margin: 0; | |
| 450 | padding-right: calc(0.9rem + var(--chat-markdown-codeblock-copy-button-space)); | |
| 530 | .chat-markdown .chat-markdown-codeblock-header { | |
| 531 | display: flex; | |
| 532 | align-items: center; | |
| 533 | justify-content: space-between; | |
| 534 | gap: 0.5rem; | |
| 535 | border-bottom: 1px solid var(--border); | |
| 536 | background: var(--muted); | |
| 537 | padding: 0.25rem 0.375rem 0.25rem 0.7rem; | |
| 538 | color: var(--muted-foreground); | |
| 451 | 539 | } |
| 452 | 540 | |
| 453 | .chat-markdown .chat-markdown-copy-button { | |
| 454 | position: absolute; | |
| 455 | top: 0.5rem; | |
| 456 | right: 0.5rem; | |
| 457 | z-index: 1; | |
| 541 | .chat-markdown .chat-markdown-codeblock-title { | |
| 458 | 542 | display: inline-flex; |
| 543 | min-width: 0; | |
| 459 | 544 | align-items: center; |
| 460 | justify-content: center; | |
| 461 | height: 1.5rem; | |
| 462 | width: 1.5rem; | |
| 463 | border-radius: 0.375rem; | |
| 464 | border: 1px solid var(--border); | |
| 465 | background: color-mix(in srgb, var(--background) 82%, transparent); | |
| 545 | gap: 0.4rem; | |
| 546 | font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); | |
| 547 | font-size: 0.6875rem; | |
| 548 | } | |
| 549 | ||
| 550 | .chat-markdown .chat-markdown-chrome-action { | |
| 466 | 551 | color: var(--muted-foreground); |
| 467 | cursor: pointer; | |
| 468 | opacity: 0; | |
| 469 | pointer-events: none; | |
| 470 | transition: | |
| 471 | opacity 120ms ease, | |
| 472 | color 120ms ease, | |
| 473 | border-color 120ms ease; | |
| 474 | 552 | } |
| 475 | 553 | |
| 476 | .chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button, | |
| 477 | .chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button { | |
| 478 | opacity: 1; | |
| 479 | pointer-events: auto; | |
| 554 | .chat-markdown .chat-markdown-chrome-action:hover { | |
| 555 | color: var(--foreground); | |
| 480 | 556 | } |
| 481 | 557 | |
| 482 | .chat-markdown .chat-markdown-copy-button:hover { | |
| 558 | .chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] { | |
| 483 | 559 | color: var(--foreground); |
| 484 | border-color: color-mix(in srgb, var(--border) 70%, var(--foreground)); | |
| 560 | background: color-mix(in srgb, var(--foreground) 8%, transparent); | |
| 561 | } | |
| 562 | ||
| 563 | .chat-markdown .chat-markdown-codeblock pre { | |
| 564 | margin: 0; | |
| 565 | border: none; | |
| 566 | border-radius: 0; | |
| 567 | background: transparent; | |
| 568 | } | |
| 569 | ||
| 570 | .chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { | |
| 571 | white-space: pre-wrap; | |
| 572 | overflow-wrap: anywhere; | |
| 485 | 573 | } |
| 486 | 574 | |
| 487 | 575 | .chat-markdown .chat-markdown-shiki .shiki { |
| 488 | background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; | |
| 576 | background: transparent !important; | |
| 489 | 577 | } |
| 490 | 578 | |
| 579 | /* Diagnostics-style tables: row separators only, uppercase headers, and a | |
| 580 | scroll-fade container for horizontal overflow. The root chat-markdown | |
| 581 | wrapping rules (overflow-wrap: anywhere) would let columns shrink to single | |
| 582 | characters and defeat the overflow — restore word-boundary wrapping so the | |
| 583 | min column width is the longest word. */ | |
| 491 | 584 | .chat-markdown table { |
| 492 | 585 | width: 100%; |
| 586 | min-width: max-content; | |
| 493 | 587 | border-collapse: collapse; |
| 588 | overflow-wrap: normal; | |
| 589 | word-break: normal; | |
| 590 | font-size: 0.75rem; | |
| 494 | 591 | } |
| 495 | 592 | |
| 496 | 593 | .chat-markdown th, |
| 497 | 594 | .chat-markdown td { |
| 498 | border: 1px solid var(--border); | |
| 499 | padding: 0.35rem 0.45rem; | |
| 595 | padding: 0.45rem 0.75rem; | |
| 500 | 596 | text-align: left; |
| 501 | 597 | } |
| 502 | 598 | |
| 599 | .chat-markdown thead th { | |
| 600 | border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); | |
| 601 | padding-block: 0.55rem; | |
| 602 | font-weight: 600; | |
| 603 | white-space: nowrap; | |
| 604 | } | |
| 605 | ||
| 606 | .chat-markdown tbody td { | |
| 607 | border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); | |
| 608 | } | |
| 609 | ||
| 610 | /* Collapsed (default): single-line cells truncate so arbitrary chat content | |
| 611 | cannot blow the column out; the footer toggle expands cells to wrap. */ | |
| 612 | .chat-markdown .chat-markdown-table-container[data-expanded="false"] th, | |
| 613 | .chat-markdown .chat-markdown-table-container[data-expanded="false"] td { | |
| 614 | overflow: hidden; | |
| 615 | max-width: 24rem; | |
| 616 | text-overflow: ellipsis; | |
| 617 | white-space: nowrap; | |
| 618 | } | |
| 619 | ||
| 620 | .chat-markdown .chat-markdown-table-container[data-expanded="true"] td { | |
| 621 | max-width: 24rem; | |
| 622 | overflow-wrap: anywhere; | |
| 623 | } | |
| 624 | ||
| 625 | .chat-markdown .chat-markdown-table-footer { | |
| 626 | display: flex; | |
| 627 | align-items: center; | |
| 628 | justify-content: space-between; | |
| 629 | margin-top: 0.125rem; | |
| 630 | } | |
| 631 | ||
| 503 | 632 | @keyframes provider-update-pill-countdown { |
| 504 | 633 | from { |
| 505 | 634 | transform: scaleX(1); |
apps/web/src/markdown-clipboard.ts created+311| ... | ... | @@ -0,0 +1,311 @@ |
| 1 | /** | |
| 2 | * Converts a DOM selection inside rendered chat markdown back into markdown | |
| 3 | * source so highlight-and-copy keeps formatting (links, emphasis, lists, | |
| 4 | * fences, tables) instead of flattening to plain text. The `text/plain` | |
| 5 | * clipboard flavor carries the markdown; `text/html` carries a sanitized | |
| 6 | * copy of the rendered fragment for rich-paste targets. | |
| 7 | */ | |
| 8 | ||
| 9 | const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]); | |
| 10 | const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"]; | |
| 11 | const SANITIZED_HTML_SELECTOR = [ | |
| 12 | "button", | |
| 13 | "input", | |
| 14 | "script", | |
| 15 | "style", | |
| 16 | "svg", | |
| 17 | '[aria-hidden="true"]', | |
| 18 | ...SKIPPED_CLASS_NAMES.map((className) => `.${className}`), | |
| 19 | ].join(", "); | |
| 20 | ||
| 21 | export interface MarkdownClipboardPayload { | |
| 22 | text: string; | |
| 23 | html: string; | |
| 24 | } | |
| 25 | ||
| 26 | function isSkippedElement(element: Element): boolean { | |
| 27 | if (SKIPPED_TAGS.has(element.tagName) || element.localName === "svg") return true; | |
| 28 | if (element.getAttribute("aria-hidden") === "true") return true; | |
| 29 | return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className)); | |
| 30 | } | |
| 31 | ||
| 32 | /** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */ | |
| 33 | function wrapInlineMarker(content: string, marker: string): string { | |
| 34 | const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content); | |
| 35 | const core = match?.[2] ?? ""; | |
| 36 | if (!core) return content; | |
| 37 | return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; | |
| 38 | } | |
| 39 | ||
| 40 | function wrapInlineCode(code: string): string { | |
| 41 | const longestRun = [...(code.match(/`+/g) ?? [])].reduce( | |
| 42 | (max, run) => Math.max(max, run.length), | |
| 43 | 0, | |
| 44 | ); | |
| 45 | const fence = "`".repeat(Math.max(1, longestRun + (longestRun > 0 ? 1 : 0))); | |
| 46 | const pad = code.startsWith("`") || code.endsWith("`") ? " " : ""; | |
| 47 | return `${fence}${pad}${code}${pad}${fence}`; | |
| 48 | } | |
| 49 | ||
| 50 | function codeFenceFor(code: string): string { | |
| 51 | const longestRun = [...(code.match(/`{3,}/g) ?? [])].reduce( | |
| 52 | (max, run) => Math.max(max, run.length), | |
| 53 | 0, | |
| 54 | ); | |
| 55 | return "`".repeat(Math.max(3, longestRun + 1)); | |
| 56 | } | |
| 57 | ||
| 58 | function resolveCodeBlockLanguage(pre: Element): string | null { | |
| 59 | const declared = | |
| 60 | pre.closest("[data-language]")?.getAttribute("data-language") ?? | |
| 61 | /(?:^|\s)language-(\S+)/.exec(pre.querySelector("code")?.className ?? "")?.[1] ?? | |
| 62 | null; | |
| 63 | return declared && declared !== "text" ? declared : null; | |
| 64 | } | |
| 65 | ||
| 66 | function serializeCodeBlock(pre: Element): string { | |
| 67 | const code = (pre.textContent ?? "").replace(/\n$/, ""); | |
| 68 | const fence = codeFenceFor(code); | |
| 69 | return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`; | |
| 70 | } | |
| 71 | ||
| 72 | function serializeTableCell(cell: Element): string { | |
| 73 | return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|"); | |
| 74 | } | |
| 75 | ||
| 76 | function tableSeparatorFor(headerCells: Element[]): string { | |
| 77 | const markers = headerCells.map((cell) => { | |
| 78 | const align = (cell as HTMLElement).style?.textAlign ?? cell.getAttribute("align") ?? ""; | |
| 79 | if (align === "center") return ":---:"; | |
| 80 | if (align === "right") return "---:"; | |
| 81 | return "---"; | |
| 82 | }); | |
| 83 | return `| ${markers.join(" | ")} |`; | |
| 84 | } | |
| 85 | ||
| 86 | function serializeTable(table: Element): string { | |
| 87 | const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")]; | |
| 88 | if (rows.length === 0) return ""; | |
| 89 | const lines: string[] = []; | |
| 90 | let emittedSeparator = false; | |
| 91 | for (const row of rows) { | |
| 92 | const cells = [...row.children].filter( | |
| 93 | (cell) => cell.tagName === "TH" || cell.tagName === "TD", | |
| 94 | ); | |
| 95 | if (cells.length === 0) continue; | |
| 96 | lines.push(`| ${cells.map((cell) => serializeTableCell(cell)).join(" | ")} |`); | |
| 97 | if (!emittedSeparator) { | |
| 98 | lines.push(tableSeparatorFor(cells)); | |
| 99 | emittedSeparator = true; | |
| 100 | } | |
| 101 | } | |
| 102 | return `${lines.join("\n")}\n\n`; | |
| 103 | } | |
| 104 | ||
| 105 | function serializeListItem(item: Element, ordered: boolean, index: number): string { | |
| 106 | const checkbox = item.querySelector('input[type="checkbox"]'); | |
| 107 | const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; | |
| 108 | const marker = ordered ? `${index}. ${task}` : `- ${task}`; | |
| 109 | let content = serializeChildren(item) | |
| 110 | .replace(/\n{3,}/g, "\n\n") | |
| 111 | .trim(); | |
| 112 | // Tight list items (no paragraph children) keep nested lists on adjacent lines. | |
| 113 | if (!item.querySelector(":scope > p")) { | |
| 114 | content = content.replace(/\n{2,}/g, "\n"); | |
| 115 | } | |
| 116 | const continuationIndent = " ".repeat(marker.length); | |
| 117 | const [first = "", ...rest] = content.split("\n"); | |
| 118 | return [ | |
| 119 | `${marker}${first}`, | |
| 120 | ...rest.map((line) => (line.length > 0 ? `${continuationIndent}${line}` : line)), | |
| 121 | ].join("\n"); | |
| 122 | } | |
| 123 | ||
| 124 | function serializeList(list: Element, ordered: boolean): string { | |
| 125 | const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1; | |
| 126 | const items = [...list.children].filter((child) => child.tagName === "LI"); | |
| 127 | if (items.length === 0) return ""; | |
| 128 | return `${items.map((item, index) => serializeListItem(item, ordered, start + index)).join("\n")}\n\n`; | |
| 129 | } | |
| 130 | ||
| 131 | function serializeBlockquote(quote: Element): string { | |
| 132 | const content = serializeChildren(quote) | |
| 133 | .replace(/\n{3,}/g, "\n\n") | |
| 134 | .trim(); | |
| 135 | if (!content) return ""; | |
| 136 | const quoted = content | |
| 137 | .split("\n") | |
| 138 | .map((line) => (line.length > 0 ? `> ${line}` : ">")) | |
| 139 | .join("\n"); | |
| 140 | return `${quoted}\n\n`; | |
| 141 | } | |
| 142 | ||
| 143 | function serializeDetails(details: Element): string { | |
| 144 | const summary = | |
| 145 | details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ?? | |
| 146 | "Details"; | |
| 147 | const contentNode = details.querySelector(":scope > * [data-markdown-details-content]"); | |
| 148 | const content = contentNode ? serializeChildren(contentNode).trim() : ""; | |
| 149 | const open = details.getAttribute("data-markdown-details-open") === "true" ? " open" : ""; | |
| 150 | return `<details${open}>\n<summary>${summary}</summary>${content ? `\n\n${content}` : ""}\n</details>\n\n`; | |
| 151 | } | |
| 152 | ||
| 153 | function serializeAnchor(anchor: Element): string { | |
| 154 | const markdownCopy = anchor.getAttribute("data-markdown-copy"); | |
| 155 | if (markdownCopy !== null) return markdownCopy; | |
| 156 | const content = serializeChildren(anchor); | |
| 157 | const href = anchor.getAttribute("href") ?? ""; | |
| 158 | if (!/^https?:\/\//i.test(href)) return content; | |
| 159 | const label = content.trim(); | |
| 160 | if (!label) return ""; | |
| 161 | if (label === href) return href; | |
| 162 | return `[${label}](${href})`; | |
| 163 | } | |
| 164 | ||
| 165 | function serializeChildren(node: Node): string { | |
| 166 | let out = ""; | |
| 167 | for (const child of node.childNodes) { | |
| 168 | out += serializeNode(child); | |
| 169 | } | |
| 170 | return out; | |
| 171 | } | |
| 172 | ||
| 173 | function serializeNode(node: Node): string { | |
| 174 | if (node.nodeType === Node.TEXT_NODE) { | |
| 175 | const text = node.textContent ?? ""; | |
| 176 | // Inter-block formatting whitespace from the renderer collapses to a | |
| 177 | // newline; real inline whitespace passes through untouched. | |
| 178 | if (text.includes("\n") && text.trim().length === 0) return "\n"; | |
| 179 | return text; | |
| 180 | } | |
| 181 | if (node.nodeType !== Node.ELEMENT_NODE) return ""; | |
| 182 | const element = node as Element; | |
| 183 | if (element.hasAttribute("data-markdown-details")) { | |
| 184 | return serializeDetails(element); | |
| 185 | } | |
| 186 | const markdownCopy = element.getAttribute("data-markdown-copy"); | |
| 187 | if (markdownCopy !== null) return markdownCopy; | |
| 188 | if (isSkippedElement(element)) return ""; | |
| 189 | ||
| 190 | const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1]; | |
| 191 | if (headingLevel) { | |
| 192 | return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element).trim()}\n\n`; | |
| 193 | } | |
| 194 | ||
| 195 | switch (element.tagName) { | |
| 196 | case "BR": | |
| 197 | return "\n"; | |
| 198 | case "HR": | |
| 199 | return "---\n\n"; | |
| 200 | case "P": | |
| 201 | return `${serializeChildren(element).trim()}\n\n`; | |
| 202 | case "PRE": | |
| 203 | return serializeCodeBlock(element); | |
| 204 | case "CODE": | |
| 205 | return wrapInlineCode(element.textContent ?? ""); | |
| 206 | case "STRONG": | |
| 207 | case "B": | |
| 208 | return wrapInlineMarker(serializeChildren(element), "**"); | |
| 209 | case "EM": | |
| 210 | case "I": | |
| 211 | return wrapInlineMarker(serializeChildren(element), "*"); | |
| 212 | case "DEL": | |
| 213 | case "S": | |
| 214 | return wrapInlineMarker(serializeChildren(element), "~~"); | |
| 215 | case "A": | |
| 216 | return serializeAnchor(element); | |
| 217 | case "IMG": { | |
| 218 | const alt = element.getAttribute("alt") ?? ""; | |
| 219 | const src = element.getAttribute("src") ?? ""; | |
| 220 | return alt && src ? `` : ""; | |
| 221 | } | |
| 222 | case "UL": | |
| 223 | return serializeList(element, false); | |
| 224 | case "OL": | |
| 225 | return serializeList(element, true); | |
| 226 | case "BLOCKQUOTE": | |
| 227 | return serializeBlockquote(element); | |
| 228 | case "TABLE": | |
| 229 | return serializeTable(element); | |
| 230 | case "DIV": | |
| 231 | case "SECTION": | |
| 232 | case "ARTICLE": { | |
| 233 | const content = serializeChildren(element); | |
| 234 | return content && !content.endsWith("\n") ? `${content}\n` : content; | |
| 235 | } | |
| 236 | default: | |
| 237 | return serializeChildren(element); | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /** Collapses serializer spacing artifacts without touching fenced code content. */ | |
| 242 | function tidyMarkdown(markdown: string): string { | |
| 243 | return markdown | |
| 244 | .split(/(```[\s\S]*?(?:```|$))/) | |
| 245 | .map((part, index) => | |
| 246 | index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"), | |
| 247 | ) | |
| 248 | .join("") | |
| 249 | .trim(); | |
| 250 | } | |
| 251 | ||
| 252 | export function serializeRenderedMarkdownFragment(container: Node): string { | |
| 253 | return tidyMarkdown(serializeChildren(container)); | |
| 254 | } | |
| 255 | ||
| 256 | export function serializeTableElementToMarkdown(table: Element): string { | |
| 257 | return serializeTable(table).trim(); | |
| 258 | } | |
| 259 | ||
| 260 | function csvCell(value: string): string { | |
| 261 | const normalized = value.replace(/\s+/g, " ").trim(); | |
| 262 | return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized; | |
| 263 | } | |
| 264 | ||
| 265 | export function serializeTableElementToCsv(table: Element): string { | |
| 266 | const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")]; | |
| 267 | const lines: string[] = []; | |
| 268 | for (const row of rows) { | |
| 269 | const cells = [...row.children].filter( | |
| 270 | (cell) => cell.tagName === "TH" || cell.tagName === "TD", | |
| 271 | ); | |
| 272 | if (cells.length === 0) continue; | |
| 273 | lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(",")); | |
| 274 | } | |
| 275 | return lines.join("\n"); | |
| 276 | } | |
| 277 | ||
| 278 | function sanitizedHtmlFrom(container: Element): string { | |
| 279 | for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) { | |
| 280 | if ( | |
| 281 | node.classList.contains("chat-markdown-file-link") || | |
| 282 | node.closest(".chat-markdown-file-link") | |
| 283 | ) { | |
| 284 | if (node.getAttribute("aria-hidden") === "true" || node.localName === "svg") { | |
| 285 | node.remove(); | |
| 286 | } | |
| 287 | continue; | |
| 288 | } | |
| 289 | node.remove(); | |
| 290 | } | |
| 291 | return `<meta charset="utf-8">${container.innerHTML}`; | |
| 292 | } | |
| 293 | ||
| 294 | export function chatMarkdownClipboardPayload( | |
| 295 | selection: Selection, | |
| 296 | ): MarkdownClipboardPayload | null { | |
| 297 | const texts: string[] = []; | |
| 298 | const htmls: string[] = []; | |
| 299 | for (let index = 0; index < selection.rangeCount; index += 1) { | |
| 300 | const range = selection.getRangeAt(index); | |
| 301 | if (range.collapsed) continue; | |
| 302 | const container = document.createElement("div"); | |
| 303 | container.appendChild(range.cloneContents()); | |
| 304 | const text = serializeRenderedMarkdownFragment(container); | |
| 305 | if (!text) continue; | |
| 306 | texts.push(text); | |
| 307 | htmls.push(sanitizedHtmlFrom(container)); | |
| 308 | } | |
| 309 | if (texts.length === 0) return null; | |
| 310 | return { text: texts.join("\n\n"), html: htmls.join("") }; | |
| 311 | } |
apps/web/src/vscode-icons.ts+29| ... | ... | @@ -69,6 +69,24 @@ function toLowercaseLookup(source: Record<string, string>): Record<string, strin |
| 69 | 69 | return lookup; |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | const extensionByLanguageId: Record<string, string> = {}; | |
| 73 | for (const [extension, languageId] of Object.entries(languageAssociations.extensionToLanguageId)) { | |
| 74 | const key = languageId.toLowerCase(); | |
| 75 | if (!(key in extensionByLanguageId)) { | |
| 76 | extensionByLanguageId[key] = extension.toLowerCase(); | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Synthesizes a file name whose icon resolution best represents a markdown | |
| 82 | * fence language id — handles both extension-style ids ("ts", "py") and | |
| 83 | * language ids ("typescript", "python", "shellscript"). | |
| 84 | */ | |
| 85 | export function syntheticFileNameForLanguageId(languageId: string): string { | |
| 86 | const normalized = languageId.toLowerCase(); | |
| 87 | return `file.${extensionByLanguageId[normalized] ?? normalized}`; | |
| 88 | } | |
| 89 | ||
| 72 | 90 | export function basenameOfPath(pathValue: string): string { |
| 73 | 91 | const slashIndex = pathValue.lastIndexOf("/"); |
| 74 | 92 | if (slashIndex === -1) return pathValue; |
| ... | ... | @@ -166,6 +184,17 @@ function resolveFolderDefinition(pathValue: string, theme: "light" | "dark"): st |
| 166 | 184 | ); |
| 167 | 185 | } |
| 168 | 186 | |
| 187 | /** True when the file name resolves to a real icon rather than the generic default. */ | |
| 188 | export function hasSpecificVscodeIconForFileName( | |
| 189 | fileName: string, | |
| 190 | theme: "light" | "dark", | |
| 191 | ): boolean { | |
| 192 | const definition = resolveFileDefinition(fileName, theme); | |
| 193 | return ( | |
| 194 | definition !== defaultDarkFileIconDefinition && definition !== defaultLightFileIconDefinition | |
| 195 | ); | |
| 196 | } | |
| 197 | ||
| 169 | 198 | export function getVscodeIconUrlForEntry( |
| 170 | 199 | pathValue: string, |
| 171 | 200 | kind: "file" | "directory", |
packages/shared/src/composerTrigger.test.ts+27-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { serializeComposerMentionPath } from "./composerTrigger.ts"; | |
| 3 | import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts"; | |
| 4 | 4 | |
| 5 | 5 | describe("serializeComposerMentionPath", () => { |
| 6 | 6 | it("keeps simple mention paths unquoted", () => { |
| ... | ... | @@ -15,3 +15,29 @@ describe("serializeComposerMentionPath", () => { |
| 15 | 15 | expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"'); |
| 16 | 16 | }); |
| 17 | 17 | }); |
| 18 | ||
| 19 | describe("serializeComposerFileLink", () => { | |
| 20 | it("uses the basename as the markdown label", () => { | |
| 21 | expect(serializeComposerFileLink("path/to/package.json")).toBe( | |
| 22 | "[package.json](path/to/package.json)", | |
| 23 | ); | |
| 24 | }); | |
| 25 | ||
| 26 | it("encodes markdown-sensitive destination characters", () => { | |
| 27 | expect(serializeComposerFileLink("docs/My File (draft).md")).toBe( | |
| 28 | "[My File (draft).md](docs/My%20File%20%28draft%29.md)", | |
| 29 | ); | |
| 30 | }); | |
| 31 | ||
| 32 | it("supports windows paths", () => { | |
| 33 | expect(serializeComposerFileLink("C:\\repo\\src\\index.ts")).toBe( | |
| 34 | "[index.ts](C:%5Crepo%5Csrc%5Cindex.ts)", | |
| 35 | ); | |
| 36 | }); | |
| 37 | ||
| 38 | it("preserves paths that legitimately start with an at sign", () => { | |
| 39 | expect(serializeComposerFileLink("@scope/package.json")).toBe( | |
| 40 | "[package.json](@scope/package.json)", | |
| 41 | ); | |
| 42 | }); | |
| 43 | }); |
packages/shared/src/composerTrigger.ts+23| ... | ... | @@ -17,6 +17,29 @@ export function serializeComposerMentionPath(path: string): string { |
| 17 | 17 | return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | function composerFileLinkBasename(path: string): string { | |
| 21 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | |
| 22 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | |
| 23 | } | |
| 24 | ||
| 25 | function escapeMarkdownLinkLabel(label: string): string { | |
| 26 | return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); | |
| 27 | } | |
| 28 | ||
| 29 | function encodeMarkdownLinkDestination(path: string): string { | |
| 30 | return encodeURI(path) | |
| 31 | .replaceAll("(", "%28") | |
| 32 | .replaceAll(")", "%29") | |
| 33 | .replaceAll("#", "%23") | |
| 34 | .replaceAll("?", "%3F") | |
| 35 | .replaceAll("\\", "%5C"); | |
| 36 | } | |
| 37 | ||
| 38 | export function serializeComposerFileLink(path: string): string { | |
| 39 | const label = escapeMarkdownLinkLabel(composerFileLinkBasename(path)); | |
| 40 | return `[${label}](${encodeMarkdownLinkDestination(path)})`; | |
| 41 | } | |
| 42 | ||
| 20 | 43 | function clampCursor(text: string, cursor: number): number { |
| 21 | 44 | if (!Number.isFinite(cursor)) return text.length; |
| 22 | 45 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); |
pnpm-lock.yaml+43| ... | ... | @@ -521,6 +521,15 @@ importers: |
| 521 | 521 | react-markdown: |
| 522 | 522 | specifier: ^10.1.0 |
| 523 | 523 | version: 10.1.0(@types/react@19.2.16)(react@19.2.6) |
| 524 | rehype-raw: | |
| 525 | specifier: ^7.0.0 | |
| 526 | version: 7.0.0 | |
| 527 | rehype-sanitize: | |
| 528 | specifier: ^6.0.0 | |
| 529 | version: 6.0.0 | |
| 530 | remark-breaks: | |
| 531 | specifier: ^4.0.0 | |
| 532 | version: 4.0.0 | |
| 524 | 533 | remark-gfm: |
| 525 | 534 | specifier: ^4.0.1 |
| 526 | 535 | version: 4.0.1 |
| ... | ... | @@ -6483,6 +6492,9 @@ packages: |
| 6483 | 6492 | hast-util-raw@9.1.0: |
| 6484 | 6493 | resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} |
| 6485 | 6494 | |
| 6495 | hast-util-sanitize@5.0.2: | |
| 6496 | resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} | |
| 6497 | ||
| 6486 | 6498 | hast-util-to-html@9.0.5: |
| 6487 | 6499 | resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} |
| 6488 | 6500 | |
| ... | ... | @@ -7238,6 +7250,9 @@ packages: |
| 7238 | 7250 | mdast-util-mdxjs-esm@2.0.1: |
| 7239 | 7251 | resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} |
| 7240 | 7252 | |
| 7253 | mdast-util-newline-to-break@2.0.0: | |
| 7254 | resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} | |
| 7255 | ||
| 7241 | 7256 | mdast-util-phrasing@4.1.0: |
| 7242 | 7257 | resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} |
| 7243 | 7258 | |
| ... | ... | @@ -8350,12 +8365,18 @@ packages: |
| 8350 | 8365 | rehype-raw@7.0.0: |
| 8351 | 8366 | resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} |
| 8352 | 8367 | |
| 8368 | rehype-sanitize@6.0.0: | |
| 8369 | resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} | |
| 8370 | ||
| 8353 | 8371 | rehype-stringify@10.0.1: |
| 8354 | 8372 | resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} |
| 8355 | 8373 | |
| 8356 | 8374 | rehype@13.0.2: |
| 8357 | 8375 | resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} |
| 8358 | 8376 | |
| 8377 | remark-breaks@4.0.0: | |
| 8378 | resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} | |
| 8379 | ||
| 8359 | 8380 | remark-gfm@4.0.1: |
| 8360 | 8381 | resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} |
| 8361 | 8382 | |
| ... | ... | @@ -15786,6 +15807,12 @@ snapshots: |
| 15786 | 15807 | web-namespaces: 2.0.1 |
| 15787 | 15808 | zwitch: 2.0.4 |
| 15788 | 15809 | |
| 15810 | hast-util-sanitize@5.0.2: | |
| 15811 | dependencies: | |
| 15812 | '@types/hast': 3.0.4 | |
| 15813 | '@ungap/structured-clone': 1.3.1 | |
| 15814 | unist-util-position: 5.0.0 | |
| 15815 | ||
| 15789 | 15816 | hast-util-to-html@9.0.5: |
| 15790 | 15817 | dependencies: |
| 15791 | 15818 | '@types/hast': 3.0.4 |
| ... | ... | @@ -16580,6 +16607,11 @@ snapshots: |
| 16580 | 16607 | transitivePeerDependencies: |
| 16581 | 16608 | - supports-color |
| 16582 | 16609 | |
| 16610 | mdast-util-newline-to-break@2.0.0: | |
| 16611 | dependencies: | |
| 16612 | '@types/mdast': 4.0.4 | |
| 16613 | mdast-util-find-and-replace: 3.0.2 | |
| 16614 | ||
| 16583 | 16615 | mdast-util-phrasing@4.1.0: |
| 16584 | 16616 | dependencies: |
| 16585 | 16617 | '@types/mdast': 4.0.4 |
| ... | ... | @@ -17962,6 +17994,11 @@ snapshots: |
| 17962 | 17994 | hast-util-raw: 9.1.0 |
| 17963 | 17995 | vfile: 6.0.3 |
| 17964 | 17996 | |
| 17997 | rehype-sanitize@6.0.0: | |
| 17998 | dependencies: | |
| 17999 | '@types/hast': 3.0.4 | |
| 18000 | hast-util-sanitize: 5.0.2 | |
| 18001 | ||
| 17965 | 18002 | rehype-stringify@10.0.1: |
| 17966 | 18003 | dependencies: |
| 17967 | 18004 | '@types/hast': 3.0.4 |
| ... | ... | @@ -17975,6 +18012,12 @@ snapshots: |
| 17975 | 18012 | rehype-stringify: 10.0.1 |
| 17976 | 18013 | unified: 11.0.5 |
| 17977 | 18014 | |
| 18015 | remark-breaks@4.0.0: | |
| 18016 | dependencies: | |
| 18017 | '@types/mdast': 4.0.4 | |
| 18018 | mdast-util-newline-to-break: 2.0.0 | |
| 18019 | unified: 11.0.5 | |
| 18020 | ||
| 17978 | 18021 | remark-gfm@4.0.1: |
| 17979 | 18022 | dependencies: |
| 17980 | 18023 | '@types/mdast': 4.0.4 |