authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-09 21:21:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-09 21:21:58-07:00
log7f741a56d176617d7f97865de24dbdf11ea3fd7e
tree4d5feec23ef47b0fd02faa1d9b6eea5ead379d79
parentcc9e81ac93b66b1d7ec308424fcd67bfc6d671c0
signature Signed by PGP key B5690EEEBB952194

Misc markdown styling improvements (#3017)

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,7 +10,7 @@ import type {
10import {10import {
11 detectComposerTrigger,11 detectComposerTrigger,
12 replaceTextRange,12 replaceTextRange,
13 serializeComposerMentionPath,13 serializeComposerFileLink,
14 type ComposerTrigger,14 type ComposerTrigger,
15} from "@t3tools/shared/composerTrigger";15} from "@t3tools/shared/composerTrigger";
16import { TextInputWrapper } from "expo-paste-input";16import { TextInputWrapper } from "expo-paste-input";
...@@ -419,7 +419,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -419,7 +419,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
419419
420 let replacement = "";420 let replacement = "";
421 if (item.type === "path") {421 if (item.type === "path") {
422 replacement = `@${serializeComposerMentionPath(item.path)} `;422 replacement = `${serializeComposerFileLink(item.path)} `;
423 } else if (item.type === "skill") {423 } else if (item.type === "skill") {
424 replacement = `$${item.skill.name} `;424 replacement = `$${item.skill.name} `;
425 } else if (item.type === "slash-command") {425 } else if (item.type === "slash-command") {
apps/web/package.json+3
...@@ -43,6 +43,9 @@...@@ -43,6 +43,9 @@
43 "react": "19.2.6",43 "react": "19.2.6",
44 "react-dom": "19.2.6",44 "react-dom": "19.2.6",
45 "react-markdown": "^10.1.0",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 "remark-gfm": "^4.0.1",49 "remark-gfm": "^4.0.1",
47 "tailwind-merge": "^3.4.0",50 "tailwind-merge": "^3.4.0",
48 "zustand": "^5.0.11"51 "zustand": "^5.0.11"
apps/web/src/components/ChatMarkdown.browser.tsx+543
...@@ -24,6 +24,7 @@ vi.mock("../localApi", () => ({...@@ -24,6 +24,7 @@ vi.mock("../localApi", () => ({
24}));24}));
2525
26import ChatMarkdown from "./ChatMarkdown";26import ChatMarkdown from "./ChatMarkdown";
27import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";
2728
28describe("ChatMarkdown", () => {29describe("ChatMarkdown", () => {
29 afterEach(() => {30 afterEach(() => {
...@@ -134,8 +135,550 @@ describe("ChatMarkdown", () => {...@@ -134,8 +135,550 @@ describe("ChatMarkdown", () => {
134 await expect.element(link).toBeInTheDocument();135 await expect.element(link).toBeInTheDocument();
135 await expect.element(link).toHaveAttribute("href", "https://openai.com/docs");136 await expect.element(link).toHaveAttribute("href", "https://openai.com/docs");
136 await expect.element(link).toHaveAttribute("target", "_blank");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 } finally {153 } finally {
138 await screen.unmount();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,9 +1,18 @@
1import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";1import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
2import { CheckIcon, CopyIcon } from "lucide-react";2import {
3 CheckIcon,
4 ChevronRightIcon,
5 CopyIcon,
6 GlobeIcon,
7 Maximize2Icon,
8 Minimize2Icon,
9 WrapTextIcon,
10} from "lucide-react";
3import type { ServerProviderSkill } from "@t3tools/contracts";11import type { ServerProviderSkill } from "@t3tools/contracts";
4import React, {12import React, {
5 Children,13 Children,
6 Suspense,14 Suspense,
15 type ClipboardEvent as ReactClipboardEvent,
7 type MouseEvent as ReactMouseEvent,16 type MouseEvent as ReactMouseEvent,
8 isValidElement,17 isValidElement,
9 use,18 use,
...@@ -18,16 +27,34 @@ import React, {...@@ -18,16 +27,34 @@ import React, {
18import type { Components } from "react-markdown";27import type { Components } from "react-markdown";
19import ReactMarkdown from "react-markdown";28import ReactMarkdown from "react-markdown";
20import { defaultUrlTransform } from "react-markdown";29import { defaultUrlTransform } from "react-markdown";
30import rehypeRaw from "rehype-raw";
31import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
32import remarkBreaks from "remark-breaks";
21import remarkGfm from "remark-gfm";33import remarkGfm from "remark-gfm";
22import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
23import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";34import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
35import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
36import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
37import {
38 getVscodeIconUrlForEntry,
39 hasSpecificVscodeIconForFileName,
40 syntheticFileNameForLanguageId,
41} from "../vscode-icons";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";42import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
43import { Button } from "./ui/button";
44import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";
45import { ScrollArea } from "./ui/scroll-area";
46import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
25import { stackedThreadToast, toastManager } from "./ui/toast";47import { stackedThreadToast, toastManager } from "./ui/toast";
26import { openInPreferredEditor } from "../editorPreferences";48import { openInPreferredEditor } from "../editorPreferences";
27import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";49import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
28import { fnv1a32 } from "../lib/diffRendering";50import { fnv1a32 } from "../lib/diffRendering";
29import { LRUCache } from "../lib/lruCache";51import { LRUCache } from "../lib/lruCache";
30import { useTheme } from "../hooks/useTheme";52import { useTheme } from "../hooks/useTheme";
53import {
54 chatMarkdownClipboardPayload,
55 serializeTableElementToCsv,
56 serializeTableElementToMarkdown,
57} from "../markdown-clipboard";
31import {58import {
32 normalizeMarkdownLinkDestination,59 normalizeMarkdownLinkDestination,
33 resolveMarkdownFileLinkMeta,60 resolveMarkdownFileLinkMeta,
...@@ -62,6 +89,9 @@ interface ChatMarkdownProps {...@@ -62,6 +89,9 @@ interface ChatMarkdownProps {
62 cwd: string | undefined;89 cwd: string | undefined;
63 isStreaming?: boolean;90 isStreaming?: boolean;
64 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;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}
6696
67const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];97const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
...@@ -74,6 +104,18 @@ const highlightedCodeCache = new LRUCache<string>(...@@ -74,6 +104,18 @@ const highlightedCodeCache = new LRUCache<string>(
74 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,104 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
75);105);
76const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();106const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
107const 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];
77119
78function extractFenceLanguage(className: string | undefined): string {120function extractFenceLanguage(className: string | undefined): string {
79 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);121 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
...@@ -82,6 +124,64 @@ function extractFenceLanguage(className: string | undefined): string {...@@ -82,6 +124,64 @@ function extractFenceLanguage(className: string | undefined): string {
82 return raw === "gitignore" ? "ini" : raw;124 return raw === "gitignore" ? "ini" : raw;
83}125}
84126
127const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i;
128const 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 */
131function 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
139function 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
157type MarkdownAstNode = {
158 type?: string;
159 meta?: unknown;
160 data?: {
161 hProperties?: Record<string, unknown>;
162 };
163 children?: MarkdownAstNode[];
164};
165
166function 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
85function nodeToPlainText(node: ReactNode): string {185function nodeToPlainText(node: ReactNode): string {
86 if (typeof node === "string" || typeof node === "number") {186 if (typeof node === "string" || typeof node === "number") {
87 return String(node);187 return String(node);
...@@ -146,9 +246,254 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {...@@ -146,9 +246,254 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
146 return promise;246 return promise;
147}247}
148248
149function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) {249function 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
382function 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 */
430function 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
479function 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 const [copied, setCopied] = useState(false);492 const [copied, setCopied] = useState(false);
493 const [wrapped, setWrapped] = useState(false);
151 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);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 const handleCopy = useCallback(() => {497 const handleCopy = useCallback(() => {
153 if (typeof navigator === "undefined" || navigator.clipboard == null) {498 if (typeof navigator === "undefined" || navigator.clipboard == null) {
154 return;499 return;
...@@ -179,16 +524,57 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo...@@ -179,16 +524,57 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo
179 );524 );
180525
181 return (526 return (
182 <div className="chat-markdown-codeblock leading-snug">527 <div
183 <button528 className="chat-markdown-codeblock leading-snug"
184 type="button"529 data-language={language}
185 className="chat-markdown-copy-button"530 data-wrap={wrapped ? "true" : "false"}
186 onClick={handleCopy}531 >
187 title={copied ? "Copied" : "Copy code"}532 <div className="chat-markdown-codeblock-header select-none">
188 aria-label={copied ? "Copied" : "Copy code"}533 <span className="chat-markdown-codeblock-title">
189 >534 <MarkdownCodeBlockTitleContent
190 {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}535 fenceTitle={fenceTitle}
191 </button>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 {children}578 {children}
193 </div>579 </div>
194 );580 );
...@@ -280,17 +666,15 @@ interface MarkdownFileLinkProps {...@@ -280,17 +666,15 @@ interface MarkdownFileLinkProps {
280 href: string;666 href: string;
281 targetPath: string;667 targetPath: string;
282 displayPath: string;668 displayPath: string;
283 filePath: string;
284 label: string;669 label: string;
670 copyMarkdown: string;
285 theme: "light" | "dark";671 theme: "light" | "dark";
286 className?: string | undefined;672 className?: string | undefined;
287}673}
288674
289const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;675const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
290const MARKDOWN_FILE_LINK_CLASS_NAME =676const MARKDOWN_FILE_LINK_CLASS_NAME =
291 "chat-markdown-file-link relative top-[2px] max-w-full no-underline";677 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";
292const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0";
293const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate";
294678
295function pathParentSegments(path: string): string[] {679function pathParentSegments(path: string): string[] {
296 const normalized = path.replaceAll("\\", "/");680 const normalized = path.replaceAll("\\", "/");
...@@ -367,12 +751,196 @@ function normalizeMarkdownLinkHrefKey(href: string): string {...@@ -367,12 +751,196 @@ function normalizeMarkdownLinkHrefKey(href: string): string {
367 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;751 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
368}752}
369753
754const 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. */
757const failedFaviconHosts = new Set<string>();
758
759function 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
770const 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
793function 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
799function 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
808function 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
828const SANITIZED_FRAGMENT_PREFIX = "user-content-";
829
830function decodeMarkdownFragmentId(href: string): string {
831 const encodedId = href.slice(1);
832 try {
833 return decodeURIComponent(encodedId);
834 } catch {
835 return encodedId;
836 }
837}
838
839function 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
847function 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
866function 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
888function 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
370const MarkdownFileLink = memo(function MarkdownFileLink({938const MarkdownFileLink = memo(function MarkdownFileLink({
371 href,939 href,
372 targetPath,940 targetPath,
373 displayPath,941 displayPath,
374 filePath,
375 label,942 label,
943 copyMarkdown,
376 theme,944 theme,
377 className,945 className,
378}: MarkdownFileLinkProps) {946}: MarkdownFileLinkProps) {
...@@ -467,7 +1035,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -467,7 +1035,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
467 render={1035 render={
468 <a1036 <a
469 href={href}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 onClick={(event) => {1040 onClick={(event) => {
472 event.preventDefault();1041 event.preventDefault();
473 event.stopPropagation();1042 event.stopPropagation();
...@@ -475,13 +1044,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -475,13 +1044,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
475 }}1044 }}
476 onContextMenu={handleContextMenu}1045 onContextMenu={handleContextMenu}
477 >1046 >
478 <VscodeEntryIcon1047 <FileTagChipContent path={targetPath} label={label} theme={theme} selectable />
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>
485 </a>1048 </a>
486 }1049 }
487 />1050 />
...@@ -505,8 +1068,8 @@ function areMarkdownFileLinkPropsEqual(...@@ -505,8 +1068,8 @@ function areMarkdownFileLinkPropsEqual(
505 previous.href === next.href &&1068 previous.href === next.href &&
506 previous.targetPath === next.targetPath &&1069 previous.targetPath === next.targetPath &&
507 previous.displayPath === next.displayPath &&1070 previous.displayPath === next.displayPath &&
508 previous.filePath === next.filePath &&
509 previous.label === next.label &&1071 previous.label === next.label &&
1072 previous.copyMarkdown === next.copyMarkdown &&
510 previous.theme === next.theme &&1073 previous.theme === next.theme &&
511 previous.className === next.className1074 previous.className === next.className
512 );1075 );
...@@ -517,6 +1080,8 @@ function ChatMarkdown({...@@ -517,6 +1080,8 @@ function ChatMarkdown({
517 cwd,1080 cwd,
518 isStreaming = false,1081 isStreaming = false,
519 skills = EMPTY_MARKDOWN_SKILLS,1082 skills = EMPTY_MARKDOWN_SKILLS,
1083 className,
1084 lineBreaks = false,
520}: ChatMarkdownProps) {1085}: ChatMarkdownProps) {
521 const { resolvedTheme } = useTheme();1086 const { resolvedTheme } = useTheme();
522 const diffThemeName = resolveDiffThemeName(resolvedTheme);1087 const diffThemeName = resolveDiffThemeName(resolvedTheme);
...@@ -542,6 +1107,17 @@ function ChatMarkdown({...@@ -542,6 +1107,17 @@ function ChatMarkdown({
542 const markdownUrlTransform = useCallback((href: string) => {1107 const markdownUrlTransform = useCallback((href: string) => {
543 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);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 const markdownComponents = useMemo<Components>(1121 const markdownComponents = useMemo<Components>(
546 () => ({1122 () => ({
547 p({ node: _node, children, ...props }) {1123 p({ node: _node, children, ...props }) {
...@@ -550,11 +1126,49 @@ function ChatMarkdown({...@@ -550,11 +1126,49 @@ function ChatMarkdown({
550 li({ node: _node, children, ...props }) {1126 li({ node: _node, children, ...props }) {
551 return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>;1127 return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>;
552 },1128 },
553 a({ node: _node, href, ...props }) {1129 a({ node, href, children, ...props }) {
554 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";1130 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
555 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;1131 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
556 if (!fileLinkMeta) {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 }
5591173
560 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);1174 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
...@@ -573,21 +1187,34 @@ function ChatMarkdown({...@@ -573,21 +1187,34 @@ function ChatMarkdown({
573 href={fileLinkMeta.targetPath}1187 href={fileLinkMeta.targetPath}
574 targetPath={fileLinkMeta.targetPath}1188 targetPath={fileLinkMeta.targetPath}
575 displayPath={fileLinkMeta.displayPath}1189 displayPath={fileLinkMeta.displayPath}
576 filePath={fileLinkMeta.filePath}
577 label={labelParts.join(" · ")}1190 label={labelParts.join(" · ")}
1191 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
578 theme={resolvedTheme}1192 theme={resolvedTheme}
579 className={props.className}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 const codeBlock = extractCodeBlock(children);1204 const codeBlock = extractCodeBlock(children);
585 if (!codeBlock) {1205 if (!codeBlock) {
586 return <pre {...props}>{children}</pre>;1206 return <pre {...props}>{children}</pre>;
587 }1207 }
5881208
1209 const language = extractFenceLanguage(codeBlock.className);
1210 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
589 return (1211 return (
590 <MarkdownCodeBlock code={codeBlock.code}>1212 <MarkdownCodeBlock
1213 code={codeBlock.code}
1214 language={language}
1215 fenceTitle={fenceTitle}
1216 theme={resolvedTheme}
1217 >
591 <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>1218 <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>
592 <Suspense fallback={<pre {...props}>{children}</pre>}>1219 <Suspense fallback={<pre {...props}>{children}</pre>}>
593 <SuspenseShikiCodeBlock1220 <SuspenseShikiCodeBlock
...@@ -613,9 +1240,20 @@ function ChatMarkdown({...@@ -613,9 +1240,20 @@ function ChatMarkdown({
613 );1240 );
6141241
615 return (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 <ReactMarkdown1250 <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 components={markdownComponents}1257 components={markdownComponents}
620 urlTransform={markdownUrlTransform}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,7 +2341,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2341 const runButton = await waitForElement(2341 const runButton = await waitForElement(
2342 () =>2342 () =>
2343 Array.from(document.querySelectorAll("button")).find(2343 Array.from(document.querySelectorAll("button")).find(
2344 (button) => button.title === "Run Lint",2344 (button) => button.getAttribute("aria-label") === "Run Lint",
2345 ) as HTMLButtonElement | null,2345 ) as HTMLButtonElement | null,
2346 "Unable to find Run Lint button.",2346 "Unable to find Run Lint button.",
2347 );2347 );
...@@ -2420,7 +2420,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2420,7 +2420,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2420 const runButton = await waitForElement(2420 const runButton = await waitForElement(
2421 () =>2421 () =>
2422 Array.from(document.querySelectorAll("button")).find(2422 Array.from(document.querySelectorAll("button")).find(
2423 (button) => button.title === "Run Test",2423 (button) => button.getAttribute("aria-label") === "Run Test",
2424 ) as HTMLButtonElement | null,2424 ) as HTMLButtonElement | null,
2425 "Unable to find Run Test button.",2425 "Unable to find Run Test button.",
2426 );2426 );
...@@ -3160,7 +3160,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3160,7 +3160,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
31603160
3161 try {3161 try {
3162 const initialModeButton = await waitForInteractionModeButton("Build");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);
31643165
3165 window.dispatchEvent(3166 window.dispatchEvent(
3166 new KeyboardEvent("keydown", {3167 new KeyboardEvent("keydown", {
...@@ -3172,7 +3173,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3172,7 +3173,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
3172 );3173 );
3173 await waitForLayout();3174 await waitForLayout();
31743175
3175 expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode");3176 expect((await waitForInteractionModeButton("Build")).getAttribute("aria-label")).toContain(
3177 "enter plan mode",
3178 );
31763179
3177 const composerEditor = await waitForComposerEditor();3180 const composerEditor = await waitForComposerEditor();
3178 composerEditor.focus();3181 composerEditor.focus();
...@@ -3187,7 +3190,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3187,7 +3190,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
31873190
3188 await vi.waitFor(3191 await vi.waitFor(
3189 async () => {3192 async () => {
3190 expect((await waitForInteractionModeButton("Plan")).title).toContain(3193 expect((await waitForInteractionModeButton("Plan")).getAttribute("aria-label")).toContain(
3191 "return to normal build mode",3194 "return to normal build mode",
3192 );3195 );
3193 },3196 },
...@@ -3205,7 +3208,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3205,7 +3208,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
32053208
3206 await vi.waitFor(3209 await vi.waitFor(
3207 async () => {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 { timeout: 8_000, interval: 16 },3215 { timeout: 8_000, interval: 16 },
3211 );3216 );
...@@ -3587,13 +3592,13 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3587,13 +3592,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
3587 },3592 },
3588 { timeout: 8_000, interval: 16 },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 await setComposerSelectionByTextOffsets({3596 await setComposerSelectionByTextOffsets({
3592 start: "hi package.json ".length,3597 start: "hi package.json ".length,
3593 end: "hi package.json there".length,3598 end: "hi package.json there".length,
3594 });3599 });
3595 await pressComposerKey("(");3600 await pressComposerKey("(");
3596 await waitForComposerText("hi @package.json (there)");3601 await waitForComposerText("hi [package.json](package.json) (there)");
3597 } finally {3602 } finally {
3598 await mounted.cleanup();3603 await mounted.cleanup();
3599 }3604 }
...@@ -3625,6 +3630,47 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3625,6 +3630,47 @@ describe("ChatView timeline estimator parity (full app)", () => {
3625 }3630 }
3626 });3631 });
36273632
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 it("shows runtime mode descriptions in the desktop composer access select", async () => {3674 it("shows runtime mode descriptions in the desktop composer access select", async () => {
3629 setDraftThreadWithoutWorktree();3675 setDraftThreadWithoutWorktree();
36303676
apps/web/src/components/CommandPalette.tsx+70-53
...@@ -1570,61 +1570,78 @@ function OpenCommandPaletteDialog() {...@@ -1570,61 +1570,78 @@ function OpenCommandPaletteDialog() {
1570 onKeyDown={handleKeyDown}1570 onKeyDown={handleKeyDown}
1571 />1571 />
1572 {addProjectCloneFlow?.step === "repository" ? (1572 {addProjectCloneFlow?.step === "repository" ? (
1573 <Button1573 <Tooltip>
1574 variant="outline"1574 <TooltipTrigger
1575 size="xs"1575 render={
1576 tabIndex={-1}1576 <Button
1577 className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2"1577 variant="outline"
1578 aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}1578 size="xs"
1579 disabled={!canSubmitRemoteProjectFlow}1579 tabIndex={-1}
1580 onMouseDown={(event) => {1580 className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2"
1581 event.preventDefault();1581 aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
1582 }}1582 disabled={!canSubmitRemoteProjectFlow}
1583 onClick={() => {1583 onMouseDown={(event) => {
1584 void submitAddProjectCloneFlow();1584 event.preventDefault();
1585 }}1585 }}
1586 title={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}1586 onClick={() => {
1587 >1587 void submitAddProjectCloneFlow();
1588 <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>1588 }}
1589 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">1589 />
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;
1612 }1590 }
1613 if (isCloneDestinationStep) {1591 >
1614 void submitAddProjectCloneFlow(resolvedAddProjectPath);1592 <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>
1615 } else {1593 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1616 void handleAddProject(resolvedAddProjectPath);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 }}1633 >
1619 title={`${submitActionLabel} (${addShortcutLabel})`}1634 <span>
1620 >1635 {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
1621 <span>1636 </span>
1622 {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}1637 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1623 </span>1638 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
1624 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">1639 </KbdGroup>
1625 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>1640 </TooltipTrigger>
1626 </KbdGroup>1641 <TooltipPopup side="top">
1627 </Button>1642 {submitActionLabel} ({addShortcutLabel})
1643 </TooltipPopup>
1644 </Tooltip>
1628 ) : null}1645 ) : null}
1629 </div>1646 </div>
1630 <CommandPanel className="max-h-[min(28rem,70vh)]">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,7 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
6import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";6import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
7import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";7import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
8import { type ServerProviderSkill } from "@t3tools/contracts";8import { type ServerProviderSkill } from "@t3tools/contracts";
9import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";9import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
10import {10import {
11 $applyNodeReplacement,11 $applyNodeReplacement,
12 $createRangeSelection,12 $createRangeSelection,
...@@ -64,14 +64,14 @@ import {...@@ -64,14 +64,14 @@ import {
64 type TerminalContextDraft,64 type TerminalContextDraft,
65} from "~/lib/terminalContext";65} from "~/lib/terminalContext";
66import { cn } from "~/lib/utils";66import { cn } from "~/lib/utils";
67import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons";67import { basenameOfPath } from "~/vscode-icons";
68import {68import {
69 COMPOSER_INLINE_CHIP_CLASS_NAME,
70 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,69 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
71 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,70 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
72 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,71 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
73 SKILL_CHIP_ICON_SVG,72 SKILL_CHIP_ICON_SVG,
74} from "./composerInlineChip";73} from "./composerInlineChip";
74import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
75import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";75import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
76import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";76import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
77import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";77import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -132,19 +132,12 @@ function ComposerMentionDecorator(props: { path: string }) {...@@ -132,19 +132,12 @@ function ComposerMentionDecorator(props: { path: string }) {
132 const theme = resolvedThemeFromDocument();132 const theme = resolvedThemeFromDocument();
133 const chip = (133 const chip = (
134 <span134 <span
135 className={COMPOSER_INLINE_CHIP_CLASS_NAME}135 className={FILE_TAG_CHIP_CLASS_NAME}
136 contentEditable={false}136 contentEditable={false}
137 spellCheck={false}137 spellCheck={false}
138 data-composer-mention-chip="true"138 data-composer-mention-chip="true"
139 >139 >
140 <img140 <FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} />
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>
148 </span>141 </span>
149 );142 );
150143
...@@ -175,8 +168,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {...@@ -175,8 +168,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
175168
176 constructor(path: string, key?: NodeKey) {169 constructor(path: string, key?: NodeKey) {
177 super(key);170 super(key);
178 const normalizedPath = path.startsWith("@") ? path.slice(1) : path;171 this.__path = path;
179 this.__path = normalizedPath;
180 }172 }
181173
182 override exportJSON(): SerializedComposerMentionNode {174 override exportJSON(): SerializedComposerMentionNode {
...@@ -199,7 +191,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {...@@ -199,7 +191,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
199 }191 }
200192
201 override getTextContent(): string {193 override getTextContent(): string {
202 return `@${serializeComposerMentionPath(this.__path)}`;194 return serializeComposerFileLink(this.__path);
203 }195 }
204196
205 override isInline(): true {197 override isInline(): true {
apps/web/src/components/DiffPanel.tsx+108-71
...@@ -42,6 +42,7 @@ import { useSettings } from "../hooks/useSettings";...@@ -42,6 +42,7 @@ import { useSettings } from "../hooks/useSettings";
42import { formatShortTimestamp } from "../timestampFormat";42import { formatShortTimestamp } from "../timestampFormat";
43import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";43import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
44import { ToggleGroup, Toggle } from "./ui/toggle-group";44import { ToggleGroup, Toggle } from "./ui/toggle-group";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
4546
46type DiffRenderMode = "stacked" | "split";47type DiffRenderMode = "stacked" | "split";
47type DiffThemeType = "light" | "dark";48type DiffThemeType = "light" | "dark";
...@@ -460,35 +461,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -460,35 +461,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
460 </div>461 </div>
461 </button>462 </button>
462 {orderedTurnDiffSummaries.map((summary) => (463 {orderedTurnDiffSummaries.map((summary) => (
463 <button464 <Tooltip key={summary.turnId}>
464 key={summary.turnId}465 <TooltipTrigger
465 type="button"466 render={
466 className="shrink-0 rounded-md"467 <button
467 onClick={() => selectTurn(summary.turnId)}468 type="button"
468 title={summary.turnId}469 aria-label={summary.turnId}
469 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}470 className="shrink-0 rounded-md"
470 >471 onClick={() => selectTurn(summary.turnId)}
471 <div472 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
472 className={cn(473 />
473 "rounded-md border px-2 py-1 text-left transition-colors",474 }
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 )}
478 >475 >
479 <div className="flex items-center gap-1">476 <div
480 <span className="text-[10px] leading-tight font-medium">477 className={cn(
481 Turn{" "}478 "rounded-md border px-2 py-1 text-left transition-colors",
482 {summary.checkpointTurnCount ??479 summary.turnId === selectedTurn?.turnId
483 inferredCheckpointTurnCountByTurnId[summary.turnId] ??480 ? "border-border bg-accent text-accent-foreground"
484 "?"}481 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
485 </span>482 )}
486 <span className="text-[9px] leading-tight opacity-70">483 >
487 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}484 <div className="flex items-center gap-1">
488 </span>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 </div>495 </div>
490 </div>496 </TooltipTrigger>
491 </button>497 <TooltipPopup side="top">{summary.turnId}</TooltipPopup>
498 </Tooltip>
492 ))}499 ))}
493 </div>500 </div>
494 </div>501 </div>
...@@ -512,30 +519,50 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -512,30 +519,50 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
512 <Columns2Icon className="size-3" />519 <Columns2Icon className="size-3" />
513 </Toggle>520 </Toggle>
514 </ToggleGroup>521 </ToggleGroup>
515 <Toggle522 <Tooltip>
516 aria-label={diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}523 <TooltipTrigger
517 title={diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"}524 render={
518 variant="outline"525 <Toggle
519 size="xs"526 aria-label={
520 pressed={diffWordWrap}527 diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"
521 onPressedChange={(pressed) => {528 }
522 setDiffWordWrap(Boolean(pressed));529 variant="outline"
523 }}530 size="xs"
524 >531 pressed={diffWordWrap}
525 <TextWrapIcon className="size-3" />532 onPressedChange={(pressed) => {
526 </Toggle>533 setDiffWordWrap(Boolean(pressed));
527 <Toggle534 }}
528 aria-label={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}535 />
529 title={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}536 }
530 variant="outline"537 >
531 size="xs"538 <TextWrapIcon className="size-3" />
532 pressed={diffIgnoreWhitespace}539 </TooltipTrigger>
533 onPressedChange={(pressed) => {540 <TooltipPopup side="top">
534 setDiffIgnoreWhitespace(Boolean(pressed));541 {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"}
535 }}542 </TooltipPopup>
536 >543 </Tooltip>
537 <PilcrowIcon className="size-3" />544 <Tooltip>
538 </Toggle>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 </div>566 </div>
540 </>567 </>
541 );568 );
...@@ -609,26 +636,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -609,26 +636,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
609 <FileDiff636 <FileDiff
610 fileDiff={fileDiff}637 fileDiff={fileDiff}
611 renderHeaderPrefix={() => (638 renderHeaderPrefix={() => (
612 <button639 <Tooltip>
613 type="button"640 <TooltipTrigger
614 className={cn(641 render={
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",642 <button
616 getDiffCollapseIconClassName(fileDiff),643 type="button"
617 )}644 className={cn(
618 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}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",
619 aria-expanded={!collapsed}646 getDiffCollapseIconClassName(fileDiff),
620 title={collapsed ? "Expand diff" : "Collapse diff"}647 )}
621 onClick={(event) => {648 aria-label={
622 event.stopPropagation();649 collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
623 toggleDiffFileCollapsed(fileKey);650 }
624 }}651 aria-expanded={!collapsed}
625 >652 onClick={(event) => {
626 {collapsed ? (653 event.stopPropagation();
627 <ChevronRightIcon className="size-4" />654 toggleDiffFileCollapsed(fileKey);
628 ) : (655 }}
629 <ChevronDownIcon className="size-4" />656 />
630 )}657 }
631 </button>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 options={{670 options={{
634 collapsed,671 collapsed,
apps/web/src/components/ProjectScriptsControl.tsx+32-17
...@@ -53,6 +53,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"...@@ -53,6 +53,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
53import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";53import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
54import { Switch } from "./ui/switch";54import { Switch } from "./ui/switch";
55import { Textarea } from "./ui/textarea";55import { Textarea } from "./ui/textarea";
56import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
5657
57const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [58const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
58 { id: "play", label: "Play" },59 { id: "play", label: "Play" },
...@@ -219,17 +220,24 @@ export default function ProjectScriptsControl({...@@ -219,17 +220,24 @@ export default function ProjectScriptsControl({
219 <>220 <>
220 {primaryScript ? (221 {primaryScript ? (
221 <Group aria-label="Project scripts">222 <Group aria-label="Project scripts">
222 <Button223 <Tooltip>
223 size="xs"224 <TooltipTrigger
224 variant="outline"225 render={
225 onClick={() => onRunScript(primaryScript)}226 <Button
226 title={`Run ${primaryScript.name}`}227 size="xs"
227 >228 variant="outline"
228 <ScriptIcon icon={primaryScript.icon} />229 aria-label={`Run ${primaryScript.name}`}
229 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">230 onClick={() => onRunScript(primaryScript)}
230 {primaryScript.name}231 />
231 </span>232 }
232 </Button>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 <GroupSeparator className="hidden @3xl/header-actions:block" />241 <GroupSeparator className="hidden @3xl/header-actions:block" />
234 <Menu highlightItemOnHover={false}>242 <Menu highlightItemOnHover={false}>
235 <MenuTrigger243 <MenuTrigger
...@@ -289,12 +297,19 @@ export default function ProjectScriptsControl({...@@ -289,12 +297,19 @@ export default function ProjectScriptsControl({
289 </Menu>297 </Menu>
290 </Group>298 </Group>
291 ) : (299 ) : (
292 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">300 <Tooltip>
293 <PlusIcon className="size-3.5" />301 <TooltipTrigger
294 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">302 render={
295 Add action303 <Button size="xs" variant="outline" aria-label="Add action" onClick={openAddDialog} />
296 </span>304 }
297 </Button>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 )}
299314
300 <Dialog315 <Dialog
apps/web/src/components/Sidebar.tsx+47-28
...@@ -608,14 +608,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -608,14 +608,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
608 </div>608 </div>
609 <div className="ml-auto flex shrink-0 items-center gap-1.5">609 <div className="ml-auto flex shrink-0 items-center gap-1.5">
610 {terminalStatus && (610 {terminalStatus && (
611 <span611 <Tooltip>
612 role="img"612 <TooltipTrigger
613 aria-label={terminalStatus.label}613 render={
614 title={terminalStatus.label}614 <span
615 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}615 role="img"
616 >616 aria-label={terminalStatus.label}
617 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />617 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
618 </span>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 <div626 <div
621 className={`flex min-w-12 justify-end ${627 className={`flex min-w-12 justify-end ${
...@@ -691,12 +697,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -691,12 +697,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
691 </Tooltip>697 </Tooltip>
692 )}698 )}
693 {jumpLabel ? (699 {jumpLabel ? (
694 <span700 <Tooltip>
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"701 <TooltipTrigger
696 title={jumpLabel}702 render={
697 >703 <span
698 {jumpLabel}704 aria-label={jumpLabel}
699 </span>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 <span714 <span
702 className={`text-[10px] ${715 className={`text-[10px] ${
...@@ -1994,20 +2007,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1994,20 +2007,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1994 onContextMenu={handleProjectButtonContextMenu}2007 onContextMenu={handleProjectButtonContextMenu}
1995 >2008 >
1996 {!projectExpanded && projectStatus ? (2009 {!projectExpanded && projectStatus ? (
1997 <span2010 <Tooltip>
1998 aria-hidden="true"2011 <TooltipTrigger
1999 title={projectStatus.label}2012 render={
2000 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}2013 <span
2001 >2014 aria-label={projectStatus.label}
2002 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">2015 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
2003 <span2016 />
2004 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${2017 }
2005 projectStatus.pulse ? "animate-pulse" : ""2018 >
2006 }`}2019 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2007 />2020 <span
2008 </span>2021 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
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" />2022 projectStatus.pulse ? "animate-pulse" : ""
2010 </span>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 <ChevronRightIcon2031 <ChevronRightIcon
2013 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${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,32 +102,45 @@ export function ThreadStatusLabel({
102}) {102}) {
103 if (compact) {103 if (compact) {
104 return (104 return (
105 <span105 <Tooltip>
106 title={status.label}106 <TooltipTrigger
107 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}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 <span135 <span
110 className={`size-[9px] rounded-full ${status.dotClass} ${136 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
111 status.pulse ? "animate-pulse" : ""137 status.pulse ? "animate-pulse" : ""
112 }`}138 }`}
113 />139 />
114 <span className="sr-only">{status.label}</span>140 <span className="hidden md:inline">{status.label}</span>
115 </span>141 </TooltipTrigger>
116 );142 <TooltipPopup side="top">{status.label}</TooltipPopup>
117 }143 </Tooltip>
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>
131 );144 );
132}145}
133146
...@@ -220,14 +233,20 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma...@@ -220,14 +233,20 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
220 return (233 return (
221 <span className="inline-flex shrink-0 items-center gap-1.5">234 <span className="inline-flex shrink-0 items-center gap-1.5">
222 {terminalStatus ? (235 {terminalStatus ? (
223 <span236 <Tooltip>
224 role="img"237 <TooltipTrigger
225 aria-label={terminalStatus.label}238 render={
226 title={terminalStatus.label}239 <span
227 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}240 role="img"
228 >241 aria-label={terminalStatus.label}
229 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />242 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
230 </span>243 />
244 }
245 >
246 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
247 </TooltipTrigger>
248 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
249 </Tooltip>
231 ) : null}250 ) : null}
232 {isRemoteThread ? (251 {isRemoteThread ? (
233 <Tooltip>252 <Tooltip>
apps/web/src/components/chat/ChatComposer.tsx+91-72
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,17 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
18 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,18 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";20import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
21import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";21import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
22import {22import {
23 memo,23 memo,
...@@ -187,6 +187,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -187,6 +187,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
187}) {187}) {
188 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];188 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
189 const RuntimeModeIcon = runtimeModeOption.icon;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`;
190197
191 return (198 return (
192 <>199 <>
...@@ -194,86 +201,98 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -194,86 +201,98 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
194201
195 {props.showInteractionModeToggle ? (202 {props.showInteractionModeToggle ? (
196 <>203 <>
197 <Button204 <Tooltip>
198 variant="ghost"205 <TooltipTrigger
199 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"206 render={
200 size="sm"207 <Button
201 type="button"208 variant="ghost"
202 onClick={props.onToggleInteractionMode}209 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
203 title={210 size="sm"
204 props.interactionMode === "plan"211 type="button"
205 ? "Plan mode — click to return to normal build mode"212 onClick={props.onToggleInteractionMode}
206 : "Default mode — click to enter plan mode"213 aria-label={interactionModeTooltip}
207 }214 />
208 >215 }
209 <BotIcon />216 >
210 <span className="sr-only sm:not-sr-only">217 <BotIcon />
211 {props.interactionMode === "plan" ? "Plan" : "Build"}218 <span className="sr-only sm:not-sr-only">
212 </span>219 {props.interactionMode === "plan" ? "Plan" : "Build"}
213 </Button>220 </span>
221 </TooltipTrigger>
222 <TooltipPopup side="top">{interactionModeTooltip}</TooltipPopup>
223 </Tooltip>
214224
215 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />225 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
216 </>226 </>
217 ) : null}227 ) : null}
218228
219 <Select229 <Tooltip>
220 value={props.runtimeMode}230 <Select
221 onValueChange={(value) => props.onRuntimeModeChange(value!)}231 value={props.runtimeMode}
222 >232 onValueChange={(value) => props.onRuntimeModeChange(value!)}
223 <SelectTrigger
224 variant="ghost"
225 size="sm"
226 className="font-medium"
227 aria-label="Runtime mode"
228 title={runtimeModeOption.description}
229 >233 >
230 <RuntimeModeIcon className="size-4" />234 <TooltipTrigger
231 <SelectValue>{runtimeModeOption.label}</SelectValue>235 render={
232 </SelectTrigger>236 <SelectTrigger
233 <SelectPopup alignItemWithTrigger={false}>237 variant="ghost"
234 {runtimeModeOptions.map((mode) => {238 size="sm"
235 const option = runtimeModeConfig[mode];239 className="font-medium"
236 const OptionIcon = option.icon;240 aria-label="Runtime mode"
237 return (241 />
238 <SelectItem key={mode} value={mode} className="min-w-64 py-2">242 }
239 <div className="grid min-w-0 gap-0.5">243 >
240 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">244 <RuntimeModeIcon className="size-4" />
241 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />245 <SelectValue>{runtimeModeOption.label}</SelectValue>
242 {option.label}246 </TooltipTrigger>
243 </span>247 <SelectPopup alignItemWithTrigger={false}>
244 <span className="text-muted-foreground text-xs leading-4">248 {runtimeModeOptions.map((mode) => {
245 {option.description}249 const option = runtimeModeConfig[mode];
246 </span>250 const OptionIcon = option.icon;
247 </div>251 return (
248 </SelectItem>252 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
249 );253 <div className="grid min-w-0 gap-0.5">
250 })}254 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
251 </SelectPopup>255 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
252 </Select>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>
253269
254 {props.showPlanToggle ? (270 {props.showPlanToggle ? (
255 <>271 <>
256 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />272 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
257 <Button273 <Tooltip>
258 variant="ghost"274 <TooltipTrigger
259 className={cn(275 render={
260 "shrink-0 whitespace-nowrap px-2 sm:px-3",276 <Button
261 props.planSidebarOpen277 variant="ghost"
262 ? "text-blue-400 hover:text-blue-300"278 className={cn(
263 : "text-muted-foreground/70 hover:text-foreground/80",279 "shrink-0 whitespace-nowrap px-2 sm:px-3",
264 )}280 props.planSidebarOpen
265 size="sm"281 ? "text-blue-400 hover:text-blue-300"
266 type="button"282 : "text-muted-foreground/70 hover:text-foreground/80",
267 onClick={props.onTogglePlanSidebar}283 )}
268 title={284 size="sm"
269 props.planSidebarOpen285 type="button"
270 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`286 onClick={props.onTogglePlanSidebar}
271 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`287 aria-label={planSidebarTooltip}
272 }288 />
273 >289 }
274 <ListTodoIcon />290 >
275 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>291 <ListTodoIcon />
276 </Button>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 ) : null}297 ) : null}
279 </>298 </>
...@@ -1472,7 +1491,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1472,7 +1491,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1472 const { snapshot, trigger } = resolveActiveComposerTrigger();1491 const { snapshot, trigger } = resolveActiveComposerTrigger();
1473 if (!trigger) return;1492 if (!trigger) return;
1474 if (item.type === "path") {1493 if (item.type === "path") {
1475 const replacement = `@${serializeComposerMentionPath(item.path)} `;1494 const replacement = `${serializeComposerFileLink(item.path)} `;
1476 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(1495 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1477 snapshot.value,1496 snapshot.value,
1478 trigger.rangeEnd,1497 trigger.rangeEnd,
apps/web/src/components/chat/ChatHeader.tsx+13-6
...@@ -92,12 +92,19 @@ export const ChatHeader = memo(function ChatHeader({...@@ -92,12 +92,19 @@ export const ChatHeader = memo(function ChatHeader({
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">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 <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">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 <SidebarTrigger className="size-7 shrink-0 md:hidden" />94 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
95 <h295 <Tooltip>
96 className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground"96 <TooltipTrigger
97 title={activeThreadTitle}97 render={
98 >98 <h2
99 {activeThreadTitle}99 aria-label={activeThreadTitle}
100 </h2>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 {activeProjectName && (108 {activeProjectName && (
102 <Badge109 <Badge
103 variant="outline"110 variant="outline"
apps/web/src/components/chat/FileTagChip.tsx created+39
...@@ -0,0 +1,39 @@
1import { inferEntryKindFromPath } from "../../vscode-icons";
2import {
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";
9import { VscodeEntryIcon } from "./VscodeEntryIcon";
10
11export const FILE_TAG_CHIP_CLASS_NAME = COMPOSER_INLINE_CHIP_CLASS_NAME;
12export const CHAT_FILE_TAG_CHIP_CLASS_NAME = CHAT_INLINE_CHIP_CLASS_NAME;
13
14export 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,6 +94,21 @@ function buildUserTimelineEntry(text: string) {
94 };94 };
95}95}
9696
97function 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
97describe("MessagesTimeline", () => {112describe("MessagesTimeline", () => {
98 afterEach(() => {113 afterEach(() => {
99 scrollToEndSpy.mockReset();114 scrollToEndSpy.mockReset();
...@@ -128,6 +143,59 @@ describe("MessagesTimeline", () => {...@@ -128,6 +143,59 @@ describe("MessagesTimeline", () => {
128 .element(page.getByText("Send a message to start the conversation."))143 .element(page.getByText("Send a message to start the conversation."))
129 .not.toBeInTheDocument();144 .not.toBeInTheDocument();
130 await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();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 } finally {199 } finally {
132 await screen.unmount();200 await screen.unmount();
133 }201 }
...@@ -261,4 +329,61 @@ describe("MessagesTimeline", () => {...@@ -261,4 +329,61 @@ describe("MessagesTimeline", () => {
261 await screen.unmount();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,7 +188,8 @@ describe("MessagesTimeline", () => {
188188
189 expect(markup).toContain("Terminal 1 lines 1-5");189 expect(markup).toContain("Terminal 1 lines 1-5");
190 expect(markup).toContain("lucide-terminal");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 expect(markup).toContain("Show full message");193 expect(markup).toContain("Show full message");
193 }, 20_000);194 }, 20_000);
194195
apps/web/src/components/chat/MessagesTimeline.tsx+124-70
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 use,10 use,
11 useCallback,11 useCallback,
12 useEffect,12 useEffect,
13 Fragment,
13 useMemo,14 useMemo,
14 useRef,15 useRef,
15 useState,16 useState,
...@@ -380,6 +381,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -380,6 +381,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
380 text={displayedUserMessage.visibleText}381 text={displayedUserMessage.visibleText}
381 terminalContexts={terminalContexts}382 terminalContexts={terminalContexts}
382 skills={ctx.skills}383 skills={ctx.skills}
384 markdownCwd={ctx.markdownCwd}
383 footer={385 footer={
384 <>386 <>
385 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">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,16 +406,23 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
404 const activity = use(TimelineRowActivityCtx);406 const activity = use(TimelineRowActivityCtx);
405407
406 return (408 return (
407 <Button409 <Tooltip>
408 type="button"410 <TooltipTrigger
409 size="xs"411 render={
410 variant="outline"412 <Button
411 disabled={activity.isRevertingCheckpoint || activity.isWorking}413 type="button"
412 onClick={() => ctx.onRevertUserMessage(messageId)}414 size="xs"
413 title="Revert to this message"415 variant="outline"
414 >416 disabled={activity.isRevertingCheckpoint || activity.isWorking}
415 <Undo2Icon className="size-3" />417 onClick={() => ctx.onRevertUserMessage(messageId)}
416 </Button>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}
419428
...@@ -781,6 +790,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop...@@ -781,6 +790,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
781 text: string;790 text: string;
782 terminalContexts: ParsedTerminalContextEntry[];791 terminalContexts: ParsedTerminalContextEntry[];
783 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;792 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
793 markdownCwd: string | undefined;
784 footer?: ReactNode;794 footer?: ReactNode;
785}) {795}) {
786 const [expanded, setExpanded] = useState(false);796 const [expanded, setExpanded] = useState(false);
...@@ -810,6 +820,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop...@@ -810,6 +820,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
810 text={props.text}820 text={props.text}
811 terminalContexts={props.terminalContexts}821 terminalContexts={props.terminalContexts}
812 skills={props.skills}822 skills={props.skills}
823 markdownCwd={props.markdownCwd}
813 />824 />
814 </div>825 </div>
815 ) : null}826 ) : null}
...@@ -847,7 +858,34 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -847,7 +858,34 @@ const UserMessageBody = memo(function UserMessageBody(props: {
847 text: string;858 text: string;
848 terminalContexts: ParsedTerminalContextEntry[];859 terminalContexts: ParsedTerminalContextEntry[];
849 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;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 const reviewCommentSegments = parseReviewCommentMessageSegments(props.text);889 const reviewCommentSegments = parseReviewCommentMessageSegments(props.text);
852 if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) {890 if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) {
853 return (891 return (
...@@ -855,8 +893,14 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -855,8 +893,14 @@ const UserMessageBody = memo(function UserMessageBody(props: {
855 {reviewCommentSegments.map((segment) =>893 {reviewCommentSegments.map((segment) =>
856 segment.kind === "text" ? (894 segment.kind === "text" ? (
857 segment.text.trim().length > 0 ? (895 segment.text.trim().length > 0 ? (
858 <div key={segment.id} className="whitespace-pre-wrap wrap-break-word">896 <div key={segment.id} className="wrap-break-word">
859 <SkillInlineText text={segment.text.trim()} skills={props.skills} />897 <ChatMarkdown
898 text={segment.text.trim()}
899 cwd={props.markdownCwd}
900 skills={props.skills}
901 className="text-foreground"
902 lineBreaks
903 />
860 </div>904 </div>
861 ) : null905 ) : null
862 ) : (906 ) : (
...@@ -887,9 +931,10 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -887,9 +931,10 @@ const UserMessageBody = memo(function UserMessageBody(props: {
887 }931 }
888 if (matchIndex > cursor) {932 if (matchIndex > cursor) {
889 inlineNodes.push(933 inlineNodes.push(
890 <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}>934 renderInlineMarkdownSegment(
891 <SkillInlineText text={props.text.slice(cursor, matchIndex)} skills={props.skills} />935 props.text.slice(cursor, matchIndex),
892 </span>,936 `user-terminal-context-inline-before:${context.header}:${cursor}`,
937 ),
893 );938 );
894 }939 }
895 inlineNodes.push(940 inlineNodes.push(
...@@ -904,9 +949,10 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -904,9 +949,10 @@ const UserMessageBody = memo(function UserMessageBody(props: {
904 if (inlineNodes.length > 0) {949 if (inlineNodes.length > 0) {
905 if (cursor < props.text.length) {950 if (cursor < props.text.length) {
906 inlineNodes.push(951 inlineNodes.push(
907 <span key={`user-message-terminal-context-inline-rest:${cursor}`}>952 renderInlineMarkdownSegment(
908 <SkillInlineText text={props.text.slice(cursor)} skills={props.skills} />953 props.text.slice(cursor),
909 </span>,954 `user-message-terminal-context-inline-rest:${cursor}`,
955 ),
910 );956 );
911 }957 }
912958
...@@ -934,9 +980,14 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -934,9 +980,14 @@ const UserMessageBody = memo(function UserMessageBody(props: {
934980
935 if (props.text.length > 0) {981 if (props.text.length > 0) {
936 inlineNodes.push(982 inlineNodes.push(
937 <span key="user-message-terminal-context-inline-text">983 <ChatMarkdown
938 <SkillInlineText text={props.text} skills={props.skills} />984 key="user-message-terminal-context-inline-text"
939 </span>,985 text={props.text}
986 cwd={props.markdownCwd}
987 skills={props.skills}
988 className="text-foreground"
989 lineBreaks
990 />,
940 );991 );
941 } else if (inlinePrefix.length === 0) {992 } else if (inlinePrefix.length === 0) {
942 return null;993 return null;
...@@ -954,9 +1005,13 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -954,9 +1005,13 @@ const UserMessageBody = memo(function UserMessageBody(props: {
954 }1005 }
9551006
956 return (1007 return (
957 <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground">1008 <ChatMarkdown
958 <SkillInlineText text={props.text} skills={props.skills} />1009 text={props.text}
959 </div>1010 cwd={props.markdownCwd}
1011 skills={props.skills}
1012 className="text-foreground"
1013 lineBreaks
1014 />
960 );1015 );
961});1016});
9621017
...@@ -1203,49 +1258,40 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -1203,49 +1258,40 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
1203 <div className="min-w-0 flex-1 overflow-hidden">1258 <div className="min-w-0 flex-1 overflow-hidden">
1204 {rawCommand ? (1259 {rawCommand ? (
1205 <div className="max-w-full">1260 <div className="max-w-full">
1206 <p1261 <Tooltip>
1207 className={cn(1262 <TooltipTrigger
1208 "truncate text-xs leading-5",1263 closeDelay={0}
1209 workToneClass(workEntry.tone),1264 delay={75}
1210 preview ? "text-muted-foreground/70" : "",1265 render={
1211 )}1266 <p
1212 title={displayText}1267 className={cn(
1213 >1268 "truncate rounded-sm text-xs leading-5",
1214 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>1269 workToneClass(workEntry.tone),
1215 {heading}1270 preview ? "text-muted-foreground/70" : "",
1216 </span>1271 )}
1217 {preview && (1272 aria-label={displayText}
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 }
1228 />1273 />
1229 <TooltipPopup1274 }
1230 align="start"1275 >
1231 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"1276 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
1232 side="top"1277 {heading}
1233 >1278 </span>
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">1279 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
1235 {rawCommand}1280 </TooltipTrigger>
1236 </div>1281 <TooltipPopup
1237 </TooltipPopup>1282 align="start"
1238 </Tooltip>1283 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"
1239 )}1284 side="top"
1240 </p>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 </div>1291 </div>
1242 ) : (1292 ) : (
1243 <Tooltip>1293 <Tooltip>
1244 <TooltipTrigger1294 <TooltipTrigger className="block min-w-0 w-full text-left" aria-label={displayText}>
1245 className="block min-w-0 w-full text-left"
1246 title={displayText}
1247 aria-label={displayText}
1248 >
1249 <p1295 <p
1250 className={cn(1296 className={cn(
1251 "truncate text-[11px] leading-5",1297 "truncate text-[11px] leading-5",
...@@ -1273,13 +1319,21 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -1273,13 +1319,21 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
1273 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {1319 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
1274 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);1320 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
1275 return (1321 return (
1276 <span1322 <Tooltip key={`${workEntry.id}:${filePath}`}>
1277 key={`${workEntry.id}:${filePath}`}1323 <TooltipTrigger
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"1324 render={
1279 title={displayPath}1325 <span
1280 >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"
1281 {displayPath}1327 aria-label={displayPath}
1282 </span>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 {(workEntry.changedFiles?.length ?? 0) > 4 && (1339 {(workEntry.changedFiles?.length ?? 0) > 4 && (
apps/web/src/components/chat/ProviderStatusBanner.tsx+9-3
...@@ -3,6 +3,7 @@ import { memo } from "react";...@@ -3,6 +3,7 @@ import { memo } from "react";
3import { InfoIcon } from "lucide-react";3import { InfoIcon } from "lucide-react";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { formatProviderDriverKindLabel } from "../../providerModels";5import { formatProviderDriverKindLabel } from "../../providerModels";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
67
7export const ProviderStatusBanner = memo(function ProviderStatusBanner({8export const ProviderStatusBanner = memo(function ProviderStatusBanner({
8 status,9 status,
...@@ -39,9 +40,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -39,9 +40,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
39 <InfoIcon className="size-4 shrink-0" aria-hidden />40 <InfoIcon className="size-4 shrink-0" aria-hidden />
40 <div className="flex min-w-0 flex-col gap-1">41 <div className="flex min-w-0 flex-col gap-1">
41 <div className="font-medium">{title}</div>42 <div className="font-medium">{title}</div>
42 <div className="line-clamp-3 text-muted-foreground" title={message}>43 <Tooltip>
43 {message}44 <TooltipTrigger
44 </div>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 </div>51 </div>
46 </div>52 </div>
47 </div>53 </div>
apps/web/src/components/chat/SkillInlineText.tsx+11-6
...@@ -3,11 +3,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts";...@@ -3,11 +3,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts";
33
4import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";4import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
5import {5import {
6 CHAT_INLINE_CHIP_CLASS_NAME,
7 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
6 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,8 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
8 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
9 SKILL_CHIP_ICON_SVG,9 SKILL_CHIP_ICON_SVG,
10} from "../composerInlineChip";10} from "../composerInlineChip";
11import { cn } from "~/lib/utils";
1112
12const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;13const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
1314
...@@ -70,15 +71,19 @@ export function renderSkillInlineMarkdownChildren(...@@ -70,15 +71,19 @@ export function renderSkillInlineMarkdownChildren(
7071
71function SkillChip(props: { skill: InlineSkill; rawText: string }) {72function SkillChip(props: { skill: InlineSkill; rawText: string }) {
72 return (73 return (
73 <span className="inline-flex align-middle leading-none">74 <span className="inline-flex align-middle leading-none" data-markdown-copy={props.rawText}>
74 <span className="sr-only">{props.rawText}</span>75 <span
75 <span aria-hidden="true" className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}>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 <span81 <span
77 aria-hidden="true"82 aria-hidden="true"
78 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}83 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
79 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}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 {formatProviderSkillDisplayName(props.skill)}87 {formatProviderSkillDisplayName(props.skill)}
83 </span>88 </span>
84 </span>89 </span>
apps/web/src/components/chat/ThreadErrorBanner.tsx+9-3
...@@ -2,6 +2,7 @@ import { memo } from "react";...@@ -2,6 +2,7 @@ import { memo } from "react";
2import { Alert, AlertAction, AlertDescription } from "../ui/alert";2import { Alert, AlertAction, AlertDescription } from "../ui/alert";
3import { Button } from "../ui/button";3import { Button } from "../ui/button";
4import { CircleAlertIcon, XIcon } from "lucide-react";4import { CircleAlertIcon, XIcon } from "lucide-react";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
56
6export const ThreadErrorBanner = memo(function ThreadErrorBanner({7export const ThreadErrorBanner = memo(function ThreadErrorBanner({
7 error,8 error,
...@@ -15,9 +16,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({...@@ -15,9 +16,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
15 <div className="pt-3 mx-auto max-w-3xl">16 <div className="pt-3 mx-auto max-w-3xl">
16 <Alert variant="error">17 <Alert variant="error">
17 <CircleAlertIcon />18 <CircleAlertIcon />
18 <AlertDescription className="line-clamp-3" title={error}>19 <Tooltip>
19 {error}20 <TooltipTrigger render={<AlertDescription className="line-clamp-3" />}>
20 </AlertDescription>21 {error}
22 </TooltipTrigger>
23 <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap">
24 {error}
25 </TooltipPopup>
26 </Tooltip>
21 {onDismiss && (27 {onDismiss && (
22 <AlertAction>28 <AlertAction>
23 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>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,9 +1,15 @@
1export const COMPOSER_INLINE_CHIP_CLASS_NAME =1const 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";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
4export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME;
5
6export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`;
37
4export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85";8export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85";
59
6export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight";10export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
11
12export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
713
8export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =14export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
9 "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";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,7 +274,6 @@ function ConnectionStatusDot({
274 const dot = (274 const dot = (
275 <button275 <button
276 type="button"276 type="button"
277 title={tooltipText}
278 aria-label={tooltipText}277 aria-label={tooltipText}
279 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"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,11 +880,16 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
881 ) : null}880 ) : null}
882 </Popover>881 </Popover>
883 </div>882 </div>
884 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>883 <Tooltip>
885 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}884 <TooltipTrigger
886 <span aria-hidden> · </span>885 render={<p aria-label={expiresAbsolute} className="text-xs text-muted-foreground" />}
887 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />886 >
888 </p>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 {shareablePairingUrl === null ? (893 {shareablePairingUrl === null ? (
890 <p className="text-[11px] text-muted-foreground/70">894 <p className="text-[11px] text-muted-foreground/70">
891 Copy the token and pair from another client using this backend&apos;s reachable host.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,17 +902,26 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
898 <>902 <>
899 {shareablePairingUrl ? (903 {shareablePairingUrl ? (
900 <Group aria-label="Copy selected endpoint">904 <Group aria-label="Copy selected endpoint">
901 <Button905 <Tooltip>
902 size="xs"906 <TooltipTrigger
903 variant="outline"907 render={
904 className="max-w-56"908 <Button
905 title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`}909 size="xs"
906 onClick={handleCopyDefaultLink}910 variant="outline"
907 >911 className="max-w-56"
908 <span className="truncate">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 Copy pairing URL for: {defaultEndpointCopyLabel}922 Copy pairing URL for: {defaultEndpointCopyLabel}
910 </span>923 </TooltipPopup>
911 </Button>924 </Tooltip>
912 <GroupSeparator />925 <GroupSeparator />
913 <Menu>926 <Menu>
914 <MenuTrigger927 <MenuTrigger
...@@ -1353,12 +1366,19 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({...@@ -1353,12 +1366,19 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
1353 {endpoint.label}1366 {endpoint.label}
1354 </h3>1367 </h3>
1355 {shouldShowEndpointUrl ? (1368 {shouldShowEndpointUrl ? (
1356 <p1369 <Tooltip>
1357 className="min-w-0 truncate text-xs leading-5 text-muted-foreground"1370 <TooltipTrigger
1358 title={endpoint.httpBaseUrl}1371 render={
1359 >1372 <p
1360 {endpoint.httpBaseUrl}1373 aria-label={endpoint.httpBaseUrl}
1361 </p>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 ) : null}1382 ) : null}
1363 {!isAvailable ? (1383 {!isAvailable ? (
1364 <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground">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,12 +3136,21 @@ export function ConnectionsSettings() {
3116 ) : null}3136 ) : null}
3117 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">3137 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
3118 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>3138 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>
3119 <p3139 <Tooltip>
3120 className="mt-1 truncate text-sm text-foreground"3140 <TooltipTrigger
3121 title={pendingTailscaleServeBaseUrl ?? undefined}3141 render={
3122 >3142 <p
3123 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}3143 aria-label={pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3124 </p>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 </div>3154 </div>
3126 </DialogPanel>3155 </DialogPanel>
3127 <DialogFooter>3156 <DialogFooter>
apps/web/src/components/settings/KeybindingsSettings.tsx+13-3
...@@ -805,9 +805,19 @@ function KeybindingTableRow({...@@ -805,9 +805,19 @@ function KeybindingTableRow({
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">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 <div className="min-w-0 pr-4">806 <div className="min-w-0 pr-4">
807 <div className="flex min-w-0 items-center gap-1.5">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}>808 <Tooltip>
809 {commandLabel(row.command)}809 <TooltipTrigger
810 </div>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 </div>821 </div>
812 </div>822 </div>
813 <div className="flex min-w-0 items-center gap-2 pr-4">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,31 +572,37 @@ function SidebarRail({
572 }, []);572 }, []);
573573
574 return (574 return (
575 <button575 <Tooltip>
576 aria-label={railLabel}576 <TooltipTrigger
577 className={cn(577 render={
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. */578 <button
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",579 aria-label={railLabel}
580 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",580 className={cn(
581 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",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 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",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 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",583 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
584 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",584 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
585 className,585 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
586 )}586 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
587 data-sidebar="rail"587 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
588 data-slot="sidebar-rail"588 className,
589 onClick={handleClick}589 )}
590 onPointerCancel={handlePointerCancel}590 data-sidebar="rail"
591 onPointerDown={handlePointerDown}591 data-slot="sidebar-rail"
592 onPointerMove={handlePointerMove}592 onClick={handleClick}
593 onPointerUp={handlePointerUp}593 onPointerCancel={handlePointerCancel}
594 ref={railRef}594 onPointerDown={handlePointerDown}
595 tabIndex={-1}595 onPointerMove={handlePointerMove}
596 title={railTitle}596 onPointerUp={handlePointerUp}
597 type="button"597 ref={railRef}
598 {...props}598 tabIndex={-1}
599 />599 type="button"
600 {...props}
601 />
602 }
603 />
604 <TooltipPopup side="right">{railTitle}</TooltipPopup>
605 </Tooltip>
600 );606 );
601}607}
602608
apps/web/src/components/ui/toast.tsx+61-45
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 shouldHideCollapsedToastContent,35 shouldHideCollapsedToastContent,
36 shouldRenderThreadScopedToast,36 shouldRenderThreadScopedToast,
37} from "./toast.logic";37} from "./toast.logic";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip";
3839
39export type ThreadToastData = {40export type ThreadToastData = {
40 threadRef?: ScopedThreadRef | null;41 threadRef?: ScopedThreadRef | null;
...@@ -113,16 +114,24 @@ function handleToastDismissClick(...@@ -113,16 +114,24 @@ function handleToastDismissClick(
113114
114function CopyErrorButton({ text }: { text: string }) {115function CopyErrorButton({ text }: { text: string }) {
115 const { copyToClipboard, isCopied } = useCopyToClipboard();116 const { copyToClipboard, isCopied } = useCopyToClipboard();
117 const label = isCopied ? "Copied error" : "Copy error";
116118
117 return (119 return (
118 <button120 <Tooltip>
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"121 <TooltipTrigger
120 onClick={() => copyToClipboard(text)}122 render={
121 title="Copy error"123 <button
122 type="button"124 aria-label={label}
123 >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"
124 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}126 onClick={() => copyToClipboard(text)}
125 </button>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}
128137
...@@ -205,43 +214,50 @@ function ToastDescriptionAndExpandable({...@@ -205,43 +214,50 @@ function ToastDescriptionAndExpandable({
205214
206 return (215 return (
207 <>216 <>
208 <div217 <Tooltip>
209 aria-expanded={open}218 <TooltipTrigger
210 className={cn(219 render={
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",220 <div
212 "transition-colors hover:bg-muted/40",221 aria-label={open ? collapseLabel : expandLabel}
213 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",222 aria-expanded={open}
214 )}223 className={cn(
215 onClick={toggle}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",
216 onKeyDown={onKeyDown}225 "transition-colors hover:bg-muted/40",
217 role="button"226 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
218 tabIndex={0}227 )}
219 title={open ? collapseLabel : expandLabel}228 onClick={toggle}
220 >229 onKeyDown={onKeyDown}
221 <div className="min-w-0 flex-1">230 role="button"
222 <Toast.Description231 tabIndex={0}
223 className={cn(232 />
224 "min-w-0 select-none wrap-break-word text-muted-foreground",233 }
225 errorDescriptionClampClass(toastType, toastDescription),234 >
226 "underline-offset-2 decoration-muted-foreground/60 group-hover:underline",235 <div className="min-w-0 flex-1">
227 )}236 <Toast.Description
228 data-slot="toast-description"237 className={cn(
229 />238 "min-w-0 select-none wrap-break-word text-muted-foreground",
230 </div>239 errorDescriptionClampClass(toastType, toastDescription),
231 {open ? (240 "underline-offset-2 decoration-muted-foreground/60 group-hover:underline",
232 <ChevronUpIcon241 )}
233 aria-hidden242 data-slot="toast-description"
234 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"243 />
235 strokeWidth={2.25}244 </div>
236 />245 {open ? (
237 ) : (246 <ChevronUpIcon
238 <ChevronDownIcon247 aria-hidden
239 aria-hidden248 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
240 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"249 strokeWidth={2.25}
241 strokeWidth={2.25}250 />
242 />251 ) : (
243 )}252 <ChevronDownIcon
244 </div>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 {open ? <div className={toastExpandablePanelClassName}>{expandableContent}</div> : null}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,7 +31,7 @@ function TooltipPopup({
31 <TooltipPrimitive.Positioner31 <TooltipPrimitive.Positioner
32 align={align}32 align={align}
33 anchor={anchor}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 data-slot="tooltip-positioner"35 data-slot="tooltip-positioner"
36 side={side}36 side={side}
37 sideOffset={sideOffset}37 sideOffset={sideOffset}
apps/web/src/composer-editor-mentions.test.ts+59-6
...@@ -10,7 +10,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -10,7 +10,7 @@ describe("splitPromptIntoComposerSegments", () => {
10 it("splits mention tokens followed by whitespace into mention segments", () => {10 it("splits mention tokens followed by whitespace into mention segments", () => {
11 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md please")).toEqual([11 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md please")).toEqual([
12 { type: "text", text: "Inspect " },12 { type: "text", text: "Inspect " },
13 { type: "mention", path: "AGENTS.md" },13 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
14 { type: "text", text: " please" },14 { type: "text", text: " please" },
15 ]);15 ]);
16 });16 });
...@@ -24,7 +24,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -24,7 +24,7 @@ describe("splitPromptIntoComposerSegments", () => {
24 it("keeps newlines around mention tokens", () => {24 it("keeps newlines around mention tokens", () => {
25 expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([25 expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([
26 { type: "text", text: "one\n" },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 { type: "text", text: " \ntwo" },28 { type: "text", text: " \ntwo" },
29 ]);29 ]);
30 });30 });
...@@ -32,7 +32,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -32,7 +32,7 @@ describe("splitPromptIntoComposerSegments", () => {
32 it("splits quoted mention tokens containing whitespace", () => {32 it("splits quoted mention tokens containing whitespace", () => {
33 expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([33 expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
34 { type: "text", text: "Inspect " },34 { type: "text", text: "Inspect " },
35 { type: "mention", path: "My File.md" },35 { type: "mention", path: "My File.md", source: '@"My File.md"' },
36 { type: "text", text: " please" },36 { type: "text", text: " please" },
37 ]);37 ]);
38 });38 });
...@@ -40,11 +40,53 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -40,11 +40,53 @@ describe("splitPromptIntoComposerSegments", () => {
40 it("unescapes quoted mention token content", () => {40 it("unescapes quoted mention token content", () => {
41 expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([41 expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
42 { type: "text", text: "Inspect " },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 { type: "text", text: " please" },48 { type: "text", text: " please" },
45 ]);49 ]);
46 });50 });
4751
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 it("splits skill tokens followed by whitespace into skill segments", () => {90 it("splits skill tokens followed by whitespace into skill segments", () => {
49 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([91 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
50 { type: "text", text: "Use " },92 { type: "text", text: "Use " },
...@@ -67,7 +109,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -67,7 +109,7 @@ describe("splitPromptIntoComposerSegments", () => {
67 ).toEqual([109 ).toEqual([
68 { type: "text", text: "Inspect " },110 { type: "text", text: "Inspect " },
69 { type: "terminal-context", context: null },111 { type: "terminal-context", context: null },
70 { type: "mention", path: "AGENTS.md" },112 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
71 { type: "text", text: " please" },113 { type: "text", text: " please" },
72 ]);114 ]);
73 });115 });
...@@ -94,7 +136,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -94,7 +136,7 @@ describe("splitPromptIntoComposerSegments", () => {
94 { type: "terminal-context", context: null },136 { type: "terminal-context", context: null },
95 { type: "skill", name: "review-follow-up" },137 { type: "skill", name: "review-follow-up" },
96 { type: "text", text: " after " },138 { type: "text", text: " after " },
97 { type: "mention", path: "AGENTS.md" },139 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
98 { type: "text", text: " " },140 { type: "text", text: " " },
99 ]);141 ]);
100 });142 });
...@@ -151,4 +193,15 @@ describe("selectionTouchesMentionBoundary", () => {...@@ -151,4 +193,15 @@ describe("selectionTouchesMentionBoundary", () => {
151 ),193 ),
152 ).toBe(true);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,6 +11,7 @@ export type ComposerPromptSegment =
11 | {11 | {
12 type: "mention";12 type: "mention";
13 path: string;13 path: string;
14 source: string;
14 }15 }
15 | {16 | {
16 type: "skill";17 type: "skill";
...@@ -23,6 +24,9 @@ export type ComposerPromptSegment =...@@ -23,6 +24,9 @@ export type ComposerPromptSegment =
2324
24const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;25const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
25const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;26const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
27const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;
28const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
29const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
2630
27function rangeIncludesIndex(start: number, end: number, index: number): boolean {31function rangeIncludesIndex(start: number, end: number, index: number): boolean {
28 return start <= index && index < end;32 return start <= index && index < end;
...@@ -57,6 +61,29 @@ type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;...@@ -57,6 +61,29 @@ type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
57function collectMentionTokenMatches(text: string): MentionTokenMatch[] {61function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
58 const matches: MentionTokenMatch[] = [];62 const matches: MentionTokenMatch[] = [];
5963
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 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {87 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
61 const fullMatch = match[0];88 const fullMatch = match[0];
62 const prefix = match[1] ?? "";89 const prefix = match[1] ?? "";
...@@ -189,7 +216,11 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen...@@ -189,7 +216,11 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
189 }216 }
190217
191 if (match.type === "mention") {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 } else {224 } else {
194 segments.push({ type: "skill", name: match.value });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,6 +167,16 @@ describe("expandCollapsedComposerCursor", () => {
167 );167 );
168 });168 });
169169
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 it("allows path trigger detection to close after selecting a mention", () => {180 it("allows path trigger detection to close after selecting a mention", () => {
171 const text = "what's in my @AGENTS.md ";181 const text = "what's in my @AGENTS.md ";
172 const collapsedCursorAfterMention = "what's in my ".length + 2;182 const collapsedCursorAfterMention = "what's in my ".length + 2;
...@@ -211,6 +221,16 @@ describe("collapseExpandedComposerCursor", () => {...@@ -211,6 +221,16 @@ describe("collapseExpandedComposerCursor", () => {
211 );221 );
212 });222 });
213223
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 it("keeps replacement cursors aligned when another mention already exists earlier", () => {234 it("keeps replacement cursors aligned when another mention already exists earlier", () => {
215 const text = "open @AGENTS.md then @src/index.ts ";235 const text = "open @AGENTS.md then @src/index.ts ";
216 const expandedCursor = text.length;236 const expandedCursor = text.length;
apps/web/src/composer-logic.ts+2-3
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
2import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
3import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
43
...@@ -55,7 +54,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -55,7 +54,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5554
56 for (const segment of segments) {55 for (const segment of segments) {
57 if (segment.type === "mention") {56 if (segment.type === "mention") {
58 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;57 const expandedLength = segment.source.length;
59 if (remaining <= 1) {58 if (remaining <= 1) {
60 return expandedCursor + (remaining === 0 ? 0 : expandedLength);59 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
61 }60 }
...@@ -143,7 +142,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number...@@ -143,7 +142,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
143142
144 for (const segment of segments) {143 for (const segment of segments) {
145 if (segment.type === "mention") {144 if (segment.type === "mention") {
146 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;145 const expandedLength = segment.source.length;
147 if (remaining === 0) {146 if (remaining === 0) {
148 return collapsedCursor;147 return collapsedCursor;
149 }148 }
apps/web/src/index.css+198-69
...@@ -305,10 +305,44 @@ label:has(> select#reasoning-effort) select {...@@ -305,10 +305,44 @@ label:has(> select#reasoning-effort) select {
305.chat-markdown ol,305.chat-markdown ol,
306.chat-markdown blockquote,306.chat-markdown blockquote,
307.chat-markdown pre,307.chat-markdown pre,
308.chat-markdown table {308.chat-markdown .chat-markdown-table-container {
309 margin: 0.65rem 0;309 margin: 0.65rem 0;
310}310}
311311
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.chat-markdown ul {346.chat-markdown ul {
313 padding-left: 1.25rem;347 padding-left: 1.25rem;
314 list-style-type: disc;348 list-style-type: disc;
...@@ -339,14 +373,39 @@ label:has(> select#reasoning-effort) select {...@@ -339,14 +373,39 @@ label:has(> select#reasoning-effort) select {
339 margin-top: 0.25rem;373 margin-top: 0.25rem;
340}374}
341375
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.chat-markdown a {386.chat-markdown a {
343 color: var(--info-foreground);387 color: var(--info-foreground);
344 text-decoration: underline;388 text-decoration: none;
345 text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent);
346}389}
347390
348.chat-markdown a:hover {391.chat-markdown a:not(.chat-markdown-file-link):hover,
349 opacity: 0.8;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}
351410
352.chat-markdown blockquote {411.chat-markdown blockquote {
...@@ -355,6 +414,35 @@ label:has(> select#reasoning-effort) select {...@@ -355,6 +414,35 @@ label:has(> select#reasoning-effort) select {
355 color: var(--muted-foreground);414 color: var(--muted-foreground);
356}415}
357416
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.chat-markdown :not(pre) > code {446.chat-markdown :not(pre) > code {
359 border: 1px solid var(--border);447 border: 1px solid var(--border);
360 border-radius: 0.375rem;448 border-radius: 0.375rem;
...@@ -364,45 +452,19 @@ label:has(> select#reasoning-effort) select {...@@ -364,45 +452,19 @@ label:has(> select#reasoning-effort) select {
364 font-size: 0.75rem;452 font-size: 0.75rem;
365}453}
366454
367.chat-markdown-file-link {455.chat-markdown a.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;
375 color: var(--foreground);456 color: var(--foreground);
376 font-family: var(--font-mono, ui-monospace, monospace);457 text-decoration: none;
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;
385}458}
386459
387.chat-markdown-file-link:hover {460.chat-markdown a.chat-markdown-file-link:hover {
388 opacity: 1;
389 color: var(--foreground);461 color: var(--foreground);
390 border-color: color-mix(in srgb, var(--border) 65%, var(--foreground));462 text-decoration: none;
391 background: color-mix(in srgb, var(--muted) 72%, var(--background));
392}463}
393464
394.chat-markdown-file-link:focus-visible {465.chat-markdown a.chat-markdown-file-link:focus-visible {
395 outline: none;466 outline: none;
396 box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent);467 box-shadow: 0 0 0 2px 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;
406}468}
407469
408.chat-markdown pre {470.chat-markdown pre {
...@@ -421,6 +483,24 @@ label:has(> select#reasoning-effort) select {...@@ -421,6 +483,24 @@ label:has(> select#reasoning-effort) select {
421 font-size: 0.75rem;483 font-size: 0.75rem;
422}484}
423485
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.markdown-file-link-tooltip-scroll {504.markdown-file-link-tooltip-scroll {
425 scrollbar-width: thin;505 scrollbar-width: thin;
426 scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent;506 scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent;
...@@ -440,66 +520,115 @@ label:has(> select#reasoning-effort) select {...@@ -440,66 +520,115 @@ label:has(> select#reasoning-effort) select {
440}520}
441521
442.chat-markdown .chat-markdown-codeblock {522.chat-markdown .chat-markdown-codeblock {
443 --chat-markdown-codeblock-copy-button-space: 1.5rem;
444 position: relative;
445 margin: 0.65rem 0;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}
447529
448.chat-markdown .chat-markdown-codeblock pre {530.chat-markdown .chat-markdown-codeblock-header {
449 margin: 0;531 display: flex;
450 padding-right: calc(0.9rem + var(--chat-markdown-codeblock-copy-button-space));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}
452540
453.chat-markdown .chat-markdown-copy-button {541.chat-markdown .chat-markdown-codeblock-title {
454 position: absolute;
455 top: 0.5rem;
456 right: 0.5rem;
457 z-index: 1;
458 display: inline-flex;542 display: inline-flex;
543 min-width: 0;
459 align-items: center;544 align-items: center;
460 justify-content: center;545 gap: 0.4rem;
461 height: 1.5rem;546 font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
462 width: 1.5rem;547 font-size: 0.6875rem;
463 border-radius: 0.375rem;548}
464 border: 1px solid var(--border);549
465 background: color-mix(in srgb, var(--background) 82%, transparent);550.chat-markdown .chat-markdown-chrome-action {
466 color: var(--muted-foreground);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}
475553
476.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button,554.chat-markdown .chat-markdown-chrome-action:hover {
477.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button {555 color: var(--foreground);
478 opacity: 1;
479 pointer-events: auto;
480}556}
481557
482.chat-markdown .chat-markdown-copy-button:hover {558.chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {
483 color: var(--foreground);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}
486574
487.chat-markdown .chat-markdown-shiki .shiki {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}
490578
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.chat-markdown table {584.chat-markdown table {
492 width: 100%;585 width: 100%;
586 min-width: max-content;
493 border-collapse: collapse;587 border-collapse: collapse;
588 overflow-wrap: normal;
589 word-break: normal;
590 font-size: 0.75rem;
494}591}
495592
496.chat-markdown th,593.chat-markdown th,
497.chat-markdown td {594.chat-markdown td {
498 border: 1px solid var(--border);595 padding: 0.45rem 0.75rem;
499 padding: 0.35rem 0.45rem;
500 text-align: left;596 text-align: left;
501}597}
502598
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@keyframes provider-update-pill-countdown {632@keyframes provider-update-pill-countdown {
504 from {633 from {
505 transform: scaleX(1);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
9const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]);
10const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"];
11const 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
21export interface MarkdownClipboardPayload {
22 text: string;
23 html: string;
24}
25
26function 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** ". */
33function 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
40function 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
50function 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
58function 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
66function 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
72function serializeTableCell(cell: Element): string {
73 return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|");
74}
75
76function 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
86function 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
105function 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
124function 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
131function 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
143function 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
153function 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
165function serializeChildren(node: Node): string {
166 let out = "";
167 for (const child of node.childNodes) {
168 out += serializeNode(child);
169 }
170 return out;
171}
172
173function 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 ? `![${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. */
242function 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
252export function serializeRenderedMarkdownFragment(container: Node): string {
253 return tidyMarkdown(serializeChildren(container));
254}
255
256export function serializeTableElementToMarkdown(table: Element): string {
257 return serializeTable(table).trim();
258}
259
260function csvCell(value: string): string {
261 const normalized = value.replace(/\s+/g, " ").trim();
262 return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized;
263}
264
265export 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
278function 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
294export 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,6 +69,24 @@ function toLowercaseLookup(source: Record<string, string>): Record<string, strin
69 return lookup;69 return lookup;
70}70}
7171
72const extensionByLanguageId: Record<string, string> = {};
73for (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 */
85export function syntheticFileNameForLanguageId(languageId: string): string {
86 const normalized = languageId.toLowerCase();
87 return `file.${extensionByLanguageId[normalized] ?? normalized}`;
88}
89
72export function basenameOfPath(pathValue: string): string {90export function basenameOfPath(pathValue: string): string {
73 const slashIndex = pathValue.lastIndexOf("/");91 const slashIndex = pathValue.lastIndexOf("/");
74 if (slashIndex === -1) return pathValue;92 if (slashIndex === -1) return pathValue;
...@@ -166,6 +184,17 @@ function resolveFolderDefinition(pathValue: string, theme: "light" | "dark"): st...@@ -166,6 +184,17 @@ function resolveFolderDefinition(pathValue: string, theme: "light" | "dark"): st
166 );184 );
167}185}
168186
187/** True when the file name resolves to a real icon rather than the generic default. */
188export 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
169export function getVscodeIconUrlForEntry(198export function getVscodeIconUrlForEntry(
170 pathValue: string,199 pathValue: string,
171 kind: "file" | "directory",200 kind: "file" | "directory",
packages/shared/src/composerTrigger.test.ts+27-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { serializeComposerMentionPath } from "./composerTrigger.ts";3import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts";
44
5describe("serializeComposerMentionPath", () => {5describe("serializeComposerMentionPath", () => {
6 it("keeps simple mention paths unquoted", () => {6 it("keeps simple mention paths unquoted", () => {
...@@ -15,3 +15,29 @@ describe("serializeComposerMentionPath", () => {...@@ -15,3 +15,29 @@ describe("serializeComposerMentionPath", () => {
15 expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');15 expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');
16 });16 });
17});17});
18
19describe("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,6 +17,29 @@ export function serializeComposerMentionPath(path: string): string {
17 return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;17 return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
18}18}
1919
20function composerFileLinkBasename(path: string): string {
21 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
22 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
23}
24
25function escapeMarkdownLinkLabel(label: string): string {
26 return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
27}
28
29function 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
38export function serializeComposerFileLink(path: string): string {
39 const label = escapeMarkdownLinkLabel(composerFileLinkBasename(path));
40 return `[${label}](${encodeMarkdownLinkDestination(path)})`;
41}
42
20function clampCursor(text: string, cursor: number): number {43function clampCursor(text: string, cursor: number): number {
21 if (!Number.isFinite(cursor)) return text.length;44 if (!Number.isFinite(cursor)) return text.length;
22 return Math.max(0, Math.min(text.length, Math.floor(cursor)));45 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
pnpm-lock.yaml+43
...@@ -521,6 +521,15 @@ importers:...@@ -521,6 +521,15 @@ importers:
521 react-markdown:521 react-markdown:
522 specifier: ^10.1.0522 specifier: ^10.1.0
523 version: 10.1.0(@types/react@19.2.16)(react@19.2.6)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 remark-gfm:533 remark-gfm:
525 specifier: ^4.0.1534 specifier: ^4.0.1
526 version: 4.0.1535 version: 4.0.1
...@@ -6483,6 +6492,9 @@ packages:...@@ -6483,6 +6492,9 @@ packages:
6483 hast-util-raw@9.1.0:6492 hast-util-raw@9.1.0:
6484 resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}6493 resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
64856494
6495 hast-util-sanitize@5.0.2:
6496 resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
6497
6486 hast-util-to-html@9.0.5:6498 hast-util-to-html@9.0.5:
6487 resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}6499 resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
64886500
...@@ -7238,6 +7250,9 @@ packages:...@@ -7238,6 +7250,9 @@ packages:
7238 mdast-util-mdxjs-esm@2.0.1:7250 mdast-util-mdxjs-esm@2.0.1:
7239 resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}7251 resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
72407252
7253 mdast-util-newline-to-break@2.0.0:
7254 resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
7255
7241 mdast-util-phrasing@4.1.0:7256 mdast-util-phrasing@4.1.0:
7242 resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}7257 resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
72437258
...@@ -8350,12 +8365,18 @@ packages:...@@ -8350,12 +8365,18 @@ packages:
8350 rehype-raw@7.0.0:8365 rehype-raw@7.0.0:
8351 resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}8366 resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
83528367
8368 rehype-sanitize@6.0.0:
8369 resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
8370
8353 rehype-stringify@10.0.1:8371 rehype-stringify@10.0.1:
8354 resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}8372 resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
83558373
8356 rehype@13.0.2:8374 rehype@13.0.2:
8357 resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==}8375 resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==}
83588376
8377 remark-breaks@4.0.0:
8378 resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
8379
8359 remark-gfm@4.0.1:8380 remark-gfm@4.0.1:
8360 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}8381 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
83618382
...@@ -15786,6 +15807,12 @@ snapshots:...@@ -15786,6 +15807,12 @@ snapshots:
15786 web-namespaces: 2.0.115807 web-namespaces: 2.0.1
15787 zwitch: 2.0.415808 zwitch: 2.0.4
1578815809
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 hast-util-to-html@9.0.5:15816 hast-util-to-html@9.0.5:
15790 dependencies:15817 dependencies:
15791 '@types/hast': 3.0.415818 '@types/hast': 3.0.4
...@@ -16580,6 +16607,11 @@ snapshots:...@@ -16580,6 +16607,11 @@ snapshots:
16580 transitivePeerDependencies:16607 transitivePeerDependencies:
16581 - supports-color16608 - supports-color
1658216609
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 mdast-util-phrasing@4.1.0:16615 mdast-util-phrasing@4.1.0:
16584 dependencies:16616 dependencies:
16585 '@types/mdast': 4.0.416617 '@types/mdast': 4.0.4
...@@ -17962,6 +17994,11 @@ snapshots:...@@ -17962,6 +17994,11 @@ snapshots:
17962 hast-util-raw: 9.1.017994 hast-util-raw: 9.1.0
17963 vfile: 6.0.317995 vfile: 6.0.3
1796417996
17997 rehype-sanitize@6.0.0:
17998 dependencies:
17999 '@types/hast': 3.0.4
18000 hast-util-sanitize: 5.0.2
18001
17965 rehype-stringify@10.0.1:18002 rehype-stringify@10.0.1:
17966 dependencies:18003 dependencies:
17967 '@types/hast': 3.0.418004 '@types/hast': 3.0.4
...@@ -17975,6 +18012,12 @@ snapshots:...@@ -17975,6 +18012,12 @@ snapshots:
17975 rehype-stringify: 10.0.118012 rehype-stringify: 10.0.1
17976 unified: 11.0.518013 unified: 11.0.5
1797718014
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 remark-gfm@4.0.1:18021 remark-gfm@4.0.1:
17979 dependencies:18022 dependencies:
17980 '@types/mdast': 4.0.418023 '@types/mdast': 4.0.4