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
11 changes: 9 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ import {
import type { ToolCallVisibility } from "@t3tools/contracts/settings";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { hostedThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import {
hostedThreadLink,
parseThreadLinkHref,
trimThreadAutolinkPadding,
} from "@t3tools/shared/threadLinks";
import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
import {
parseComposerContextHref,
Expand Down Expand Up @@ -1202,7 +1206,10 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
}) {
const models = useModelMentions(props.environmentId, props.markdown);
const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
const liveMarkdown = useLiveThreadLinkLabels(
trimThreadAutolinkPadding(props.markdown),
props.environmentId,
);
const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown);
const linkHandlers: MarkdownLinkHandlers = {
...props.linkHandlers,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/TimelineAssistantRows.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
import { trimThreadAutolinkPadding } from "@t3tools/shared/threadLinks";
import { type RunId } from "@t3tools/contracts";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
import { memo, use, useState } from "react";
Expand Down Expand Up @@ -33,7 +34,8 @@ export function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind
row.projectedItem?.item.id,
);
const text = liveText ?? row.message.text;
const messageText = text || (row.message.streaming ? "" : "(empty response)");
const messageText =
trimThreadAutolinkPadding(text) || (row.message.streaming ? "" : "(empty response)");

return (
<>
Expand Down
30 changes: 30 additions & 0 deletions packages/shared/src/threadLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
relabelThreadLinks,
threadLinkCandidates,
threadLinkRefs,
trimThreadAutolinkPadding,
} from "./threadLinks.ts";

const ref = (environmentId: string, threadId = "chat-1") => ({
Expand Down Expand Up @@ -151,6 +152,35 @@ describe("thread links", () => {
);
});

it("trims the whitespace a model writes inside a thread autolink's brackets, and nothing else", () => {
const delegated =
"t3-thread://v2/ffa70b7f-bbd5-47ff-9f01-bdad03fba727/thread:delegated-task:command%3Amcp%3A14ece488-0be2-4b67-bb57-7c2ee9190309%3Adelegate-task%3Azack-settings-ui-opus-20261107-2144";
expect(trimThreadAutolinkPadding(`Opened < ${delegated}>.`)).toBe(`Opened <${delegated}>.`);
expect(
trimThreadAutolinkPadding("<\tt3-thread://v1/t1 \t> and <\n t3-thread://v2/local/t2\n>"),
).toBe("<t3-thread://v1/t1> and <t3-thread://v2/local/t2>");
expect(
trimThreadAutolinkPadding("[Settings](< t3-thread://v1/t1 >)\r\n<\r\nt3-thread://v1/t2\r\n>"),
).toBe("[Settings](<t3-thread://v1/t1>)\r\n<t3-thread://v1/t2>");
const untouched = [
"<t3-thread://v2/local/t1> [a](<t3-thread://v2/local/t1>)",
"< t3 > < t3-thread://v2/env > < t3-thread://v2/<env>/<thread> > < t3-thread://v1/ >",
"< t3-thread://v2/local/thr ead > <\n\nt3-thread://v1/t1>",
'< https://example.com/t3-thread > <a href="t3-thread://v2/local/t1">x</a>',
"Literal `< t3-thread://v2/local/t1 >`",
"Literal `code\n< t3-thread://v2/local/t1 >\ncode`",
" < t3-thread://v2/local/t1 >",
"\t< t3-thread://v2/local/t1 >",
"```md",
"< t3-thread://v2/local/t1 >",
"```",
].join("\n");
expect(trimThreadAutolinkPadding(untouched)).toBe(untouched);
expect(
relabelThreadLinks(trimThreadAutolinkPadding(`< ${delegated} >`), local, () => "Settings UI"),
).toBe(`[Settings UI](${delegated})`);
});

it("lists the threads a message's links can name, outside code", () => {
expect(
threadLinkRefs(
Expand Down
20 changes: 18 additions & 2 deletions packages/shared/src/threadLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`;
const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`;
const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+";
const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`;
// Code comes first in the alternation so a link written inside a code span or fence is skipped.
// Code comes first in an alternation so a link written inside a code span or fence is skipped.
const CODE = String.raw`(?<fence>(\`{3,}|~{3,})[\s\S]*?(?:\2|(?![\s\S])))|(?<span>(\`+)[\s\S]*?\4)`;
const THREAD_LINK_OUTSIDE_CODE = new RegExp(
String.raw`(?<fence>(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(\`+)[^\n]*?\4)|\[(?<label>[^\]\n]*)\]\(<?(?<href>t3-thread:\/\/v[12]\/[^\s>)]+|${HOSTED_LINK})>?\)|<(?<autolink>t3-thread:\/\/v[12]\/[^\s<>]+|${HOSTED_LINK})>|(?<bare>${HOSTED_LINK})(?![\w%/?#-])`,
String.raw`${CODE}|\[(?<label>[^\]\n]*)\]\(<?(?<href>t3-thread:\/\/v[12]\/[^\s>)]+|${HOSTED_LINK})>?\)|<(?<autolink>t3-thread:\/\/v[12]\/[^\s<>]+|${HOSTED_LINK})>|(?<bare>${HOSTED_LINK})(?![\w%/?#-])`,
"g",
);

Expand Down Expand Up @@ -139,6 +140,21 @@ export function formatHostedThreadLink(
return cleaned ? `[${cleaned}](${href})` : href;
}

// At most one line break on a side, so a blank line never joins two paragraphs.
const PADDED_THREAD_AUTOLINK = new RegExp(
String.raw`${CODE}|^(?: {4}|\t)[^\n]*|<[ \t]*\r?\n?[ \t]*(?<autolink>t3-thread:\/\/v[12]\/[^\s<>]+)[ \t]*\r?\n?[ \t]*>`,
"gm",
);

/** Models sometimes pad an autolink, as in `< t3-thread://… >`, which Markdown leaves as text. */
export function trimThreadAutolinkPadding(markdown: string): string {
if (!markdown.includes(`${THREAD_LINK_PROTOCOL}://v`)) return markdown;
return markdown.replace(PADDED_THREAD_AUTOLINK, (source, ...args) => {
const href = (args.at(-1) as { autolink?: string }).autolink;
return href !== undefined && parseThreadLinkHref(href) ? `<${href}>` : source;
});
}

export function hasThreadLinks(markdown: string): boolean {
return markdown.includes(`${THREAD_LINK_PROTOCOL}://v`) || markdown.includes(HOSTED_PREFIX);
}
Expand Down
Loading