Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
29 changes: 6 additions & 23 deletions apps/web/src/__tests__/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MarkdownContent content="https://github.com/milex-consulting/CaravanFE" />);
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(<MarkdownContent content={url} />);
const link = screen.getByRole("link", { name: url });

expect(link).toHaveAttribute("href", url);
await user.hover(link);
await waitFor(() => {
const tooltip = document.querySelector<HTMLElement>("[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(<MarkdownContent content="https://github.com/milex-consulting/CaravanFE/issues/42" />);
expect(screen.getByText("milex-consulting/CaravanFE#42")).toBeInTheDocument();
});

it("compacts a bare non-GitHub URL to host/path, dropping the protocol", () => {
render(<MarkdownContent content="https://www.example.com/docs/start/" />);
expect(screen.getByText("example.com/docs/start")).toBeInTheDocument();
});

it("never rewrites authored link text", () => {
render(<MarkdownContent content="[the repo](https://github.com/milex-consulting/CaravanFE)" />);
expect(screen.getByText("the repo")).toBeInTheDocument();
expect(screen.queryByText("milex-consulting/CaravanFE")).not.toBeInTheDocument();
});

it("calls desktopBridge.openExternalUrl for http links", () => {
Expand Down
42 changes: 3 additions & 39 deletions apps/web/src/components/chat/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -283,7 +251,7 @@ function MarkdownLinkAnchor({
render={
<a
href={safeHref}
className="inline-flex max-w-full items-center gap-1 align-baseline text-primary no-underline transition-colors hover:underline"
className="inline-flex max-w-full items-center gap-1 align-baseline text-link no-underline transition-colors hover:underline"
target="_blank"
rel="noopener noreferrer"
data-testid="markdown-link"
Expand All @@ -302,7 +270,6 @@ function MarkdownLinkAnchor({
/>
)}
<span className="min-w-0 text-fade">{label}</span>
{safeHref ? <ExternalLink size={12} aria-hidden className="shrink-0 text-muted" /> : null}
</a>
}
/>
Expand All @@ -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 = (
<MarkdownLinkAnchor
safeHref={safeHref}
isPreviewable={isPreviewable}
faviconUrl={faviconUrl}
fileIconPath={fileIconPath}
label={label}
label={children}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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],
Expand Down
58 changes: 25 additions & 33 deletions apps/web/src/features/thread-overview/entries/repository.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<McodeTransport, "getRemoteUrl">;

Expand Down Expand Up @@ -100,37 +101,28 @@ function ThreadOverviewRepositoryRow({
if (!canOpen) return null;

return (
<div className="grid animate-thread-overview-row-reveal">
<div className="min-h-0 overflow-hidden">
<div
data-testid="thread-overview-repository"
className="flex w-full flex-col gap-1.5 px-3 py-1.5"
<div data-testid="thread-overview-repository">
<ThreadOverviewTooltipButton content={repository.webUrl ?? label}>
<Button
variant="ghost"
size="compact"
type="button"
onClick={onOpen}
data-testid="thread-overview-repository-link"
aria-label={`Open ${label} on remote`}
className={cn(OVERVIEW_ROW_CLASS, "justify-start text-ink/75")}
>
<span className="font-mono text-xs font-medium uppercase leading-tight tracking-[0.18em] text-muted">
REPOSITORY
</span>
<ThreadOverviewTooltipButton content={repository.webUrl ?? label}>
<Button
variant="ghost"
size="compact"
type="button"
onClick={onOpen}
data-testid="thread-overview-repository-link"
aria-label={`Open ${label} on remote`}
className="-mx-1.5 h-7 min-w-0 justify-start gap-1.5 rounded-md px-1.5 text-left text-primary hover:bg-hover/50 hover:text-primary focus-visible:ring-inset"
>
<SiteFavicon
src={repository.faviconUrl}
frameTestId="thread-overview-repository-favicon-frame"
imageTestId="thread-overview-repository-favicon"
fallback={<GitBranch size={14} className="shrink-0 text-muted" />}
/>
<span className="text-fade text-xs font-medium">{label}</span>
<ExternalLink size={12} aria-hidden className="shrink-0 text-muted" />
</Button>
</ThreadOverviewTooltipButton>
</div>
</div>
<OverviewRowIcon>
<SiteFavicon
src={repository.faviconUrl}
frameTestId="thread-overview-repository-favicon-frame"
imageTestId="thread-overview-repository-favicon"
fallback={<GitBranch size={14} className="shrink-0 text-muted" />}
/>
</OverviewRowIcon>
<span className="text-fade font-medium">{label}</span>
</Button>
</ThreadOverviewTooltipButton>
</div>
);
}
Expand Down Expand Up @@ -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" ? <RepositoryEntry /> : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading