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