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 {
1010import {
1111 detectComposerTrigger,
1212 replaceTextRange,
13 serializeComposerMentionPath,
13 serializeComposerFileLink,
1414 type ComposerTrigger,
1515} from "@t3tools/shared/composerTrigger";
1616import { TextInputWrapper } from "expo-paste-input";
......@@ -419,7 +419,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
419419
420420 let replacement = "";
421421 if (item.type === "path") {
422 replacement = `@${serializeComposerMentionPath(item.path)} `;
422 replacement = `${serializeComposerFileLink(item.path)} `;
423423 } else if (item.type === "skill") {
424424 replacement = `$${item.skill.name} `;
425425 } else if (item.type === "slash-command") {
apps/web/package.json+3
......@@ -43,6 +43,9 @@
4343 "react": "19.2.6",
4444 "react-dom": "19.2.6",
4545 "react-markdown": "^10.1.0",
46 "rehype-raw": "^7.0.0",
47 "rehype-sanitize": "^6.0.0",
48 "remark-breaks": "^4.0.0",
4649 "remark-gfm": "^4.0.1",
4750 "tailwind-merge": "^3.4.0",
4851 "zustand": "^5.0.11"
apps/web/src/components/ChatMarkdown.browser.tsx+543
......@@ -24,6 +24,7 @@ vi.mock("../localApi", () => ({
2424}));
2525
2626import ChatMarkdown from "./ChatMarkdown";
27import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";
2728
2829describe("ChatMarkdown", () => {
2930 afterEach(() => {
......@@ -134,8 +135,550 @@ describe("ChatMarkdown", () => {
134135 await expect.element(link).toBeInTheDocument();
135136 await expect.element(link).toHaveAttribute("href", "https://openai.com/docs");
136137 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();
137153 } finally {
138154 await screen.unmount();
139155 }
140156 });
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 });
141684});
apps/web/src/components/ChatMarkdown.tsx+672-34
......@@ -1,9 +1,18 @@
11import { 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";
311import type { ServerProviderSkill } from "@t3tools/contracts";
412import React, {
513 Children,
614 Suspense,
15 type ClipboardEvent as ReactClipboardEvent,
716 type MouseEvent as ReactMouseEvent,
817 isValidElement,
918 use,
......@@ -18,16 +27,34 @@ import React, {
1827import type { Components } from "react-markdown";
1928import ReactMarkdown from "react-markdown";
2029import { defaultUrlTransform } from "react-markdown";
30import rehypeRaw from "rehype-raw";
31import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
32import remarkBreaks from "remark-breaks";
2133import remarkGfm from "remark-gfm";
22import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
2334import { 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";
2442import { 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";
2547import { stackedThreadToast, toastManager } from "./ui/toast";
2648import { openInPreferredEditor } from "../editorPreferences";
2749import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
2850import { fnv1a32 } from "../lib/diffRendering";
2951import { LRUCache } from "../lib/lruCache";
3052import { useTheme } from "../hooks/useTheme";
53import {
54 chatMarkdownClipboardPayload,
55 serializeTableElementToCsv,
56 serializeTableElementToMarkdown,
57} from "../markdown-clipboard";
3158import {
3259 normalizeMarkdownLinkDestination,
3360 resolveMarkdownFileLinkMeta,
......@@ -62,6 +89,9 @@ interface ChatMarkdownProps {
6289 cwd: string | undefined;
6390 isStreaming?: boolean;
6491 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
92 className?: string;
93 /** Treat single newlines as hard breaks — chat-style user input. */
94 lineBreaks?: boolean;
6595}
6696
6797const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
......@@ -74,6 +104,18 @@ const highlightedCodeCache = new LRUCache<string>(
74104 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
75105);
76106const 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
78120function extractFenceLanguage(className: string | undefined): string {
79121 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
......@@ -82,6 +124,64 @@ function extractFenceLanguage(className: string | undefined): string {
82124 return raw === "gitignore" ? "ini" : raw;
83125}
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
85185function nodeToPlainText(node: ReactNode): string {
86186 if (typeof node === "string" || typeof node === "number") {
87187 return String(node);
......@@ -146,9 +246,254 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
146246 return promise;
147247}
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}) {
150492 const [copied, setCopied] = useState(false);
493 const [wrapped, setWrapped] = useState(false);
151494 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
495 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
496 const copyLabel = copied ? "Copied" : "Copy code";
152497 const handleCopy = useCallback(() => {
153498 if (typeof navigator === "undefined" || navigator.clipboard == null) {
154499 return;
......@@ -179,16 +524,57 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo
179524 );
180525
181526 return (
182 <div className="chat-markdown-codeblock leading-snug">
183 <button
184 type="button"
185 className="chat-markdown-copy-button"
186 onClick={handleCopy}
187 title={copied ? "Copied" : "Copy code"}
188 aria-label={copied ? "Copied" : "Copy code"}
189 >
190 {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
191 </button>
527 <div
528 className="chat-markdown-codeblock leading-snug"
529 data-language={language}
530 data-wrap={wrapped ? "true" : "false"}
531 >
532 <div className="chat-markdown-codeblock-header select-none">
533 <span className="chat-markdown-codeblock-title">
534 <MarkdownCodeBlockTitleContent
535 fenceTitle={fenceTitle}
536 language={language}
537 theme={theme}
538 />
539 </span>
540 <span className="flex items-center gap-0.5">
541 <Tooltip>
542 <TooltipTrigger
543 render={
544 <Button
545 type="button"
546 variant="ghost"
547 size="icon-xs"
548 className="chat-markdown-chrome-action"
549 aria-pressed={wrapped}
550 onClick={() => setWrapped((value) => !value)}
551 aria-label={wrapLabel}
552 />
553 }
554 >
555 <WrapTextIcon className="size-3" />
556 </TooltipTrigger>
557 <TooltipPopup side="top">{wrapLabel}</TooltipPopup>
558 </Tooltip>
559 <Tooltip>
560 <TooltipTrigger
561 render={
562 <Button
563 type="button"
564 variant="ghost"
565 size="icon-xs"
566 className="chat-markdown-chrome-action"
567 onClick={handleCopy}
568 aria-label={copyLabel}
569 />
570 }
571 >
572 {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
573 </TooltipTrigger>
574 <TooltipPopup side="top">{copyLabel}</TooltipPopup>
575 </Tooltip>
576 </span>
577 </div>
192578 {children}
193579 </div>
194580 );
......@@ -280,17 +666,15 @@ interface MarkdownFileLinkProps {
280666 href: string;
281667 targetPath: string;
282668 displayPath: string;
283 filePath: string;
284669 label: string;
670 copyMarkdown: string;
285671 theme: "light" | "dark";
286672 className?: string | undefined;
287673}
288674
289675const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
290676const MARKDOWN_FILE_LINK_CLASS_NAME =
291 "chat-markdown-file-link relative top-[2px] max-w-full no-underline";
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";
677 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";
294678
295679function pathParentSegments(path: string): string[] {
296680 const normalized = path.replaceAll("\\", "/");
......@@ -367,12 +751,196 @@ function normalizeMarkdownLinkHrefKey(href: string): string {
367751 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
368752}
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
370938const MarkdownFileLink = memo(function MarkdownFileLink({
371939 href,
372940 targetPath,
373941 displayPath,
374 filePath,
375942 label,
943 copyMarkdown,
376944 theme,
377945 className,
378946}: MarkdownFileLinkProps) {
......@@ -467,7 +1035,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
4671035 render={
4681036 <a
4691037 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}
4711040 onClick={(event) => {
4721041 event.preventDefault();
4731042 event.stopPropagation();
......@@ -475,13 +1044,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
4751044 }}
4761045 onContextMenu={handleContextMenu}
4771046 >
478 <VscodeEntryIcon
479 pathValue={filePath}
480 kind="file"
481 theme={theme}
482 className={cn(MARKDOWN_FILE_LINK_ICON_CLASS_NAME, "text-current")}
483 />
484 <span className={MARKDOWN_FILE_LINK_LABEL_CLASS_NAME}>{label}</span>
1047 <FileTagChipContent path={targetPath} label={label} theme={theme} selectable />
4851048 </a>
4861049 }
4871050 />
......@@ -505,8 +1068,8 @@ function areMarkdownFileLinkPropsEqual(
5051068 previous.href === next.href &&
5061069 previous.targetPath === next.targetPath &&
5071070 previous.displayPath === next.displayPath &&
508 previous.filePath === next.filePath &&
5091071 previous.label === next.label &&
1072 previous.copyMarkdown === next.copyMarkdown &&
5101073 previous.theme === next.theme &&
5111074 previous.className === next.className
5121075 );
......@@ -517,6 +1080,8 @@ function ChatMarkdown({
5171080 cwd,
5181081 isStreaming = false,
5191082 skills = EMPTY_MARKDOWN_SKILLS,
1083 className,
1084 lineBreaks = false,
5201085}: ChatMarkdownProps) {
5211086 const { resolvedTheme } = useTheme();
5221087 const diffThemeName = resolveDiffThemeName(resolvedTheme);
......@@ -542,6 +1107,17 @@ function ChatMarkdown({
5421107 const markdownUrlTransform = useCallback((href: string) => {
5431108 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
5441109 }, []);
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 }, []);
5451121 const markdownComponents = useMemo<Components>(
5461122 () => ({
5471123 p({ node: _node, children, ...props }) {
......@@ -550,11 +1126,49 @@ function ChatMarkdown({
5501126 li({ node: _node, children, ...props }) {
5511127 return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>;
5521128 },
553 a({ node: _node, href, ...props }) {
1129 a({ node, href, children, ...props }) {
5541130 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
5551131 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
5561132 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 );
5581172 }
5591173
5601174 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
......@@ -573,21 +1187,34 @@ function ChatMarkdown({
5731187 href={fileLinkMeta.targetPath}
5741188 targetPath={fileLinkMeta.targetPath}
5751189 displayPath={fileLinkMeta.displayPath}
576 filePath={fileLinkMeta.filePath}
5771190 label={labelParts.join(" · ")}
1191 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
5781192 theme={resolvedTheme}
5791193 className={props.className}
5801194 />
5811195 );
5821196 },
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 }) {
5841204 const codeBlock = extractCodeBlock(children);
5851205 if (!codeBlock) {
5861206 return <pre {...props}>{children}</pre>;
5871207 }
5881208
1209 const language = extractFenceLanguage(codeBlock.className);
1210 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
5891211 return (
590 <MarkdownCodeBlock code={codeBlock.code}>
1212 <MarkdownCodeBlock
1213 code={codeBlock.code}
1214 language={language}
1215 fenceTitle={fenceTitle}
1216 theme={resolvedTheme}
1217 >
5911218 <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>
5921219 <Suspense fallback={<pre {...props}>{children}</pre>}>
5931220 <SuspenseShikiCodeBlock
......@@ -613,9 +1240,20 @@ function ChatMarkdown({
6131240 );
6141241
6151242 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 >
6171250 <ReactMarkdown
618 remarkPlugins={[remarkGfm]}
1251 remarkPlugins={
1252 lineBreaks
1253 ? [remarkGfm, remarkBreaks, remarkPreserveCodeMeta]
1254 : [remarkGfm, remarkPreserveCodeMeta]
1255 }
1256 rehypePlugins={[rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA]]}
6191257 components={markdownComponents}
6201258 urlTransform={markdownUrlTransform}
6211259 >
apps/web/src/components/ChatView.browser.tsx+54-8
......@@ -2341,7 +2341,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
23412341 const runButton = await waitForElement(
23422342 () =>
23432343 Array.from(document.querySelectorAll("button")).find(
2344 (button) => button.title === "Run Lint",
2344 (button) => button.getAttribute("aria-label") === "Run Lint",
23452345 ) as HTMLButtonElement | null,
23462346 "Unable to find Run Lint button.",
23472347 );
......@@ -2420,7 +2420,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
24202420 const runButton = await waitForElement(
24212421 () =>
24222422 Array.from(document.querySelectorAll("button")).find(
2423 (button) => button.title === "Run Test",
2423 (button) => button.getAttribute("aria-label") === "Run Test",
24242424 ) as HTMLButtonElement | null,
24252425 "Unable to find Run Test button.",
24262426 );
......@@ -3160,7 +3160,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
31603160
31613161 try {
31623162 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
31653166 window.dispatchEvent(
31663167 new KeyboardEvent("keydown", {
......@@ -3172,7 +3173,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
31723173 );
31733174 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
31773180 const composerEditor = await waitForComposerEditor();
31783181 composerEditor.focus();
......@@ -3187,7 +3190,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
31873190
31883191 await vi.waitFor(
31893192 async () => {
3190 expect((await waitForInteractionModeButton("Plan")).title).toContain(
3193 expect((await waitForInteractionModeButton("Plan")).getAttribute("aria-label")).toContain(
31913194 "return to normal build mode",
31923195 );
31933196 },
......@@ -3205,7 +3208,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
32053208
32063209 await vi.waitFor(
32073210 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");
32093214 },
32103215 { timeout: 8_000, interval: 16 },
32113216 );
......@@ -3587,13 +3592,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
35873592 },
35883593 { timeout: 8_000, interval: 16 },
35893594 );
3590 await waitForComposerText("hi @package.json there");
3595 await waitForComposerText("hi [package.json](package.json) there");
35913596 await setComposerSelectionByTextOffsets({
35923597 start: "hi package.json ".length,
35933598 end: "hi package.json there".length,
35943599 });
35953600 await pressComposerKey("(");
3596 await waitForComposerText("hi @package.json (there)");
3601 await waitForComposerText("hi [package.json](package.json) (there)");
35973602 } finally {
35983603 await mounted.cleanup();
35993604 }
......@@ -3625,6 +3630,47 @@ describe("ChatView timeline estimator parity (full app)", () => {
36253630 }
36263631 });
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
36283674 it("shows runtime mode descriptions in the desktop composer access select", async () => {
36293675 setDraftThreadWithoutWorktree();
36303676
apps/web/src/components/CommandPalette.tsx+70-53
......@@ -1570,61 +1570,78 @@ function OpenCommandPaletteDialog() {
15701570 onKeyDown={handleKeyDown}
15711571 />
15721572 {addProjectCloneFlow?.step === "repository" ? (
1573 <Button
1574 variant="outline"
1575 size="xs"
1576 tabIndex={-1}
1577 className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2"
1578 aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
1579 disabled={!canSubmitRemoteProjectFlow}
1580 onMouseDown={(event) => {
1581 event.preventDefault();
1582 }}
1583 onClick={() => {
1584 void submitAddProjectCloneFlow();
1585 }}
1586 title={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
1587 >
1588 <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>
1589 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1590 <Kbd>Enter</Kbd>
1591 </KbdGroup>
1592 </Button>
1593 ) : isBrowsing ? (
1594 <Button
1595 variant="outline"
1596 size="xs"
1597 tabIndex={-1}
1598 className={cn(
1599 "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2",
1600 hasHighlightedBrowseItem ? "gap-1" : "gap-1.5",
1601 )}
1602 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
1603 disabled={
1604 relativePathNeedsActiveProject || (isCloneDestinationStep && isRemoteProjectPending)
1605 }
1606 onMouseDown={(event) => {
1607 event.preventDefault();
1608 }}
1609 onClick={() => {
1610 if (relativePathNeedsActiveProject) {
1611 return;
1573 <Tooltip>
1574 <TooltipTrigger
1575 render={
1576 <Button
1577 variant="outline"
1578 size="xs"
1579 tabIndex={-1}
1580 className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2"
1581 aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
1582 disabled={!canSubmitRemoteProjectFlow}
1583 onMouseDown={(event) => {
1584 event.preventDefault();
1585 }}
1586 onClick={() => {
1587 void submitAddProjectCloneFlow();
1588 }}
1589 />
16121590 }
1613 if (isCloneDestinationStep) {
1614 void submitAddProjectCloneFlow(resolvedAddProjectPath);
1615 } else {
1616 void handleAddProject(resolvedAddProjectPath);
1591 >
1592 <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>
1593 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1594 <Kbd>Enter</Kbd>
1595 </KbdGroup>
1596 </TooltipTrigger>
1597 <TooltipPopup side="top">
1598 {remoteProjectButtonLabel ?? "Continue"} (Enter)
1599 </TooltipPopup>
1600 </Tooltip>
1601 ) : isBrowsing ? (
1602 <Tooltip>
1603 <TooltipTrigger
1604 render={
1605 <Button
1606 variant="outline"
1607 size="xs"
1608 tabIndex={-1}
1609 className={cn(
1610 "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2",
1611 hasHighlightedBrowseItem ? "gap-1" : "gap-1.5",
1612 )}
1613 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
1614 disabled={
1615 relativePathNeedsActiveProject ||
1616 (isCloneDestinationStep && isRemoteProjectPending)
1617 }
1618 onMouseDown={(event) => {
1619 event.preventDefault();
1620 }}
1621 onClick={() => {
1622 if (relativePathNeedsActiveProject) {
1623 return;
1624 }
1625 if (isCloneDestinationStep) {
1626 void submitAddProjectCloneFlow(resolvedAddProjectPath);
1627 } else {
1628 void handleAddProject(resolvedAddProjectPath);
1629 }
1630 }}
1631 />
16171632 }
1618 }}
1619 title={`${submitActionLabel} (${addShortcutLabel})`}
1620 >
1621 <span>
1622 {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
1623 </span>
1624 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1625 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
1626 </KbdGroup>
1627 </Button>
1633 >
1634 <span>
1635 {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
1636 </span>
1637 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1638 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
1639 </KbdGroup>
1640 </TooltipTrigger>
1641 <TooltipPopup side="top">
1642 {submitActionLabel} ({addShortcutLabel})
1643 </TooltipPopup>
1644 </Tooltip>
16281645 ) : null}
16291646 </div>
16301647 <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";
66import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
77import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
88import { type ServerProviderSkill } from "@t3tools/contracts";
9import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
9import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
1010import {
1111 $applyNodeReplacement,
1212 $createRangeSelection,
......@@ -64,14 +64,14 @@ import {
6464 type TerminalContextDraft,
6565} from "~/lib/terminalContext";
6666import { cn } from "~/lib/utils";
67import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons";
67import { basenameOfPath } from "~/vscode-icons";
6868import {
69 COMPOSER_INLINE_CHIP_CLASS_NAME,
7069 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7170 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
7271 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
7372 SKILL_CHIP_ICON_SVG,
7473} from "./composerInlineChip";
74import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
7575import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
7676import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
7777import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -132,19 +132,12 @@ function ComposerMentionDecorator(props: { path: string }) {
132132 const theme = resolvedThemeFromDocument();
133133 const chip = (
134134 <span
135 className={COMPOSER_INLINE_CHIP_CLASS_NAME}
135 className={FILE_TAG_CHIP_CLASS_NAME}
136136 contentEditable={false}
137137 spellCheck={false}
138138 data-composer-mention-chip="true"
139139 >
140 <img
141 alt=""
142 aria-hidden="true"
143 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
144 loading="lazy"
145 src={getVscodeIconUrlForEntry(props.path, inferEntryKindFromPath(props.path), theme)}
146 />
147 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{basenameOfPath(props.path)}</span>
140 <FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} />
148141 </span>
149142 );
150143
......@@ -175,8 +168,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
175168
176169 constructor(path: string, key?: NodeKey) {
177170 super(key);
178 const normalizedPath = path.startsWith("@") ? path.slice(1) : path;
179 this.__path = normalizedPath;
171 this.__path = path;
180172 }
181173
182174 override exportJSON(): SerializedComposerMentionNode {
......@@ -199,7 +191,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
199191 }
200192
201193 override getTextContent(): string {
202 return `@${serializeComposerMentionPath(this.__path)}`;
194 return serializeComposerFileLink(this.__path);
203195 }
204196
205197 override isInline(): true {
apps/web/src/components/DiffPanel.tsx+108-71
......@@ -42,6 +42,7 @@ import { useSettings } from "../hooks/useSettings";
4242import { formatShortTimestamp } from "../timestampFormat";
4343import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
4444import { ToggleGroup, Toggle } from "./ui/toggle-group";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
4546
4647type DiffRenderMode = "stacked" | "split";
4748type DiffThemeType = "light" | "dark";
......@@ -460,35 +461,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
460461 </div>
461462 </button>
462463 {orderedTurnDiffSummaries.map((summary) => (
463 <button
464 key={summary.turnId}
465 type="button"
466 className="shrink-0 rounded-md"
467 onClick={() => selectTurn(summary.turnId)}
468 title={summary.turnId}
469 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
470 >
471 <div
472 className={cn(
473 "rounded-md border px-2 py-1 text-left transition-colors",
474 summary.turnId === selectedTurn?.turnId
475 ? "border-border bg-accent text-accent-foreground"
476 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
477 )}
464 <Tooltip key={summary.turnId}>
465 <TooltipTrigger
466 render={
467 <button
468 type="button"
469 aria-label={summary.turnId}
470 className="shrink-0 rounded-md"
471 onClick={() => selectTurn(summary.turnId)}
472 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
473 />
474 }
478475 >
479 <div className="flex items-center gap-1">
480 <span className="text-[10px] leading-tight font-medium">
481 Turn{" "}
482 {summary.checkpointTurnCount ??
483 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
484 "?"}
485 </span>
486 <span className="text-[9px] leading-tight opacity-70">
487 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
488 </span>
476 <div
477 className={cn(
478 "rounded-md border px-2 py-1 text-left transition-colors",
479 summary.turnId === selectedTurn?.turnId
480 ? "border-border bg-accent text-accent-foreground"
481 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
482 )}
483 >
484 <div className="flex items-center gap-1">
485 <span className="text-[10px] leading-tight font-medium">
486 Turn{" "}
487 {summary.checkpointTurnCount ??
488 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
489 "?"}
490 </span>
491 <span className="text-[9px] leading-tight opacity-70">
492 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
493 </span>
494 </div>
489495 </div>
490 </div>
491 </button>
496 </TooltipTrigger>
497 <TooltipPopup side="top">{summary.turnId}</TooltipPopup>
498 </Tooltip>
492499 ))}
493500 </div>
494501 </div>
......@@ -512,30 +519,50 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
512519 <Columns2Icon className="size-3" />
513520 </Toggle>
514521 </ToggleGroup>
515 <Toggle
516 aria-label={diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}
517 title={diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"}
518 variant="outline"
519 size="xs"
520 pressed={diffWordWrap}
521 onPressedChange={(pressed) => {
522 setDiffWordWrap(Boolean(pressed));
523 }}
524 >
525 <TextWrapIcon className="size-3" />
526 </Toggle>
527 <Toggle
528 aria-label={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
529 title={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
530 variant="outline"
531 size="xs"
532 pressed={diffIgnoreWhitespace}
533 onPressedChange={(pressed) => {
534 setDiffIgnoreWhitespace(Boolean(pressed));
535 }}
536 >
537 <PilcrowIcon className="size-3" />
538 </Toggle>
522 <Tooltip>
523 <TooltipTrigger
524 render={
525 <Toggle
526 aria-label={
527 diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"
528 }
529 variant="outline"
530 size="xs"
531 pressed={diffWordWrap}
532 onPressedChange={(pressed) => {
533 setDiffWordWrap(Boolean(pressed));
534 }}
535 />
536 }
537 >
538 <TextWrapIcon className="size-3" />
539 </TooltipTrigger>
540 <TooltipPopup side="top">
541 {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"}
542 </TooltipPopup>
543 </Tooltip>
544 <Tooltip>
545 <TooltipTrigger
546 render={
547 <Toggle
548 aria-label={
549 diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"
550 }
551 variant="outline"
552 size="xs"
553 pressed={diffIgnoreWhitespace}
554 onPressedChange={(pressed) => {
555 setDiffIgnoreWhitespace(Boolean(pressed));
556 }}
557 />
558 }
559 >
560 <PilcrowIcon className="size-3" />
561 </TooltipTrigger>
562 <TooltipPopup side="top">
563 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
564 </TooltipPopup>
565 </Tooltip>
539566 </div>
540567 </>
541568 );
......@@ -609,26 +636,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
609636 <FileDiff
610637 fileDiff={fileDiff}
611638 renderHeaderPrefix={() => (
612 <button
613 type="button"
614 className={cn(
615 "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
616 getDiffCollapseIconClassName(fileDiff),
617 )}
618 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
619 aria-expanded={!collapsed}
620 title={collapsed ? "Expand diff" : "Collapse diff"}
621 onClick={(event) => {
622 event.stopPropagation();
623 toggleDiffFileCollapsed(fileKey);
624 }}
625 >
626 {collapsed ? (
627 <ChevronRightIcon className="size-4" />
628 ) : (
629 <ChevronDownIcon className="size-4" />
630 )}
631 </button>
639 <Tooltip>
640 <TooltipTrigger
641 render={
642 <button
643 type="button"
644 className={cn(
645 "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
646 getDiffCollapseIconClassName(fileDiff),
647 )}
648 aria-label={
649 collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
650 }
651 aria-expanded={!collapsed}
652 onClick={(event) => {
653 event.stopPropagation();
654 toggleDiffFileCollapsed(fileKey);
655 }}
656 />
657 }
658 >
659 {collapsed ? (
660 <ChevronRightIcon className="size-4" />
661 ) : (
662 <ChevronDownIcon className="size-4" />
663 )}
664 </TooltipTrigger>
665 <TooltipPopup side="top">
666 {collapsed ? "Expand diff" : "Collapse diff"}
667 </TooltipPopup>
668 </Tooltip>
632669 )}
633670 options={{
634671 collapsed,
apps/web/src/components/ProjectScriptsControl.tsx+32-17
......@@ -53,6 +53,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
5353import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
5454import { Switch } from "./ui/switch";
5555import { Textarea } from "./ui/textarea";
56import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
5657
5758const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
5859 { id: "play", label: "Play" },
......@@ -219,17 +220,24 @@ export default function ProjectScriptsControl({
219220 <>
220221 {primaryScript ? (
221222 <Group aria-label="Project scripts">
222 <Button
223 size="xs"
224 variant="outline"
225 onClick={() => onRunScript(primaryScript)}
226 title={`Run ${primaryScript.name}`}
227 >
228 <ScriptIcon icon={primaryScript.icon} />
229 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
230 {primaryScript.name}
231 </span>
232 </Button>
223 <Tooltip>
224 <TooltipTrigger
225 render={
226 <Button
227 size="xs"
228 variant="outline"
229 aria-label={`Run ${primaryScript.name}`}
230 onClick={() => onRunScript(primaryScript)}
231 />
232 }
233 >
234 <ScriptIcon icon={primaryScript.icon} />
235 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
236 {primaryScript.name}
237 </span>
238 </TooltipTrigger>
239 <TooltipPopup side="top">Run {primaryScript.name}</TooltipPopup>
240 </Tooltip>
233241 <GroupSeparator className="hidden @3xl/header-actions:block" />
234242 <Menu highlightItemOnHover={false}>
235243 <MenuTrigger
......@@ -289,12 +297,19 @@ export default function ProjectScriptsControl({
289297 </Menu>
290298 </Group>
291299 ) : (
292 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">
293 <PlusIcon className="size-3.5" />
294 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
295 Add action
296 </span>
297 </Button>
300 <Tooltip>
301 <TooltipTrigger
302 render={
303 <Button size="xs" variant="outline" aria-label="Add action" onClick={openAddDialog} />
304 }
305 >
306 <PlusIcon className="size-3.5" />
307 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
308 Add action
309 </span>
310 </TooltipTrigger>
311 <TooltipPopup side="top">Add action</TooltipPopup>
312 </Tooltip>
298313 )}
299314
300315 <Dialog
apps/web/src/components/Sidebar.tsx+47-28
......@@ -608,14 +608,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
608608 </div>
609609 <div className="ml-auto flex shrink-0 items-center gap-1.5">
610610 {terminalStatus && (
611 <span
612 role="img"
613 aria-label={terminalStatus.label}
614 title={terminalStatus.label}
615 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
616 >
617 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
618 </span>
611 <Tooltip>
612 <TooltipTrigger
613 render={
614 <span
615 role="img"
616 aria-label={terminalStatus.label}
617 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
618 />
619 }
620 >
621 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
622 </TooltipTrigger>
623 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
624 </Tooltip>
619625 )}
620626 <div
621627 className={`flex min-w-12 justify-end ${
......@@ -691,12 +697,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
691697 </Tooltip>
692698 )}
693699 {jumpLabel ? (
694 <span
695 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
696 title={jumpLabel}
697 >
698 {jumpLabel}
699 </span>
700 <Tooltip>
701 <TooltipTrigger
702 render={
703 <span
704 aria-label={jumpLabel}
705 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
706 />
707 }
708 >
709 {jumpLabel}
710 </TooltipTrigger>
711 <TooltipPopup side="top">{jumpLabel}</TooltipPopup>
712 </Tooltip>
700713 ) : (
701714 <span
702715 className={`text-[10px] ${
......@@ -1994,20 +2007,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19942007 onContextMenu={handleProjectButtonContextMenu}
19952008 >
19962009 {!projectExpanded && projectStatus ? (
1997 <span
1998 aria-hidden="true"
1999 title={projectStatus.label}
2000 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
2001 >
2002 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2003 <span
2004 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2005 projectStatus.pulse ? "animate-pulse" : ""
2006 }`}
2007 />
2008 </span>
2009 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
2010 </span>
2010 <Tooltip>
2011 <TooltipTrigger
2012 render={
2013 <span
2014 aria-label={projectStatus.label}
2015 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
2016 />
2017 }
2018 >
2019 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2020 <span
2021 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2022 projectStatus.pulse ? "animate-pulse" : ""
2023 }`}
2024 />
2025 </span>
2026 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
2027 </TooltipTrigger>
2028 <TooltipPopup side="top">{projectStatus.label}</TooltipPopup>
2029 </Tooltip>
20112030 ) : (
20122031 <ChevronRightIcon
20132032 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({
102102}) {
103103 if (compact) {
104104 return (
105 <span
106 title={status.label}
107 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
105 <Tooltip>
106 <TooltipTrigger
107 render={
108 <span
109 aria-label={status.label}
110 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
111 />
112 }
113 >
114 <span
115 className={`size-[9px] rounded-full ${status.dotClass} ${
116 status.pulse ? "animate-pulse" : ""
117 }`}
118 />
119 </TooltipTrigger>
120 <TooltipPopup side="top">{status.label}</TooltipPopup>
121 </Tooltip>
122 );
123 }
124
125 return (
126 <Tooltip>
127 <TooltipTrigger
128 render={
129 <span
130 aria-label={status.label}
131 className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}
132 />
133 }
108134 >
109135 <span
110 className={`size-[9px] rounded-full ${status.dotClass} ${
136 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
111137 status.pulse ? "animate-pulse" : ""
112138 }`}
113139 />
114 <span className="sr-only">{status.label}</span>
115 </span>
116 );
117 }
118
119 return (
120 <span
121 title={status.label}
122 className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}
123 >
124 <span
125 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
126 status.pulse ? "animate-pulse" : ""
127 }`}
128 />
129 <span className="hidden md:inline">{status.label}</span>
130 </span>
140 <span className="hidden md:inline">{status.label}</span>
141 </TooltipTrigger>
142 <TooltipPopup side="top">{status.label}</TooltipPopup>
143 </Tooltip>
131144 );
132145}
133146
......@@ -220,14 +233,20 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
220233 return (
221234 <span className="inline-flex shrink-0 items-center gap-1.5">
222235 {terminalStatus ? (
223 <span
224 role="img"
225 aria-label={terminalStatus.label}
226 title={terminalStatus.label}
227 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
228 >
229 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
230 </span>
236 <Tooltip>
237 <TooltipTrigger
238 render={
239 <span
240 role="img"
241 aria-label={terminalStatus.label}
242 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
243 />
244 }
245 >
246 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
247 </TooltipTrigger>
248 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
249 </Tooltip>
231250 ) : null}
232251 {isRemoteThread ? (
233252 <Tooltip>
apps/web/src/components/chat/ChatComposer.tsx+91-72
......@@ -17,7 +17,7 @@ import {
1717 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1818 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1919} from "@t3tools/contracts";
20import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
20import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
2121import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2222import {
2323 memo,
......@@ -187,6 +187,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
187187}) {
188188 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
189189 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
191198 return (
192199 <>
......@@ -194,86 +201,98 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
194201
195202 {props.showInteractionModeToggle ? (
196203 <>
197 <Button
198 variant="ghost"
199 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
200 size="sm"
201 type="button"
202 onClick={props.onToggleInteractionMode}
203 title={
204 props.interactionMode === "plan"
205 ? "Plan mode — click to return to normal build mode"
206 : "Default mode — click to enter plan mode"
207 }
208 >
209 <BotIcon />
210 <span className="sr-only sm:not-sr-only">
211 {props.interactionMode === "plan" ? "Plan" : "Build"}
212 </span>
213 </Button>
204 <Tooltip>
205 <TooltipTrigger
206 render={
207 <Button
208 variant="ghost"
209 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
210 size="sm"
211 type="button"
212 onClick={props.onToggleInteractionMode}
213 aria-label={interactionModeTooltip}
214 />
215 }
216 >
217 <BotIcon />
218 <span className="sr-only sm:not-sr-only">
219 {props.interactionMode === "plan" ? "Plan" : "Build"}
220 </span>
221 </TooltipTrigger>
222 <TooltipPopup side="top">{interactionModeTooltip}</TooltipPopup>
223 </Tooltip>
214224
215225 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
216226 </>
217227 ) : null}
218228
219 <Select
220 value={props.runtimeMode}
221 onValueChange={(value) => props.onRuntimeModeChange(value!)}
222 >
223 <SelectTrigger
224 variant="ghost"
225 size="sm"
226 className="font-medium"
227 aria-label="Runtime mode"
228 title={runtimeModeOption.description}
229 <Tooltip>
230 <Select
231 value={props.runtimeMode}
232 onValueChange={(value) => props.onRuntimeModeChange(value!)}
229233 >
230 <RuntimeModeIcon className="size-4" />
231 <SelectValue>{runtimeModeOption.label}</SelectValue>
232 </SelectTrigger>
233 <SelectPopup alignItemWithTrigger={false}>
234 {runtimeModeOptions.map((mode) => {
235 const option = runtimeModeConfig[mode];
236 const OptionIcon = option.icon;
237 return (
238 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
239 <div className="grid min-w-0 gap-0.5">
240 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
241 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
242 {option.label}
243 </span>
244 <span className="text-muted-foreground text-xs leading-4">
245 {option.description}
246 </span>
247 </div>
248 </SelectItem>
249 );
250 })}
251 </SelectPopup>
252 </Select>
234 <TooltipTrigger
235 render={
236 <SelectTrigger
237 variant="ghost"
238 size="sm"
239 className="font-medium"
240 aria-label="Runtime mode"
241 />
242 }
243 >
244 <RuntimeModeIcon className="size-4" />
245 <SelectValue>{runtimeModeOption.label}</SelectValue>
246 </TooltipTrigger>
247 <SelectPopup alignItemWithTrigger={false}>
248 {runtimeModeOptions.map((mode) => {
249 const option = runtimeModeConfig[mode];
250 const OptionIcon = option.icon;
251 return (
252 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
253 <div className="grid min-w-0 gap-0.5">
254 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
255 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
256 {option.label}
257 </span>
258 <span className="text-muted-foreground text-xs leading-4">
259 {option.description}
260 </span>
261 </div>
262 </SelectItem>
263 );
264 })}
265 </SelectPopup>
266 </Select>
267 <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup>
268 </Tooltip>
253269
254270 {props.showPlanToggle ? (
255271 <>
256272 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
257 <Button
258 variant="ghost"
259 className={cn(
260 "shrink-0 whitespace-nowrap px-2 sm:px-3",
261 props.planSidebarOpen
262 ? "text-blue-400 hover:text-blue-300"
263 : "text-muted-foreground/70 hover:text-foreground/80",
264 )}
265 size="sm"
266 type="button"
267 onClick={props.onTogglePlanSidebar}
268 title={
269 props.planSidebarOpen
270 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
271 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`
272 }
273 >
274 <ListTodoIcon />
275 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
276 </Button>
273 <Tooltip>
274 <TooltipTrigger
275 render={
276 <Button
277 variant="ghost"
278 className={cn(
279 "shrink-0 whitespace-nowrap px-2 sm:px-3",
280 props.planSidebarOpen
281 ? "text-blue-400 hover:text-blue-300"
282 : "text-muted-foreground/70 hover:text-foreground/80",
283 )}
284 size="sm"
285 type="button"
286 onClick={props.onTogglePlanSidebar}
287 aria-label={planSidebarTooltip}
288 />
289 }
290 >
291 <ListTodoIcon />
292 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
293 </TooltipTrigger>
294 <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup>
295 </Tooltip>
277296 </>
278297 ) : null}
279298 </>
......@@ -1472,7 +1491,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14721491 const { snapshot, trigger } = resolveActiveComposerTrigger();
14731492 if (!trigger) return;
14741493 if (item.type === "path") {
1475 const replacement = `@${serializeComposerMentionPath(item.path)} `;
1494 const replacement = `${serializeComposerFileLink(item.path)} `;
14761495 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
14771496 snapshot.value,
14781497 trigger.rangeEnd,
apps/web/src/components/chat/ChatHeader.tsx+13-6
......@@ -92,12 +92,19 @@ export const ChatHeader = memo(function ChatHeader({
9292 <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
9393 <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">
9494 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
95 <h2
96 className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground"
97 title={activeThreadTitle}
98 >
99 {activeThreadTitle}
100 </h2>
95 <Tooltip>
96 <TooltipTrigger
97 render={
98 <h2
99 aria-label={activeThreadTitle}
100 className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground"
101 >
102 {activeThreadTitle}
103 </h2>
104 }
105 />
106 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
107 </Tooltip>
101108 {activeProjectName && (
102109 <Badge
103110 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) {
9494 };
9595}
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
97112describe("MessagesTimeline", () => {
98113 afterEach(() => {
99114 scrollToEndSpy.mockReset();
......@@ -128,6 +143,59 @@ describe("MessagesTimeline", () => {
128143 .element(page.getByText("Send a message to start the conversation."))
129144 .not.toBeInTheDocument();
130145 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 });
131199 } finally {
132200 await screen.unmount();
133201 }
......@@ -261,4 +329,61 @@ describe("MessagesTimeline", () => {
261329 await screen.unmount();
262330 }
263331 });
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 });
264389});
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-1
......@@ -188,7 +188,8 @@ describe("MessagesTimeline", () => {
188188
189189 expect(markup).toContain("Terminal 1 lines 1-5");
190190 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>');
192193 expect(markup).toContain("Show full message");
193194 }, 20_000);
194195
apps/web/src/components/chat/MessagesTimeline.tsx+124-70
......@@ -10,6 +10,7 @@ import {
1010 use,
1111 useCallback,
1212 useEffect,
13 Fragment,
1314 useMemo,
1415 useRef,
1516 useState,
......@@ -380,6 +381,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
380381 text={displayedUserMessage.visibleText}
381382 terminalContexts={terminalContexts}
382383 skills={ctx.skills}
384 markdownCwd={ctx.markdownCwd}
383385 footer={
384386 <>
385387 <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 }) {
404406 const activity = use(TimelineRowActivityCtx);
405407
406408 return (
407 <Button
408 type="button"
409 size="xs"
410 variant="outline"
411 disabled={activity.isRevertingCheckpoint || activity.isWorking}
412 onClick={() => ctx.onRevertUserMessage(messageId)}
413 title="Revert to this message"
414 >
415 <Undo2Icon className="size-3" />
416 </Button>
409 <Tooltip>
410 <TooltipTrigger
411 render={
412 <Button
413 type="button"
414 size="xs"
415 variant="outline"
416 disabled={activity.isRevertingCheckpoint || activity.isWorking}
417 onClick={() => ctx.onRevertUserMessage(messageId)}
418 aria-label="Revert to this message"
419 />
420 }
421 >
422 <Undo2Icon className="size-3" />
423 </TooltipTrigger>
424 <TooltipPopup side="top">Revert to this message</TooltipPopup>
425 </Tooltip>
417426 );
418427}
419428
......@@ -781,6 +790,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
781790 text: string;
782791 terminalContexts: ParsedTerminalContextEntry[];
783792 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
793 markdownCwd: string | undefined;
784794 footer?: ReactNode;
785795}) {
786796 const [expanded, setExpanded] = useState(false);
......@@ -810,6 +820,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
810820 text={props.text}
811821 terminalContexts={props.terminalContexts}
812822 skills={props.skills}
823 markdownCwd={props.markdownCwd}
813824 />
814825 </div>
815826 ) : null}
......@@ -847,7 +858,34 @@ const UserMessageBody = memo(function UserMessageBody(props: {
847858 text: string;
848859 terminalContexts: ParsedTerminalContextEntry[];
849860 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
861 markdownCwd: string | undefined;
850862}) {
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
851889 const reviewCommentSegments = parseReviewCommentMessageSegments(props.text);
852890 if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) {
853891 return (
......@@ -855,8 +893,14 @@ const UserMessageBody = memo(function UserMessageBody(props: {
855893 {reviewCommentSegments.map((segment) =>
856894 segment.kind === "text" ? (
857895 segment.text.trim().length > 0 ? (
858 <div key={segment.id} className="whitespace-pre-wrap wrap-break-word">
859 <SkillInlineText text={segment.text.trim()} skills={props.skills} />
896 <div key={segment.id} className="wrap-break-word">
897 <ChatMarkdown
898 text={segment.text.trim()}
899 cwd={props.markdownCwd}
900 skills={props.skills}
901 className="text-foreground"
902 lineBreaks
903 />
860904 </div>
861905 ) : null
862906 ) : (
......@@ -887,9 +931,10 @@ const UserMessageBody = memo(function UserMessageBody(props: {
887931 }
888932 if (matchIndex > cursor) {
889933 inlineNodes.push(
890 <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}>
891 <SkillInlineText text={props.text.slice(cursor, matchIndex)} skills={props.skills} />
892 </span>,
934 renderInlineMarkdownSegment(
935 props.text.slice(cursor, matchIndex),
936 `user-terminal-context-inline-before:${context.header}:${cursor}`,
937 ),
893938 );
894939 }
895940 inlineNodes.push(
......@@ -904,9 +949,10 @@ const UserMessageBody = memo(function UserMessageBody(props: {
904949 if (inlineNodes.length > 0) {
905950 if (cursor < props.text.length) {
906951 inlineNodes.push(
907 <span key={`user-message-terminal-context-inline-rest:${cursor}`}>
908 <SkillInlineText text={props.text.slice(cursor)} skills={props.skills} />
909 </span>,
952 renderInlineMarkdownSegment(
953 props.text.slice(cursor),
954 `user-message-terminal-context-inline-rest:${cursor}`,
955 ),
910956 );
911957 }
912958
......@@ -934,9 +980,14 @@ const UserMessageBody = memo(function UserMessageBody(props: {
934980
935981 if (props.text.length > 0) {
936982 inlineNodes.push(
937 <span key="user-message-terminal-context-inline-text">
938 <SkillInlineText text={props.text} skills={props.skills} />
939 </span>,
983 <ChatMarkdown
984 key="user-message-terminal-context-inline-text"
985 text={props.text}
986 cwd={props.markdownCwd}
987 skills={props.skills}
988 className="text-foreground"
989 lineBreaks
990 />,
940991 );
941992 } else if (inlinePrefix.length === 0) {
942993 return null;
......@@ -954,9 +1005,13 @@ const UserMessageBody = memo(function UserMessageBody(props: {
9541005 }
9551006
9561007 return (
957 <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground">
958 <SkillInlineText text={props.text} skills={props.skills} />
959 </div>
1008 <ChatMarkdown
1009 text={props.text}
1010 cwd={props.markdownCwd}
1011 skills={props.skills}
1012 className="text-foreground"
1013 lineBreaks
1014 />
9601015 );
9611016});
9621017
......@@ -1203,49 +1258,40 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
12031258 <div className="min-w-0 flex-1 overflow-hidden">
12041259 {rawCommand ? (
12051260 <div className="max-w-full">
1206 <p
1207 className={cn(
1208 "truncate text-xs leading-5",
1209 workToneClass(workEntry.tone),
1210 preview ? "text-muted-foreground/70" : "",
1211 )}
1212 title={displayText}
1213 >
1214 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
1215 {heading}
1216 </span>
1217 {preview && (
1218 <Tooltip>
1219 <TooltipTrigger
1220 closeDelay={0}
1221 delay={75}
1222 render={
1223 <span className="max-w-full cursor-default text-muted-foreground/55 transition-colors hover:text-muted-foreground/75 focus-visible:text-muted-foreground/75">
1224 {" "}
1225 - {preview}
1226 </span>
1227 }
1261 <Tooltip>
1262 <TooltipTrigger
1263 closeDelay={0}
1264 delay={75}
1265 render={
1266 <p
1267 className={cn(
1268 "truncate rounded-sm text-xs leading-5",
1269 workToneClass(workEntry.tone),
1270 preview ? "text-muted-foreground/70" : "",
1271 )}
1272 aria-label={displayText}
12281273 />
1229 <TooltipPopup
1230 align="start"
1231 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"
1232 side="top"
1233 >
1234 <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap">
1235 {rawCommand}
1236 </div>
1237 </TooltipPopup>
1238 </Tooltip>
1239 )}
1240 </p>
1274 }
1275 >
1276 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
1277 {heading}
1278 </span>
1279 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
1280 </TooltipTrigger>
1281 <TooltipPopup
1282 align="start"
1283 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"
1284 side="top"
1285 >
1286 <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap">
1287 {rawCommand}
1288 </div>
1289 </TooltipPopup>
1290 </Tooltip>
12411291 </div>
12421292 ) : (
12431293 <Tooltip>
1244 <TooltipTrigger
1245 className="block min-w-0 w-full text-left"
1246 title={displayText}
1247 aria-label={displayText}
1248 >
1294 <TooltipTrigger className="block min-w-0 w-full text-left" aria-label={displayText}>
12491295 <p
12501296 className={cn(
12511297 "truncate text-[11px] leading-5",
......@@ -1273,13 +1319,21 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
12731319 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
12741320 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
12751321 return (
1276 <span
1277 key={`${workEntry.id}:${filePath}`}
1278 className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"
1279 title={displayPath}
1280 >
1281 {displayPath}
1282 </span>
1322 <Tooltip key={`${workEntry.id}:${filePath}`}>
1323 <TooltipTrigger
1324 render={
1325 <span
1326 className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"
1327 aria-label={displayPath}
1328 />
1329 }
1330 >
1331 {displayPath}
1332 </TooltipTrigger>
1333 <TooltipPopup side="top" className="max-w-[min(40rem,calc(100vw-2rem))]">
1334 <span className="font-mono text-[11px] whitespace-nowrap">{displayPath}</span>
1335 </TooltipPopup>
1336 </Tooltip>
12831337 );
12841338 })}
12851339 {(workEntry.changedFiles?.length ?? 0) > 4 && (
apps/web/src/components/chat/ProviderStatusBanner.tsx+9-3
......@@ -3,6 +3,7 @@ import { memo } from "react";
33import { InfoIcon } from "lucide-react";
44import { cn } from "~/lib/utils";
55import { formatProviderDriverKindLabel } from "../../providerModels";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
67
78export const ProviderStatusBanner = memo(function ProviderStatusBanner({
89 status,
......@@ -39,9 +40,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
3940 <InfoIcon className="size-4 shrink-0" aria-hidden />
4041 <div className="flex min-w-0 flex-col gap-1">
4142 <div className="font-medium">{title}</div>
42 <div className="line-clamp-3 text-muted-foreground" title={message}>
43 {message}
44 </div>
43 <Tooltip>
44 <TooltipTrigger
45 render={<div className="line-clamp-3 text-muted-foreground">{message}</div>}
46 />
47 <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap">
48 {message}
49 </TooltipPopup>
50 </Tooltip>
4551 </div>
4652 </div>
4753 </div>
apps/web/src/components/chat/SkillInlineText.tsx+11-6
......@@ -3,11 +3,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts";
33
44import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
55import {
6 CHAT_INLINE_CHIP_CLASS_NAME,
7 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
68 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
8 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
99 SKILL_CHIP_ICON_SVG,
1010} from "../composerInlineChip";
11import { cn } from "~/lib/utils";
1112
1213const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
1314
......@@ -70,15 +71,19 @@ export function renderSkillInlineMarkdownChildren(
7071
7172function SkillChip(props: { skill: InlineSkill; rawText: string }) {
7273 return (
73 <span className="inline-flex align-middle leading-none">
74 <span className="sr-only">{props.rawText}</span>
75 <span aria-hidden="true" className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}>
74 <span className="inline-flex align-middle leading-none" data-markdown-copy={props.rawText}>
75 <span
76 className={cn(
77 CHAT_INLINE_CHIP_CLASS_NAME,
78 "border-fuchsia-500/25 bg-fuchsia-500/12 text-fuchsia-700 dark:text-fuchsia-300",
79 )}
80 >
7681 <span
7782 aria-hidden="true"
7883 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
7984 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
8085 />
81 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>
86 <span className={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}>
8287 {formatProviderSkillDisplayName(props.skill)}
8388 </span>
8489 </span>
apps/web/src/components/chat/ThreadErrorBanner.tsx+9-3
......@@ -2,6 +2,7 @@ import { memo } from "react";
22import { Alert, AlertAction, AlertDescription } from "../ui/alert";
33import { Button } from "../ui/button";
44import { CircleAlertIcon, XIcon } from "lucide-react";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
56
67export const ThreadErrorBanner = memo(function ThreadErrorBanner({
78 error,
......@@ -15,9 +16,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
1516 <div className="pt-3 mx-auto max-w-3xl">
1617 <Alert variant="error">
1718 <CircleAlertIcon />
18 <AlertDescription className="line-clamp-3" title={error}>
19 {error}
20 </AlertDescription>
19 <Tooltip>
20 <TooltipTrigger render={<AlertDescription className="line-clamp-3" />}>
21 {error}
22 </TooltipTrigger>
23 <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap">
24 {error}
25 </TooltipPopup>
26 </Tooltip>
2127 {onDismiss && (
2228 <AlertAction>
2329 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
apps/web/src/components/composerInlineChip.ts+9-3
......@@ -1,9 +1,15 @@
1export const COMPOSER_INLINE_CHIP_CLASS_NAME =
2 "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";
1const INLINE_CHIP_CLASS_NAME =
2 "inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";
3
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
48export 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
814export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
915 "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({
274274 const dot = (
275275 <button
276276 type="button"
277 title={tooltipText}
278277 aria-label={tooltipText}
279278 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"
280279 >
......@@ -881,11 +880,16 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
881880 ) : null}
882881 </Popover>
883882 </div>
884 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
885 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
886 <span aria-hidden> · </span>
887 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
888 </p>
883 <Tooltip>
884 <TooltipTrigger
885 render={<p aria-label={expiresAbsolute} className="text-xs text-muted-foreground" />}
886 >
887 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
888 <span aria-hidden> · </span>
889 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
890 </TooltipTrigger>
891 <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup>
892 </Tooltip>
889893 {shareablePairingUrl === null ? (
890894 <p className="text-[11px] text-muted-foreground/70">
891895 Copy the token and pair from another client using this backend&apos;s reachable host.
......@@ -898,17 +902,26 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
898902 <>
899903 {shareablePairingUrl ? (
900904 <Group aria-label="Copy selected endpoint">
901 <Button
902 size="xs"
903 variant="outline"
904 className="max-w-56"
905 title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`}
906 onClick={handleCopyDefaultLink}
907 >
908 <span className="truncate">
905 <Tooltip>
906 <TooltipTrigger
907 render={
908 <Button
909 size="xs"
910 variant="outline"
911 className="max-w-56"
912 aria-label={`Copy pairing URL for: ${defaultEndpointCopyLabel}`}
913 onClick={handleCopyDefaultLink}
914 />
915 }
916 >
917 <span className="truncate">
918 Copy pairing URL for: {defaultEndpointCopyLabel}
919 </span>
920 </TooltipTrigger>
921 <TooltipPopup side="top">
909922 Copy pairing URL for: {defaultEndpointCopyLabel}
910 </span>
911 </Button>
923 </TooltipPopup>
924 </Tooltip>
912925 <GroupSeparator />
913926 <Menu>
914927 <MenuTrigger
......@@ -1353,12 +1366,19 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
13531366 {endpoint.label}
13541367 </h3>
13551368 {shouldShowEndpointUrl ? (
1356 <p
1357 className="min-w-0 truncate text-xs leading-5 text-muted-foreground"
1358 title={endpoint.httpBaseUrl}
1359 >
1360 {endpoint.httpBaseUrl}
1361 </p>
1369 <Tooltip>
1370 <TooltipTrigger
1371 render={
1372 <p
1373 aria-label={endpoint.httpBaseUrl}
1374 className="min-w-0 truncate text-xs leading-5 text-muted-foreground"
1375 />
1376 }
1377 >
1378 {endpoint.httpBaseUrl}
1379 </TooltipTrigger>
1380 <TooltipPopup side="top">{endpoint.httpBaseUrl}</TooltipPopup>
1381 </Tooltip>
13621382 ) : null}
13631383 {!isAvailable ? (
13641384 <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() {
31163136 ) : null}
31173137 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
31183138 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>
3119 <p
3120 className="mt-1 truncate text-sm text-foreground"
3121 title={pendingTailscaleServeBaseUrl ?? undefined}
3122 >
3123 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3124 </p>
3139 <Tooltip>
3140 <TooltipTrigger
3141 render={
3142 <p
3143 aria-label={pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3144 className="mt-1 truncate text-sm text-foreground"
3145 />
3146 }
3147 >
3148 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3149 </TooltipTrigger>
3150 <TooltipPopup side="top">
3151 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3152 </TooltipPopup>
3153 </Tooltip>
31253154 </div>
31263155 </DialogPanel>
31273156 <DialogFooter>
apps/web/src/components/settings/KeybindingsSettings.tsx+13-3
......@@ -805,9 +805,19 @@ function KeybindingTableRow({
805805 <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">
806806 <div className="min-w-0 pr-4">
807807 <div className="flex min-w-0 items-center gap-1.5">
808 <div className="truncate text-[13px] font-medium text-foreground" title={row.command}>
809 {commandLabel(row.command)}
810 </div>
808 <Tooltip>
809 <TooltipTrigger
810 render={
811 <div
812 aria-label={row.command}
813 className="truncate text-[13px] font-medium text-foreground"
814 />
815 }
816 >
817 {commandLabel(row.command)}
818 </TooltipTrigger>
819 <TooltipPopup side="top">{row.command}</TooltipPopup>
820 </Tooltip>
811821 </div>
812822 </div>
813823 <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({
572572 }, []);
573573
574574 return (
575 <button
576 aria-label={railLabel}
577 className={cn(
578 /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */
579 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
580 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
581 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
582 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
583 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
584 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
585 className,
586 )}
587 data-sidebar="rail"
588 data-slot="sidebar-rail"
589 onClick={handleClick}
590 onPointerCancel={handlePointerCancel}
591 onPointerDown={handlePointerDown}
592 onPointerMove={handlePointerMove}
593 onPointerUp={handlePointerUp}
594 ref={railRef}
595 tabIndex={-1}
596 title={railTitle}
597 type="button"
598 {...props}
599 />
575 <Tooltip>
576 <TooltipTrigger
577 render={
578 <button
579 aria-label={railLabel}
580 className={cn(
581 /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */
582 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
583 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
584 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
585 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
586 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
587 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
588 className,
589 )}
590 data-sidebar="rail"
591 data-slot="sidebar-rail"
592 onClick={handleClick}
593 onPointerCancel={handlePointerCancel}
594 onPointerDown={handlePointerDown}
595 onPointerMove={handlePointerMove}
596 onPointerUp={handlePointerUp}
597 ref={railRef}
598 tabIndex={-1}
599 type="button"
600 {...props}
601 />
602 }
603 />
604 <TooltipPopup side="right">{railTitle}</TooltipPopup>
605 </Tooltip>
600606 );
601607}
602608
apps/web/src/components/ui/toast.tsx+61-45
......@@ -35,6 +35,7 @@ import {
3535 shouldHideCollapsedToastContent,
3636 shouldRenderThreadScopedToast,
3737} from "./toast.logic";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip";
3839
3940export type ThreadToastData = {
4041 threadRef?: ScopedThreadRef | null;
......@@ -113,16 +114,24 @@ function handleToastDismissClick(
113114
114115function CopyErrorButton({ text }: { text: string }) {
115116 const { copyToClipboard, isCopied } = useCopyToClipboard();
117 const label = isCopied ? "Copied error" : "Copy error";
116118
117119 return (
118 <button
119 className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground"
120 onClick={() => copyToClipboard(text)}
121 title="Copy error"
122 type="button"
123 >
124 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
125 </button>
120 <Tooltip>
121 <TooltipTrigger
122 render={
123 <button
124 aria-label={label}
125 className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground"
126 onClick={() => copyToClipboard(text)}
127 type="button"
128 />
129 }
130 >
131 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
132 </TooltipTrigger>
133 <TooltipPopup side="top">{label}</TooltipPopup>
134 </Tooltip>
126135 );
127136}
128137
......@@ -205,43 +214,50 @@ function ToastDescriptionAndExpandable({
205214
206215 return (
207216 <>
208 <div
209 aria-expanded={open}
210 className={cn(
211 "group flex min-w-0 w-full cursor-pointer select-none items-start gap-1.5 rounded-sm text-left outline-none ring-offset-background",
212 "transition-colors hover:bg-muted/40",
213 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
214 )}
215 onClick={toggle}
216 onKeyDown={onKeyDown}
217 role="button"
218 tabIndex={0}
219 title={open ? collapseLabel : expandLabel}
220 >
221 <div className="min-w-0 flex-1">
222 <Toast.Description
223 className={cn(
224 "min-w-0 select-none wrap-break-word text-muted-foreground",
225 errorDescriptionClampClass(toastType, toastDescription),
226 "underline-offset-2 decoration-muted-foreground/60 group-hover:underline",
227 )}
228 data-slot="toast-description"
229 />
230 </div>
231 {open ? (
232 <ChevronUpIcon
233 aria-hidden
234 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
235 strokeWidth={2.25}
236 />
237 ) : (
238 <ChevronDownIcon
239 aria-hidden
240 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
241 strokeWidth={2.25}
242 />
243 )}
244 </div>
217 <Tooltip>
218 <TooltipTrigger
219 render={
220 <div
221 aria-label={open ? collapseLabel : expandLabel}
222 aria-expanded={open}
223 className={cn(
224 "group flex min-w-0 w-full cursor-pointer select-none items-start gap-1.5 rounded-sm text-left outline-none ring-offset-background",
225 "transition-colors hover:bg-muted/40",
226 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
227 )}
228 onClick={toggle}
229 onKeyDown={onKeyDown}
230 role="button"
231 tabIndex={0}
232 />
233 }
234 >
235 <div className="min-w-0 flex-1">
236 <Toast.Description
237 className={cn(
238 "min-w-0 select-none wrap-break-word text-muted-foreground",
239 errorDescriptionClampClass(toastType, toastDescription),
240 "underline-offset-2 decoration-muted-foreground/60 group-hover:underline",
241 )}
242 data-slot="toast-description"
243 />
244 </div>
245 {open ? (
246 <ChevronUpIcon
247 aria-hidden
248 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
249 strokeWidth={2.25}
250 />
251 ) : (
252 <ChevronDownIcon
253 aria-hidden
254 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
255 strokeWidth={2.25}
256 />
257 )}
258 </TooltipTrigger>
259 <TooltipPopup side="top">{open ? collapseLabel : expandLabel}</TooltipPopup>
260 </Tooltip>
245261 {open ? <div className={toastExpandablePanelClassName}>{expandableContent}</div> : null}
246262 </>
247263 );
apps/web/src/components/ui/tooltip.tsx+1-1
......@@ -31,7 +31,7 @@ function TooltipPopup({
3131 <TooltipPrimitive.Positioner
3232 align={align}
3333 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"
3535 data-slot="tooltip-positioner"
3636 side={side}
3737 sideOffset={sideOffset}
apps/web/src/composer-editor-mentions.test.ts+59-6
......@@ -10,7 +10,7 @@ describe("splitPromptIntoComposerSegments", () => {
1010 it("splits mention tokens followed by whitespace into mention segments", () => {
1111 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md please")).toEqual([
1212 { type: "text", text: "Inspect " },
13 { type: "mention", path: "AGENTS.md" },
13 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
1414 { type: "text", text: " please" },
1515 ]);
1616 });
......@@ -24,7 +24,7 @@ describe("splitPromptIntoComposerSegments", () => {
2424 it("keeps newlines around mention tokens", () => {
2525 expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([
2626 { type: "text", text: "one\n" },
27 { type: "mention", path: "src/index.ts" },
27 { type: "mention", path: "src/index.ts", source: "@src/index.ts" },
2828 { type: "text", text: " \ntwo" },
2929 ]);
3030 });
......@@ -32,7 +32,7 @@ describe("splitPromptIntoComposerSegments", () => {
3232 it("splits quoted mention tokens containing whitespace", () => {
3333 expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
3434 { type: "text", text: "Inspect " },
35 { type: "mention", path: "My File.md" },
35 { type: "mention", path: "My File.md", source: '@"My File.md"' },
3636 { type: "text", text: " please" },
3737 ]);
3838 });
......@@ -40,11 +40,53 @@ describe("splitPromptIntoComposerSegments", () => {
4040 it("unescapes quoted mention token content", () => {
4141 expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
4242 { 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 },
4448 { type: "text", text: " please" },
4549 ]);
4650 });
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
4890 it("splits skill tokens followed by whitespace into skill segments", () => {
4991 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
5092 { type: "text", text: "Use " },
......@@ -67,7 +109,7 @@ describe("splitPromptIntoComposerSegments", () => {
67109 ).toEqual([
68110 { type: "text", text: "Inspect " },
69111 { type: "terminal-context", context: null },
70 { type: "mention", path: "AGENTS.md" },
112 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
71113 { type: "text", text: " please" },
72114 ]);
73115 });
......@@ -94,7 +136,7 @@ describe("splitPromptIntoComposerSegments", () => {
94136 { type: "terminal-context", context: null },
95137 { type: "skill", name: "review-follow-up" },
96138 { type: "text", text: " after " },
97 { type: "mention", path: "AGENTS.md" },
139 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
98140 { type: "text", text: " " },
99141 ]);
100142 });
......@@ -151,4 +193,15 @@ describe("selectionTouchesMentionBoundary", () => {
151193 ),
152194 ).toBe(true);
153195 });
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 });
154207});
apps/web/src/composer-editor-mentions.ts+32-1
......@@ -11,6 +11,7 @@ export type ComposerPromptSegment =
1111 | {
1212 type: "mention";
1313 path: string;
14 source: string;
1415 }
1516 | {
1617 type: "skill";
......@@ -23,6 +24,9 @@ export type ComposerPromptSegment =
2324
2425const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
2526const 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
2731function rangeIncludesIndex(start: number, end: number, index: number): boolean {
2832 return start <= index && index < end;
......@@ -57,6 +61,29 @@ type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
5761function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
5862 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
6087 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
6188 const fullMatch = match[0];
6289 const prefix = match[1] ?? "";
......@@ -189,7 +216,11 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
189216 }
190217
191218 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 });
193224 } else {
194225 segments.push({ type: "skill", name: match.value });
195226 }
apps/web/src/composer-logic.test.ts+20
......@@ -167,6 +167,16 @@ describe("expandCollapsedComposerCursor", () => {
167167 );
168168 });
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
170180 it("allows path trigger detection to close after selecting a mention", () => {
171181 const text = "what's in my @AGENTS.md ";
172182 const collapsedCursorAfterMention = "what's in my ".length + 2;
......@@ -211,6 +221,16 @@ describe("collapseExpandedComposerCursor", () => {
211221 );
212222 });
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
214234 it("keeps replacement cursors aligned when another mention already exists earlier", () => {
215235 const text = "open @AGENTS.md then @src/index.ts ";
216236 const expandedCursor = text.length;
apps/web/src/composer-logic.ts+2-3
......@@ -1,4 +1,3 @@
1import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
21import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
32import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
43
......@@ -55,7 +54,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5554
5655 for (const segment of segments) {
5756 if (segment.type === "mention") {
58 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
57 const expandedLength = segment.source.length;
5958 if (remaining <= 1) {
6059 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
6160 }
......@@ -143,7 +142,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
143142
144143 for (const segment of segments) {
145144 if (segment.type === "mention") {
146 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
145 const expandedLength = segment.source.length;
147146 if (remaining === 0) {
148147 return collapsedCursor;
149148 }
apps/web/src/index.css+198-69
......@@ -305,10 +305,44 @@ label:has(> select#reasoning-effort) select {
305305.chat-markdown ol,
306306.chat-markdown blockquote,
307307.chat-markdown pre,
308.chat-markdown table {
308.chat-markdown .chat-markdown-table-container {
309309 margin: 0.65rem 0;
310310}
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
312346.chat-markdown ul {
313347 padding-left: 1.25rem;
314348 list-style-type: disc;
......@@ -339,14 +373,39 @@ label:has(> select#reasoning-effort) select {
339373 margin-top: 0.25rem;
340374}
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
342386.chat-markdown a {
343387 color: var(--info-foreground);
344 text-decoration: underline;
345 text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent);
388 text-decoration: none;
346389}
347390
348.chat-markdown a:hover {
349 opacity: 0.8;
391.chat-markdown a:not(.chat-markdown-file-link):hover,
392.chat-markdown a:not(.chat-markdown-file-link):focus-visible {
393 background-image: radial-gradient(circle, currentcolor 0.75px, transparent 1px);
394 background-position: left bottom;
395 background-repeat: repeat-x;
396 background-size: 4px 2px;
397}
398
399.chat-markdown .chat-markdown-link-favicon {
400 @apply inline-flex;
401 width: 14px;
402 height: 14px;
403 margin-inline: 0.25em 0.2em;
404 vertical-align: -0.125em;
405}
406
407.chat-markdown .chat-markdown-link-leading {
408 white-space: nowrap;
350409}
351410
352411.chat-markdown blockquote {
......@@ -355,6 +414,35 @@ label:has(> select#reasoning-effort) select {
355414 color: var(--muted-foreground);
356415}
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
358446.chat-markdown :not(pre) > code {
359447 border: 1px solid var(--border);
360448 border-radius: 0.375rem;
......@@ -364,45 +452,19 @@ label:has(> select#reasoning-effort) select {
364452 font-size: 0.75rem;
365453}
366454
367.chat-markdown-file-link {
368 display: inline-flex;
369 align-items: center;
370 gap: 0.28rem;
371 border: 1px solid color-mix(in srgb, var(--border) 92%, transparent);
372 border-radius: 0.375rem;
373 background: color-mix(in srgb, var(--muted) 88%, var(--background));
374 padding: 0.08rem 0.34rem;
455.chat-markdown a.chat-markdown-file-link {
375456 color: var(--foreground);
376 font-family: var(--font-mono, ui-monospace, monospace);
377 font-size: 0.75rem;
378 line-height: 1.15;
379 vertical-align: text-bottom;
380 transition:
381 background-color 120ms ease,
382 border-color 120ms ease,
383 color 120ms ease,
384 opacity 120ms ease;
457 text-decoration: none;
385458}
386459
387.chat-markdown-file-link:hover {
388 opacity: 1;
460.chat-markdown a.chat-markdown-file-link:hover {
389461 color: var(--foreground);
390 border-color: color-mix(in srgb, var(--border) 65%, var(--foreground));
391 background: color-mix(in srgb, var(--muted) 72%, var(--background));
462 text-decoration: none;
392463}
393464
394.chat-markdown-file-link:focus-visible {
465.chat-markdown a.chat-markdown-file-link:focus-visible {
395466 outline: none;
396 box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent);
397}
398
399.chat-markdown-file-link-icon {
400 opacity: 0.72;
401}
402
403.chat-markdown-file-link-label {
404 color: color-mix(in srgb, var(--foreground) 88%, transparent);
405 line-height: 1.1;
467 box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent);
406468}
407469
408470.chat-markdown pre {
......@@ -421,6 +483,24 @@ label:has(> select#reasoning-effort) select {
421483 font-size: 0.75rem;
422484}
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
424504.markdown-file-link-tooltip-scroll {
425505 scrollbar-width: thin;
426506 scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent;
......@@ -440,66 +520,115 @@ label:has(> select#reasoning-effort) select {
440520}
441521
442522.chat-markdown .chat-markdown-codeblock {
443 --chat-markdown-codeblock-copy-button-space: 1.5rem;
444 position: relative;
445523 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));
446528}
447529
448.chat-markdown .chat-markdown-codeblock pre {
449 margin: 0;
450 padding-right: calc(0.9rem + var(--chat-markdown-codeblock-copy-button-space));
530.chat-markdown .chat-markdown-codeblock-header {
531 display: flex;
532 align-items: center;
533 justify-content: space-between;
534 gap: 0.5rem;
535 border-bottom: 1px solid var(--border);
536 background: var(--muted);
537 padding: 0.25rem 0.375rem 0.25rem 0.7rem;
538 color: var(--muted-foreground);
451539}
452540
453.chat-markdown .chat-markdown-copy-button {
454 position: absolute;
455 top: 0.5rem;
456 right: 0.5rem;
457 z-index: 1;
541.chat-markdown .chat-markdown-codeblock-title {
458542 display: inline-flex;
543 min-width: 0;
459544 align-items: center;
460 justify-content: center;
461 height: 1.5rem;
462 width: 1.5rem;
463 border-radius: 0.375rem;
464 border: 1px solid var(--border);
465 background: color-mix(in srgb, var(--background) 82%, transparent);
545 gap: 0.4rem;
546 font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
547 font-size: 0.6875rem;
548}
549
550.chat-markdown .chat-markdown-chrome-action {
466551 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;
474552}
475553
476.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button,
477.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button {
478 opacity: 1;
479 pointer-events: auto;
554.chat-markdown .chat-markdown-chrome-action:hover {
555 color: var(--foreground);
480556}
481557
482.chat-markdown .chat-markdown-copy-button:hover {
558.chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {
483559 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;
485573}
486574
487575.chat-markdown .chat-markdown-shiki .shiki {
488 background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important;
576 background: transparent !important;
489577}
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. */
491584.chat-markdown table {
492585 width: 100%;
586 min-width: max-content;
493587 border-collapse: collapse;
588 overflow-wrap: normal;
589 word-break: normal;
590 font-size: 0.75rem;
494591}
495592
496593.chat-markdown th,
497594.chat-markdown td {
498 border: 1px solid var(--border);
499 padding: 0.35rem 0.45rem;
595 padding: 0.45rem 0.75rem;
500596 text-align: left;
501597}
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
503632@keyframes provider-update-pill-countdown {
504633 from {
505634 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
6969 return lookup;
7070}
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
7290export function basenameOfPath(pathValue: string): string {
7391 const slashIndex = pathValue.lastIndexOf("/");
7492 if (slashIndex === -1) return pathValue;
......@@ -166,6 +184,17 @@ function resolveFolderDefinition(pathValue: string, theme: "light" | "dark"): st
166184 );
167185}
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
169198export function getVscodeIconUrlForEntry(
170199 pathValue: string,
171200 kind: "file" | "directory",
packages/shared/src/composerTrigger.test.ts+27-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { serializeComposerMentionPath } from "./composerTrigger.ts";
3import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts";
44
55describe("serializeComposerMentionPath", () => {
66 it("keeps simple mention paths unquoted", () => {
......@@ -15,3 +15,29 @@ describe("serializeComposerMentionPath", () => {
1515 expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');
1616 });
1717});
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 {
1717 return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1818}
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
2043function clampCursor(text: string, cursor: number): number {
2144 if (!Number.isFinite(cursor)) return text.length;
2245 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
pnpm-lock.yaml+43
......@@ -521,6 +521,15 @@ importers:
521521 react-markdown:
522522 specifier: ^10.1.0
523523 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
524533 remark-gfm:
525534 specifier: ^4.0.1
526535 version: 4.0.1
......@@ -6483,6 +6492,9 @@ packages:
64836492 hast-util-raw@9.1.0:
64846493 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
64866498 hast-util-to-html@9.0.5:
64876499 resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
64886500
......@@ -7238,6 +7250,9 @@ packages:
72387250 mdast-util-mdxjs-esm@2.0.1:
72397251 resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
72407252
7253 mdast-util-newline-to-break@2.0.0:
7254 resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
7255
72417256 mdast-util-phrasing@4.1.0:
72427257 resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
72437258
......@@ -8350,12 +8365,18 @@ packages:
83508365 rehype-raw@7.0.0:
83518366 resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
83528367
8368 rehype-sanitize@6.0.0:
8369 resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
8370
83538371 rehype-stringify@10.0.1:
83548372 resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
83558373
83568374 rehype@13.0.2:
83578375 resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==}
83588376
8377 remark-breaks@4.0.0:
8378 resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
8379
83598380 remark-gfm@4.0.1:
83608381 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
83618382
......@@ -15786,6 +15807,12 @@ snapshots:
1578615807 web-namespaces: 2.0.1
1578715808 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
1578915816 hast-util-to-html@9.0.5:
1579015817 dependencies:
1579115818 '@types/hast': 3.0.4
......@@ -16580,6 +16607,11 @@ snapshots:
1658016607 transitivePeerDependencies:
1658116608 - 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
1658316615 mdast-util-phrasing@4.1.0:
1658416616 dependencies:
1658516617 '@types/mdast': 4.0.4
......@@ -17962,6 +17994,11 @@ snapshots:
1796217994 hast-util-raw: 9.1.0
1796317995 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
1796518002 rehype-stringify@10.0.1:
1796618003 dependencies:
1796718004 '@types/hast': 3.0.4
......@@ -17975,6 +18012,12 @@ snapshots:
1797518012 rehype-stringify: 10.0.1
1797618013 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
1797818021 remark-gfm@4.0.1:
1797918022 dependencies:
1798018023 '@types/mdast': 4.0.4