diff --git a/apps/server/src/features/agents/turns/__tests__/thread-title.test.ts b/apps/server/src/features/agents/turns/__tests__/thread-title.test.ts new file mode 100644 index 000000000..e8f4ab927 --- /dev/null +++ b/apps/server/src/features/agents/turns/__tests__/thread-title.test.ts @@ -0,0 +1,26 @@ +import "reflect-metadata"; +import { describe, expect, it } from "vitest"; +import { titleFrom } from "../thread-creation-coordinator.js"; + +describe("titleFrom", () => { + it("keeps a 60-character first line whole without an ellipsis", () => { + const line = "Refactor the provider adapter so retries survive reconnects!"; + expect(titleFrom(line)).toBe(line); + }); + + it("cuts an overlong line at a word boundary without an ellipsis", () => { + const title = titleFrom("word ".repeat(60).trim() + "tail"); + expect(title.length).toBeLessThanOrEqual(200); + expect(title.endsWith("...")).toBe(false); + expect(title.endsWith(" ")).toBe(false); + expect(title.split(" ").every((word) => word === "word")).toBe(true); + }); + + it("uses only the first line", () => { + expect(titleFrom("Fix the header\nMore detail here")).toBe("Fix the header"); + }); + + it("falls back to New Thread for blank content", () => { + expect(titleFrom(" \n ")).toBe("New Thread"); + }); +}); diff --git a/apps/server/src/features/agents/turns/thread-creation-coordinator.ts b/apps/server/src/features/agents/turns/thread-creation-coordinator.ts index adb0dc683..1ef34d0a6 100644 --- a/apps/server/src/features/agents/turns/thread-creation-coordinator.ts +++ b/apps/server/src/features/agents/turns/thread-creation-coordinator.ts @@ -867,11 +867,18 @@ function startupErrorDetail(error: unknown): string | undefined { return message.split(/\r?\n/).find((line) => line.trim())?.trim().slice(0, 2_000); } -function titleFrom(content: string): string { +const MAX_THREAD_TITLE_LENGTH = 200; + +/** + * Derives a fallback thread title from the first line of a user message, + * cutting at a word boundary when it exceeds {@link MAX_THREAD_TITLE_LENGTH}. + * Never appends an ellipsis: clients fade titles that overflow their space. + */ +export function titleFrom(content: string): string { const firstLine = content.split("\n")[0].trim(); - if (firstLine.length <= 50) return firstLine || "New Thread"; - const truncated = firstLine.slice(0, 50); + if (!firstLine) return "New Thread"; + if (firstLine.length <= MAX_THREAD_TITLE_LENGTH) return firstLine; + const truncated = firstLine.slice(0, MAX_THREAD_TITLE_LENGTH); const lastSpace = truncated.lastIndexOf(" "); - const cutPoint = lastSpace > 0 ? lastSpace : 50; - return truncated.slice(0, cutPoint) + "..."; + return truncated.slice(0, lastSpace > 0 ? lastSpace : MAX_THREAD_TITLE_LENGTH).trimEnd(); } diff --git a/apps/web/src/__tests__/MarkdownContent.test.tsx b/apps/web/src/__tests__/MarkdownContent.test.tsx index bdfa2abde..9d2715da9 100644 --- a/apps/web/src/__tests__/MarkdownContent.test.tsx +++ b/apps/web/src/__tests__/MarkdownContent.test.tsx @@ -86,38 +86,21 @@ describe("MarkdownContent link handling", () => { expect(screen.queryByTestId("markdown-link-favicon-frame")).not.toBeInTheDocument(); }); - it("shows a bare GitHub repo URL as owner/repo but opens the full URL", async () => { - render(); - const url = "https://github.com/milex-consulting/CaravanFE"; - const link = screen.getByRole("link", { name: "milex-consulting/CaravanFE" }); - const user = userEvent.setup(); + it("renders a bare URL exactly as written with the site favicon", async () => { + const url = "https://github.com/Mzeey-Empire/mcode/pull/2"; + render(); + const link = screen.getByRole("link", { name: url }); expect(link).toHaveAttribute("href", url); - await user.hover(link); - await waitFor(() => { - const tooltip = document.querySelector("[data-slot='tooltip-content']"); - expect(tooltip).toBeVisible(); - expect(tooltip).toHaveTextContent(url); - }); + expect(screen.getByTestId("markdown-link-favicon")).toHaveAttribute("src", "https://github.com/favicon.ico"); - await user.click(link); + await userEvent.setup().click(link); expect(mockOpenExternalUrl).toHaveBeenCalledWith(url); }); - it("shows a bare GitHub issue URL as owner/repo#number", () => { - render(); - expect(screen.getByText("milex-consulting/CaravanFE#42")).toBeInTheDocument(); - }); - - it("compacts a bare non-GitHub URL to host/path, dropping the protocol", () => { - render(); - expect(screen.getByText("example.com/docs/start")).toBeInTheDocument(); - }); - it("never rewrites authored link text", () => { render(); expect(screen.getByText("the repo")).toBeInTheDocument(); - expect(screen.queryByText("milex-consulting/CaravanFE")).not.toBeInTheDocument(); }); it("calls desktopBridge.openExternalUrl for http links", () => { diff --git a/apps/web/src/components/chat/MarkdownContent.tsx b/apps/web/src/components/chat/MarkdownContent.tsx index 6a7db8036..38071d5ee 100644 --- a/apps/web/src/components/chat/MarkdownContent.tsx +++ b/apps/web/src/components/chat/MarkdownContent.tsx @@ -3,7 +3,7 @@ import { memo, useMemo, lazy, Suspense } from "react"; import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; -import { ExternalLink, Globe } from "lucide-react"; +import { Globe } from "lucide-react"; import { isMcodeWorkspacePreviewUrl, mcodeWorkspacePreviewHref, @@ -149,38 +149,6 @@ function resolveMarkdownHref(href: string | undefined, workspacePath: string | n return undefined; } -/** - * Produces a compact, human-readable label for a bare URL so inline links read - * like the Overview repository row (`owner/repo`) instead of a raw `https://` - * string. GitHub repos collapse to `owner/repo`, issues and pull requests to - * `owner/repo#123`, commits to `owner/repo@shortsha`; every other URL drops the - * protocol and a leading `www.`, keeping `host/path`. Only used when the link's - * visible text is the URL itself; authored link text is never rewritten. - */ -function formatBareUrlLabel(rawHref: string): string { - try { - const url = new URL(rawHref); - const host = url.hostname.replace(/^www\./, ""); - const path = url.pathname.replace(/\/+$/, ""); - const segments = path.split("/").filter(Boolean); - - if (host === "github.com" && segments.length >= 2) { - const ownerRepo = `${segments[0]}/${segments[1]}`; - if (segments.length >= 4 && (segments[2] === "issues" || segments[2] === "pull")) { - return `${ownerRepo}#${segments[3]}`; - } - if (segments.length >= 4 && segments[2] === "commit") { - return `${ownerRepo}@${segments[3].slice(0, 7)}`; - } - return ownerRepo; - } - - return path ? `${host}${path}` : host; - } catch { - return rawHref; - } -} - /** Extracts plain text from link children when it is a single string node. */ function getPlainTextChild(children: React.ReactNode): string | null { if (typeof children === "string") return children; @@ -283,7 +251,7 @@ function MarkdownLinkAnchor({ render={ )} {label} - {safeHref ? : null} } /> @@ -322,16 +289,13 @@ function MarkdownLink({ const faviconUrl = getLinkFaviconUrl(safeHref); const childText = getPlainTextChild(children); const fileIconPath = getMarkdownFileIconPath({ href, safeHref, childText }); - const isBareUrlText = - !!childText && !!safeHref && (childText === href || childText === safeHref || HTTP_URL_RE.test(childText.trim())); - const label = isBareUrlText && safeHref ? formatBareUrlLabel(safeHref) : children; const anchor = ( ); diff --git a/apps/web/src/features/thread-overview/__tests__/overview-registry.test.tsx b/apps/web/src/features/thread-overview/__tests__/overview-registry.test.tsx index 008ce82dc..1ea07a350 100644 --- a/apps/web/src/features/thread-overview/__tests__/overview-registry.test.tsx +++ b/apps/web/src/features/thread-overview/__tests__/overview-registry.test.tsx @@ -58,7 +58,6 @@ describe("overview registry", () => { expect(getOverviewEntries(subject).map(({ id, section, order }) => [id, section, order])).toEqual([ ["save-recovery", "activity", 10], ["changes", "activity", 20], - ["repository", "activity", 30], ["tasks", "activity", 40], ["plan", "activity", 45], ["local", "lane", 50], @@ -67,6 +66,7 @@ describe("overview registry", () => { ["commit", "lane", 80], ["terminals", "terminals", 85], ["usage", "summary", 90], + ["repository", "lane", 95], ["subagents", "activity", 100], ["pull-request", "lane", 110], ["browser", "activity", 120], diff --git a/apps/web/src/features/thread-overview/entries/repository.tsx b/apps/web/src/features/thread-overview/entries/repository.tsx index 4a783549c..4802bffda 100644 --- a/apps/web/src/features/thread-overview/entries/repository.tsx +++ b/apps/web/src/features/thread-overview/entries/repository.tsx @@ -4,9 +4,10 @@ import type { OverviewSubject } from "@/features/thread-overview/overview-subjec import { useOverviewContext } from "@/features/thread-overview/overview-state"; import { createOverviewEntryState } from "@/features/thread-overview/overview-entry-state"; import { getTransport, type McodeTransport, type Thread } from "@/transport"; -import { ExternalLink, GitBranch } from "lucide-react"; +import { GitBranch } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useCallback, useEffect, useState } from "react"; -import { LoadStatus, ThreadOverviewTooltipButton } from "@/features/thread-overview/overview-row"; +import { LoadStatus, OVERVIEW_ROW_CLASS, OverviewRowIcon, ThreadOverviewTooltipButton } from "@/features/thread-overview/overview-row"; type ThreadOverviewRepositoryTransport = Pick; @@ -100,37 +101,28 @@ function ThreadOverviewRepositoryRow({ if (!canOpen) return null; return ( -
-
-
+ + - -
-
+ + } + /> + + {label} + +
); } @@ -207,7 +199,7 @@ function RepositoryEntry() { />); } -/** Repository block in the thread overview, preserving its existing row position. */ +/** Lane row in the thread overview that links the thread's remote repository. */ export function RepositoryEntryBlock({ subject }: { subject: OverviewSubject }) { return subject.kind === "thread" ? : null; } diff --git a/apps/web/src/features/thread-overview/overview-registry.ts b/apps/web/src/features/thread-overview/overview-registry.ts index 9462aef46..27eccee4b 100644 --- a/apps/web/src/features/thread-overview/overview-registry.ts +++ b/apps/web/src/features/thread-overview/overview-registry.ts @@ -51,7 +51,7 @@ export interface OverviewHeaderAction { export const OVERVIEW_ENTRIES: readonly OverviewEntry[] = [ { id: "save-recovery", section: "activity", order: 10, subjects: ["thread"], Entry: SaveRecoveryEntryBlock }, { id: "changes", section: "activity", order: 20, subjects: ["thread"], Entry: ChangesEntryBlock, State: ChangesEntryState }, - { id: "repository", section: "activity", order: 30, subjects: ["thread"], Entry: RepositoryEntryBlock, State: RepositoryEntryState }, + { id: "repository", section: "lane", order: 95, subjects: ["thread"], Entry: RepositoryEntryBlock, State: RepositoryEntryState }, { id: "tasks", section: "activity", order: 40, subjects: ["thread"], Entry: TasksEntryBlock }, { id: "plan", section: "activity", order: 45, subjects: ["thread"], Entry: PlanEntryBlock }, { id: "local", section: "lane", order: 50, subjects: ["thread"], Entry: LocalEntryBlock, State: LocalEntryState },