Skip to content

Commit c2fbf0a

Browse files
feat(server,web,mobile): thread links name their environment and never a title (#26239)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 949929d commit c2fbf0a

27 files changed

Lines changed: 427 additions & 185 deletions

‎apps/mobile/src/features/threads/PendingUserInputCard.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Button } from "../../components/ui/Button";
22
import { QuestionAttachments } from "./QuestionAttachments";
3-
import type { RuntimeRequestId } from "@t3tools/contracts";
3+
import type { EnvironmentId, RuntimeRequestId } from "@t3tools/contracts";
44
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
55
import { useRef } from "react";
66
import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
@@ -23,13 +23,15 @@ import { cn } from "../../lib/cn";
2323
import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
2424
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
2525
import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
26+
import { useLiveThreadLinkLabels } from "../../state/entities";
2627
import {
2728
isPendingUserInputOptionSelected,
2829
type PendingUserInput,
2930
type PendingUserInputDraftAnswer,
3031
} from "../../lib/threadActivity";
3132

3233
export interface PendingUserInputCardProps {
34+
readonly environmentId: EnvironmentId;
3335
readonly canOperateThread: boolean;
3436
readonly pendingUserInput: PendingUserInput;
3537
readonly maxHeight: DerivedValue<number>;
@@ -59,12 +61,13 @@ export interface PendingUserInputCardProps {
5961
/** Android cannot hit-test overlays beyond their parent's bounds; render its expanded card in-flow. */
6062
const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
6163

62-
function QuestionText(props: { readonly markdown: string }) {
64+
function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId }) {
6365
const styles = useMarkdownPreviewStyles();
66+
const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
6467
const openLink = useOpenPullRequest();
6568
return (
6669
<SelectableMarkdownText
67-
markdown={props.markdown}
70+
markdown={markdown}
6871
textStyle={styles.nativeTextStyle}
6972
onLinkPress={(href) => void openLink(href)}
7073
/>
@@ -217,7 +220,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
217220
<Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
218221
{question.header}
219222
</Text>
220-
<QuestionText markdown={question.question} />
223+
<QuestionText markdown={question.question} environmentId={props.environmentId} />
221224
<View className="gap-2">
222225
{question.options.map((option) => {
223226
const optionValue = option.value ?? option.label.trim();

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
15131513
) : null}
15141514
{props.activePendingUserInput ? (
15151515
<PendingUserInputCard
1516+
environmentId={props.environmentId}
15161517
canOperateThread={props.canOperateThread}
15171518
pendingUserInput={props.activePendingUserInput}
15181519
maxHeight={pendingUserInputMaxHeight}

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
} from "@t3tools/contracts";
5757
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
5858
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
59-
import { parseThreadLinkHref, resolveThreadLinkRef } from "@t3tools/shared/threadLinks";
59+
import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
6060
import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
6161
import {
6262
parseComposerContextHref,
@@ -239,6 +239,7 @@ import {
239239
WORKSPACE_BASENAME_LOOKUP_LIMIT,
240240
} from "@t3tools/client-runtime/workspace-basename-lookup";
241241
import {
242+
resolveThreadLink,
242243
useLiveThreadLinkLabels,
243244
useQuietThreadShell,
244245
useThreadChipIcons,
@@ -2255,13 +2256,16 @@ export const ThreadFeed = memo(function ThreadFeed({
22552256
});
22562257
};
22572258
const resolveLinkedThread = (href: string) => {
2258-
const linkedThreadId = parseThreadLinkHref(href);
2259-
if (!linkedThreadId) return null;
2260-
const source = scopeThreadRef(props.environmentId, linkedThreadId);
2261-
return (
2262-
resolveThreadLinkRef(source, appAtomRegistry.get(environmentThreadShells.threadRefsAtom)) ??
2263-
source
2259+
const link = parseThreadLinkHref(href);
2260+
if (!link) return null;
2261+
const resolved = resolveThreadLink(
2262+
(atom) => appAtomRegistry.get(atom),
2263+
link,
2264+
props.environmentId,
22642265
);
2266+
if (resolved !== null || link.environmentId !== null) return resolved;
2267+
// A v1 link names no environment, so it falls back to this message's.
2268+
return scopeThreadRef(props.environmentId, link.threadId);
22652269
};
22662270
const onMarkdownLinkPress = (href: string, imageEmbed = false) => {
22672271
markdownLinkRequest.current?.abort();
@@ -2273,12 +2277,14 @@ export const ThreadFeed = memo(function ThreadFeed({
22732277
if (instructionsPath) openSkillInstructions(instructionsPath);
22742278
return;
22752279
}
2276-
const target = resolveLinkedThread(href);
2277-
if (target) {
2278-
openLinkedThread({
2279-
environmentId: String(target.environmentId),
2280-
threadId: String(target.threadId),
2281-
});
2280+
if (parseThreadLinkHref(href)) {
2281+
const target = resolveLinkedThread(href);
2282+
if (target) {
2283+
openLinkedThread({
2284+
environmentId: String(target.environmentId),
2285+
threadId: String(target.threadId),
2286+
});
2287+
}
22822288
return;
22832289
}
22842290
const media = resolveMarkdownMediaPreview(href, {

‎apps/mobile/src/lib/enrichedLinkPresentation.test.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ describe("Enriched link presentation", () => {
5151
});
5252

5353
it("presents thread links as thread chips and leaves other links alone", () => {
54-
expect(enrichedThreadLinkPresentation("t3-thread://v1/thread%3Aproject%3A1")).toEqual({
55-
accent: "#009c96",
56-
icon: { kind: "chip", name: "messages-square" },
57-
});
54+
for (const href of ["t3-thread://v1/thread%3Aproject%3A1", "t3-thread://v2/env/thread_1"]) {
55+
expect(enrichedThreadLinkPresentation(href), href).toEqual({
56+
accent: "#009c96",
57+
icon: { kind: "chip", name: "messages-square" },
58+
});
59+
}
5860
for (const href of [
5961
"https://github.com/example/repo/pull/7",
6062
"https://example.com",
@@ -70,7 +72,7 @@ describe("Enriched link presentation", () => {
7072
expect(
7173
threadChipTargets(
7274
here,
73-
"See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a) and [B](t3-thread://v1/thread_b).",
75+
"See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a), [B](t3-thread://v1/thread_b) and [D](t3-thread://v2/env-there/thread_d).",
7476
[
7577
{
7678
version: 1,
@@ -86,6 +88,12 @@ describe("Enriched link presentation", () => {
8688
).toEqual([
8789
{ href: "t3-thread://v1/thread_a", environmentId: here, threadId: "thread_a", link: true },
8890
{ href: "t3-thread://v1/thread_b", environmentId: here, threadId: "thread_b", link: true },
91+
{
92+
href: "t3-thread://v2/env-there/thread_d",
93+
environmentId: there,
94+
threadId: "thread_d",
95+
link: true,
96+
},
8997
{
9098
href: "t3-context://v1/thread/thr1",
9199
environmentId: there,

‎apps/mobile/src/lib/enrichedLinkPresentation.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,16 @@ export function threadChipTargets(
5858
): ReadonlyArray<ThreadChipTarget> {
5959
const targets = new Map<string, ThreadChipTarget>();
6060
if (hasThreadLinks(markdown)) {
61-
for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v1\/[^\s)]+)\)/g)) {
62-
const threadId = parseThreadLinkHref(href);
63-
if (threadId) targets.set(href, { href, environmentId, threadId, link: true });
61+
for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) {
62+
const link = parseThreadLinkHref(href);
63+
if (link) {
64+
targets.set(href, {
65+
href,
66+
environmentId: link.environmentId ?? environmentId,
67+
threadId: link.threadId,
68+
link: true,
69+
});
70+
}
6471
}
6572
}
6673
for (const record of records ?? []) {

‎apps/mobile/src/state/entities.ts‎

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ import {
1717
} from "@t3tools/contracts";
1818
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
1919
import {
20+
guessThreadLinkRef,
2021
hasThreadLinks,
22+
parseThreadLinkHref,
2123
relabelThreadLinks,
22-
resolveThreadLinkRef,
24+
threadLinkCandidates,
25+
threadLinkRefs,
26+
type ThreadLink,
2327
} from "@t3tools/shared/threadLinks";
2428
import * as Option from "effect/Option";
2529
import * as Schema from "effect/Schema";
@@ -45,26 +49,35 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
4549
const EMPTY_THREAD_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
4650
Atom.withLabel("mobile-thread:empty"),
4751
);
48-
const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map();
49-
const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe(
50-
Atom.withLabel("mobile-thread-titles:empty"),
52+
const EMPTY_THREAD_LINK_TITLES_ATOM = Atom.make<ReadonlyMap<string, string>>(new Map()).pipe(
53+
Atom.withLabel("mobile-thread-link-titles:empty"),
5154
);
5255

53-
const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => {
54-
let previous = EMPTY_THREAD_TITLES;
56+
const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId]));
57+
const threadKey = (ref: ScopedThreadRef) => JSON.stringify([ref.environmentId, ref.threadId]);
58+
59+
// Null when this client doesn't know the thread. Keyed, so a link never walks an environment's threads.
60+
const threadTitleAtom = Atom.family((key: string) => {
61+
const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key)));
62+
return Atom.make((get) => get(environmentThreadShells.threadShellAtom(ref))?.title ?? null).pipe(
63+
Atom.withLabel(`mobile-thread-title:${key}`),
64+
);
65+
});
66+
67+
const decodeThreadKeys = Schema.decodeUnknownSync(Schema.Array(Schema.String));
68+
69+
const threadLinkTitlesAtom = Atom.family((key: string) => {
70+
const refKeys = decodeThreadKeys(JSON.parse(key));
5571
return Atom.make((get) => {
56-
const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId));
57-
const unchanged =
58-
index.size === previous.size &&
59-
Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title);
60-
if (!unchanged) {
61-
previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title]));
72+
const titles = new Map<string, string>();
73+
for (const refKey of refKeys) {
74+
const title = get(threadTitleAtom(refKey));
75+
if (title !== null) titles.set(refKey, title);
6276
}
63-
return previous;
64-
}).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`));
77+
return titles;
78+
}).pipe(Atom.withLabel(`mobile-thread-link-titles:${key}`));
6579
});
6680

67-
const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId]));
6881
const decodeThreadChipTargets = Schema.decodeUnknownSync(
6982
Schema.Array(
7083
Schema.Struct({
@@ -118,13 +131,11 @@ const threadChipIconsAtom = Atom.family((key: string) => {
118131
return Atom.make((get) => {
119132
const icons = new Map<string, string>();
120133
for (const target of targets) {
121-
const source = scopeThreadRef(target.environmentId, target.threadId);
122-
const ref = target.link
123-
? (resolveThreadLinkRef(source, get(environmentThreadShells.threadRefsAtom)) ?? source)
124-
: source;
125-
const icon = get(
126-
threadProjectChipIconAtom(JSON.stringify([ref.environmentId, ref.threadId])),
127-
);
134+
const link = target.link ? parseThreadLinkHref(target.href) : null;
135+
const ref =
136+
(link ? resolveThreadLink(get, link, target.environmentId) : null) ??
137+
scopeThreadRef(target.environmentId, target.threadId);
138+
const icon = get(threadProjectChipIconAtom(threadKey(ref)));
128139
if (icon) icons.set(target.href, icon);
129140
}
130141
return icons;
@@ -190,11 +201,26 @@ export function useChildThreadInputs(ref: ScopedThreadRef) {
190201

191202
export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
192203
const titles = useAtomValue(
193-
hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM,
204+
hasThreadLinks(markdown)
205+
? threadLinkTitlesAtom(JSON.stringify(threadLinkRefs(markdown, environmentId).map(threadKey)))
206+
: EMPTY_THREAD_LINK_TITLES_ATOM,
207+
);
208+
return relabelThreadLinks(markdown, environmentId, (ref) => titles.get(threadKey(ref)));
209+
}
210+
211+
/** Keyed reads; every thread is read only to place a v1 link its message's environment lacks. */
212+
export function resolveThreadLink(
213+
read: <A>(atom: Atom.Atom<A>) => A,
214+
link: ThreadLink,
215+
messageEnvironmentId: EnvironmentId,
216+
): ScopedThreadRef | null {
217+
const known = threadLinkCandidates(link, messageEnvironmentId).find(
218+
(ref) => read(threadTitleAtom(threadKey(ref))) !== null,
194219
);
195-
return titles.size === 0
196-
? markdown
197-
: relabelThreadLinks(markdown, (threadId) => titles.get(threadId));
220+
if (known) return known;
221+
return link.environmentId === null
222+
? guessThreadLinkRef(link, read(environmentThreadShells.threadRefsAtom))
223+
: null;
198224
}
199225

200226
export function useThreadChipIcons(

‎apps/server/src/mcp/OrchestratorMcpService.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
CommandId,
3+
type EnvironmentId,
34
type RunId,
45
isProviderAvailable,
56
MessageId,
@@ -748,13 +749,14 @@ function threadPullRequests(
748749
}
749750

750751
function listItemFromShell(
752+
environmentId: EnvironmentId,
751753
shell: OrchestrationV2ThreadShell,
752754
nowMs: number,
753755
): OrchestratorMcpThreadListItem {
754756
return {
755757
threadId: shell.id,
756758
title: shell.title,
757-
link: formatThreadLink(shell.id, shell.title),
759+
link: formatThreadLink({ environmentId, threadId: shell.id }),
758760
createdBy: shell.createdBy,
759761
creationSource: shell.creationSource,
760762
status: shell.activityRunStatus ?? shell.status,
@@ -776,6 +778,7 @@ function listItemFromShell(
776778
}
777779

778780
function threadDetail(
781+
environmentId: EnvironmentId,
779782
projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
780783
itemCount: number,
781784
shell: OrchestrationV2ThreadShell,
@@ -787,7 +790,7 @@ function threadDetail(
787790
threadId: projection.thread.id,
788791
projectId: projection.thread.projectId,
789792
title: projection.thread.title,
790-
link: formatThreadLink(projection.thread.id, projection.thread.title),
793+
link: formatThreadLink({ environmentId, threadId: projection.thread.id }),
791794
createdBy: projection.thread.createdBy,
792795
creationSource: projection.thread.creationSource,
793796
status: active?.status ?? latest?.status ?? "idle",
@@ -1518,7 +1521,10 @@ const make = Effect.gen(function* () {
15181521
const result = {
15191522
taskId: task.id,
15201523
childThreadId: task.childThreadId,
1521-
link: formatThreadLink(task.childThreadId, childControls.thread.title),
1524+
link: formatThreadLink({
1525+
environmentId: scope.environmentId,
1526+
threadId: task.childThreadId,
1527+
}),
15221528
childRunId: childRun?.id ?? null,
15231529
childNodeId: task.id,
15241530
status,
@@ -1674,7 +1680,9 @@ const make = Effect.gen(function* () {
16741680
return {
16751681
taskId: task.id,
16761682
childThreadId: null,
1677-
...(task.remoteChild === undefined ? {} : { remoteChild: task.remoteChild }),
1683+
...(task.remoteChild === undefined
1684+
? {}
1685+
: { remoteChild: task.remoteChild, link: formatThreadLink(task.remoteChild) }),
16781686
childRunId: null,
16791687
childNodeId: task.id,
16801688
status,
@@ -2739,7 +2747,7 @@ const make = Effect.gen(function* () {
27392747
);
27402748
return {
27412749
threadId,
2742-
link: formatThreadLink(threadId, projection.thread.title),
2750+
link: formatThreadLink({ environmentId: scope.environmentId, threadId }),
27432751
runId: run?.id ?? null,
27442752
status: run?.status ?? "idle",
27452753
title: projection.thread.title,
@@ -2792,7 +2800,7 @@ const make = Effect.gen(function* () {
27922800
return {
27932801
projectId,
27942802
currentThreadId: parent?.thread.id ?? null,
2795-
threads: page.map((shell) => listItemFromShell(shell, nowMs)),
2803+
threads: page.map((shell) => listItemFromShell(scope.environmentId, shell, nowMs)),
27962804
nextCursor,
27972805
total: filtered.length,
27982806
} satisfies OrchestratorMcpThreadListResult;
@@ -2872,7 +2880,13 @@ const make = Effect.gen(function* () {
28722880
}
28732881
}
28742882
return {
2875-
thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis),
2883+
thread: threadDetail(
2884+
scope.environmentId,
2885+
target,
2886+
timeline.totalItems,
2887+
shell,
2888+
yield* Clock.currentTimeMillis,
2889+
),
28762890
recentRuns: target.runs
28772891
.toSorted((left, right) => right.ordinal - left.ordinal)
28782892
.slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
@@ -2931,7 +2945,7 @@ const make = Effect.gen(function* () {
29312945
);
29322946
return {
29332947
threadId: input.threadId,
2934-
link: formatThreadLink(input.threadId, target.thread.title),
2948+
link: formatThreadLink({ environmentId: scope.environmentId, threadId: input.threadId }),
29352949
messageId,
29362950
runId: result.run.id,
29372951
...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }),

0 commit comments

Comments
 (0)