authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 20:59:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 20:59:26-07:00
log0f09627a111f7e9adc22c256c725565672676f1a
treef7bd5ab7bea6ef95fb848b3aefcc2600386f59d5
parent04ad17425a80484bf323a7d5076bd477a9499119
signature Signed by PGP key B5690EEEBB952194

fix(server,web,mobile): thread links reference the thread id, not a baked title (#17017)


18 files changed, 384 insertions(+), 180 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+20-10
......@@ -198,6 +198,7 @@ import {
198198} from "../../state/assets";
199199import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
200200import { usePreparedConnection } from "../../state/session";
201import { useLiveThreadLinkLabels } from "../../state/entities";
201202import { useThreadSelection } from "../../state/use-thread-selection";
202203import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
203204import * as Option from "effect/Option";
......@@ -906,16 +907,15 @@ interface MarkdownLinkHandlers {
906907
907908const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
908909 readonly markdown: string;
910 readonly environmentId: EnvironmentId;
909911 readonly markdownStyles: MarkdownStyleSet;
910912 readonly linkHandlers: MarkdownLinkHandlers;
911913 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
912914 readonly renderImage: MarkdownImageRenderer;
913915 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
914916}) {
915 const segments = useMemo(
916 () => splitCodexArtifactTemplateMarkdown(props.markdown),
917 [props.markdown],
918 );
917 const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
918 const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);
919919
920920 return segments.map((segment) => {
921921 if (segment.kind === "artifact-template") {
......@@ -1905,6 +1905,7 @@ function renderFeedEntry(
19051905 <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
19061906 <AssistantMarkdownContent
19071907 markdown={renderedText}
1908 environmentId={props.environmentId}
19081909 markdownStyles={styles}
19091910 linkHandlers={props.markdownLinkHandlers}
19101911 onUseArtifactTemplate={props.onUseArtifactTemplate}
......@@ -2002,7 +2003,8 @@ function UserMessageContent(props: UserMessageContentProps) {
20022003 const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
20032004 const navigation = useNavigation();
20042005 const { selectedThread } = useThreadSelection();
2005 const text = replaceComposerContextReferences(props.text, (ref) => {
2006 const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
2007 const text = replaceComposerContextReferences(liveText, (ref) => {
20062008 const available = props.context?.records.some((record) => record.contextId === ref.contextId);
20072009 return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;
20082010 });
......@@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22922294 const userBubbleColor = theme["--color-user-bubble"];
22932295 const onMarkdownLinkPress = useCallback(
22942296 (href: string) => {
2295 const threadLink = parseThreadLinkHref(href);
2296 if (threadLink) {
2297 // A thread link names a thread in this feed's environment.
2298 const linkedThreadId = parseThreadLinkHref(href);
2299 if (linkedThreadId) {
22972300 navigation.navigate("Thread", {
2298 environmentId: String(threadLink.environmentId),
2299 threadId: String(threadLink.threadId),
2301 environmentId: String(props.environmentId),
2302 threadId: String(linkedThreadId),
23002303 });
23012304 return;
23022305 }
......@@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25042507 (text: string) => (
25052508 <AssistantMarkdownContent
25062509 markdown={text}
2510 environmentId={props.environmentId}
25072511 markdownStyles={markdownStyles.assistant}
25082512 linkHandlers={markdownLinkHandlers}
25092513 renderImage={renderMarkdownImage}
25102514 skills={props.skills}
25112515 />
25122516 ),
2513 [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills],
2517 [
2518 markdownStyles.assistant,
2519 markdownLinkHandlers,
2520 renderMarkdownImage,
2521 props.skills,
2522 props.environmentId,
2523 ],
25142524 );
25152525 const reviewCommentColors = useReviewCommentColors();
25162526 const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
apps/mobile/src/lib/nativeMarkdownText.test.ts+2-2
......@@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => {
9191 children: [
9292 {
9393 type: "link",
94 href: "t3-thread://v1/env/thread-1",
94 href: "t3-thread://v1/thread-1",
9595 children: [{ type: "text", content: "Fix the build" }],
9696 },
9797 ],
9898 }),
99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]);
99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]);
100100 });
101101
102102 it("preserves the destination of a link with a code-formatted label", () => {
apps/mobile/src/state/entities.ts+36
......@@ -12,8 +12,11 @@ import type {
1212 ScopedProjectRef,
1313 ScopedThreadRef,
1414 ServerConfig,
15 ThreadId,
1516} from "@t3tools/contracts";
17import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks";
1618import { Atom } from "effect/reactivity";
19import { useMemo } from "react";
1720
1821import { environmentProjects } from "./projects";
1922import { environmentServerConfigsAtom, serverEnvironment } from "./server";
......@@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
2831const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
2932 Atom.withLabel("mobile-server-config:empty"),
3033);
34const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map();
35const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe(
36 Atom.withLabel("mobile-thread-titles:empty"),
37);
38
39/** Thread titles in one environment. Emits when a title changes, not on every shell update. */
40const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => {
41 let previous = EMPTY_THREAD_TITLES;
42 return Atom.make((get) => {
43 const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId));
44 const unchanged =
45 index.size === previous.size &&
46 Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title);
47 if (!unchanged) {
48 previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title]));
49 }
50 return previous;
51 }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`));
52});
3153
3254/** Resolves when the project event reaches the live client store. */
3355export function waitForProject(
......@@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
7698 );
7799}
78100
101/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */
102export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
103 const titles = useAtomValue(
104 hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM,
105 );
106 return useMemo(
107 () =>
108 titles.size === 0
109 ? markdown
110 : relabelThreadLinks(markdown, (threadId) => titles.get(threadId)),
111 [markdown, titles],
112 );
113}
114
79115export function useEnvironmentServerConfig(
80116 environmentId: EnvironmentId | null,
81117): ServerConfig | null {
apps/server/scripts/migrate-dev-db.test.ts+59
......@@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
142142 }),
143143 );
144144
145 it.effect("never copies a settled thread's rows, and new events append after the source's", () =>
146 Effect.gen(function* () {
147 const fs = yield* FileSystem.FileSystem;
148 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" });
149 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" });
150 const source = yield* createFixtureSource(sourceDir);
151 const [sourceSequence] = yield* withDatabase(
152 source,
153 Effect.gen(function* () {
154 const sql = yield* SqlClient.SqlClient;
155 return yield* sql<{ seq: number }>`
156 SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
157 }),
158 );
159
160 // Keep every family, so only the copy can leave the settled one out.
161 const result = yield* runMigrateDevDb(
162 { baseDir: destDir, source, projects: 5, threadsPerProject: 100 },
163 { sharedHome: sourceDir },
164 );
165
166 const copied = yield* withDatabase(
167 result.databasePath,
168 Effect.gen(function* () {
169 const sql = yield* SqlClient.SqlClient;
170 const [settledRows] = yield* sql<{ count: number }>`
171 SELECT
172 (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread')
173 + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread')
174 AS count`;
175 const [sequence] = yield* sql<{ seq: number }>`
176 SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
177 return { settledRows: settledRows?.count, sequence: sequence?.seq };
178 }),
179 );
180 assert.equal(copied.settledRows, 0);
181 assert.equal(copied.sequence, sourceSequence?.seq);
182 }),
183 );
184
185 it.effect("upgrades a source from before the V2 thread tables", () =>
186 Effect.gen(function* () {
187 const fs = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
189 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" });
190 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" });
191 const stateDir = path.join(sourceDir, "userdata");
192 const source = path.join(stateDir, "statev2.sqlite");
193 yield* fs.makeDirectory(stateDir, { recursive: true });
194 yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 }));
195
196 const result = yield* runMigrateDevDb(
197 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
198 { sharedHome: sourceDir },
199 );
200 assert.include(result.executedMigrations, "55_OrchestrationV2");
201 }),
202 );
203
145204 it.effect("fails loudly on a migration slot collision", () =>
146205 Effect.gen(function* () {
147206 const fs = yield* FileSystem.FileSystem;
apps/server/scripts/migrate-dev-db.ts+98-28
......@@ -6,9 +6,12 @@
66 *
77 * `vp run migrate-dev-db` from a worktree:
88 * 1. Nukes `<worktree>/.t3/userdata/statev2.sqlite`.
9 * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only
10 * VACUUM INTO) and prunes it to the most recently updated projects and,
11 * per project, the most recent threads that have fully stopped, with
9 * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`,
10 * attached read-only): the schema, and every row except those of
11 * deleted, archived, and settled threads, which are nearly all of a
12 * long-lived database. It then prunes that to the most recently updated
13 * projects and, per project, the most recent threads that have fully
14 * stopped, with
1215 * their forks and subagents. Working, settled, and archived threads, and
1316 * threads with pending recovery, are skipped, and scheduled tasks and
1417 * queued effects are dropped, so the dev server never adopts live work.
......@@ -28,6 +31,7 @@
2831import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2932import * as NodeServices from "@effect/platform-node/NodeServices";
3033import * as NodeOS from "node:os";
34import * as NodeURL from "node:url";
3135import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
3236import * as Console from "effect/Console";
3337import * as Effect from "effect/Effect";
......@@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray<ProjectionStore.ProjectionRecoveryKind> = [
242246 "delegated-completions",
243247];
244248
249/** Tables the prune empties. The copy skips their rows. */
250const CLEARED_TABLES: ReadonlyArray<string> = [
251 // Pending work the dev server would otherwise pick up and run.
252 "scheduled_tasks",
253 "orchestration_v2_effect_outbox",
254 "orchestration_v2_thread_launch_workflows",
255 "orchestration_command_receipts",
256 "provider_session_runtime",
257 "auth_sessions",
258 "auth_pairing_links",
259];
260
261/** Shared between threads; the prune keeps the ones a kept thread is bound to. */
262const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions";
263
264/** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */
265const rootIsVisible = (sql: SqlClient.SqlClient) => sql`
266 root.deleted_at IS NULL
267 AND json_extract(root.payload_json, '$.deletedAt') IS NULL
268 AND json_extract(root.payload_json, '$.archivedAt') IS NULL
269 AND json_extract(root.payload_json, '$.settledAt') IS NULL
270 AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`;
271
272/**
273 * Copies the source into the empty snapshot, without the rows of thread families whose root is
274 * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's
275 * events, so copying them is what used to make this take a full-size copy of the real db.
276 * A source from before the V2 thread tables has no visibility to filter on, so it is copied
277 * whole. One transaction reads the source, so the copy is consistent while the real server writes.
278 */
279const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) {
280 const sql = yield* SqlClient.SqlClient;
281 yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`;
282 const schema = yield* sql<{ type: string; name: string; sql: string }>`
283 SELECT type, name, sql FROM src.sqlite_master
284 WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`;
285 const threadColumns = yield* sql<{ name: string; notnull: number }>`
286 SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c
287 WHERE m.type = 'table' AND c.name = 'thread_id'`;
288 const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0]));
289 const tables = schema.filter((entry) => entry.type === "table");
290 const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads");
291
292 yield* sql.withTransaction(
293 Effect.gen(function* () {
294 for (const table of tables) yield* sql.unsafe(table.sql).unprepared;
295 if (filtered) {
296 yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`;
297 yield* sql`INSERT OR IGNORE INTO copied_threads
298 SELECT family.thread_id FROM src.orchestration_v2_projection_threads family
299 JOIN src.orchestration_v2_projection_threads root ON root.thread_id =
300 COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id)
301 WHERE ${rootIsVisible(sql)}`;
302 }
303 for (const { name } of tables) {
304 if (CLEARED_TABLES.includes(name)) continue;
305 const nullable = threadIdNullable.get(name);
306 const filter = !filtered
307 ? ""
308 : name === "orchestration_events"
309 ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id)
310 // index instead of scanning every event.
311 `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads))
312 OR aggregate_kind < 'thread' OR aggregate_kind > 'thread'`
313 : nullable === undefined || name === PROVIDER_SESSIONS_TABLE
314 ? ""
315 : `WHERE thread_id IN (SELECT thread_id FROM copied_threads)${nullable ? " OR thread_id IS NULL" : ""}`;
316 yield* sql.unsafe(`INSERT INTO main."${name}" SELECT * FROM src."${name}" ${filter}`)
317 .unprepared;
318 }
319 // The source's AUTOINCREMENT high-water marks, so new events append after them.
320 yield* sql`DELETE FROM main.sqlite_sequence`;
321 yield* sql`INSERT INTO main.sqlite_sequence SELECT name, seq FROM src.sqlite_sequence`;
322 for (const entry of schema) {
323 if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared;
324 }
325 }),
326 );
327 yield* sql`DETACH DATABASE src`;
328});
329
245330const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) {
246331 const sql = yield* SqlClient.SqlClient;
247332
......@@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
278363 SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at
279364 FROM thread_families f
280365 JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id
281 WHERE root.deleted_at IS NULL
282 AND json_extract(root.payload_json, '$.deletedAt') IS NULL
283 AND json_extract(root.payload_json, '$.archivedAt') IS NULL
284 AND json_extract(root.payload_json, '$.settledAt') IS NULL
285 AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'
366 WHERE ${rootIsVisible(sql)}
286367 GROUP BY f.root_id, root.project_id
287368 HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`;
288369
......@@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
323404 const threadTables = yield* sql<{ name: string }>`
324405 SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c
325406 WHERE m.type = 'table' AND c.name = 'thread_id'
326 AND m.name <> 'orchestration_v2_projection_provider_sessions'`;
407 AND m.name <> ${PROVIDER_SESSIONS_TABLE}`;
327408
328409 yield* sql.withTransaction(
329410 Effect.gen(function* () {
330411 yield* sql`DELETE FROM projection_projects
331412 WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`;
332 yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions
413 yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)}
333414 WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads)
334415 AND provider_session_id NOT IN (
335416 SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings
......@@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
348429 AND stream_id NOT IN (SELECT thread_id FROM kept_threads))
349430 OR (aggregate_kind = 'project'
350431 AND stream_id NOT IN (SELECT project_id FROM kept_projects))`;
351 // Pending work the dev server would otherwise pick up and run.
352 yield* sql`DELETE FROM scheduled_tasks`;
353 yield* sql`DELETE FROM orchestration_v2_effect_outbox`;
354 yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`;
355 yield* sql`DELETE FROM orchestration_command_receipts`;
356 yield* sql`DELETE FROM provider_session_runtime`;
357 yield* sql`DELETE FROM auth_sessions`;
358 yield* sql`DELETE FROM auth_pairing_links`;
432 for (const table of CLEARED_TABLES) {
433 yield* sql`DELETE FROM ${sql(table)}`;
434 }
359435 }),
360436 );
361437
......@@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
457533 );
458534
459535 yield* removeDatabaseFiles(snapshotPath);
460 // The snapshot is a full-size copy of the source; make sure it is removed
461 // even when a phase fails partway through.
536 // Make sure the snapshot is removed even when a phase fails partway through.
462537 const { executedMigrations, pruned } = yield* Effect.gen(function* () {
463 yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`);
464 yield* Effect.gen(function* () {
465 const sql = yield* SqlClient.SqlClient;
466 yield* sql`VACUUM INTO ${snapshotPath}`;
467 }).pipe(
468 Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })),
538 yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`);
539 yield* copySourceSlice(sourcePath).pipe(
540 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
469541 wrapPhase("snapshot", sourcePath),
470542 );
471543
472544 // Migrate before pruning: a source older than this checkout would
473545 // otherwise crash the prune queries on columns that don't exist yet.
474 // Running against the full snapshot also exercises new migrations on the
475 // same data volume the real database would face.
476546 yield* Console.log("Running migrations on the snapshot...");
477547 const executed = yield* Effect.gen(function* () {
478548 const sql = yield* SqlClient.SqlClient;
apps/server/src/mcp/OrchestratorMcpService.ts+6-29
......@@ -1,5 +1,4 @@
11import {
2 type EnvironmentId,
32 CommandId,
43 type RunId,
54 isProviderAvailable,
......@@ -59,7 +58,6 @@ import {
5958 ThreadId,
6059} from "@t3tools/contracts";
6160import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
62import { formatThreadLink } from "@t3tools/shared/threadLinks";
6361import * as Clock from "effect/Clock";
6462import * as Context from "effect/Context";
6563import * as Crypto from "effect/Crypto";
......@@ -633,23 +631,12 @@ function threadSnooze(
633631 };
634632}
635633
636/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */
637interface ThreadViewContext {
638 readonly environmentId: EnvironmentId;
639 readonly nowMs: number;
640}
641
642634function listItemFromShell(
643635 shell: OrchestrationV2ThreadShell,
644 context: ThreadViewContext,
636 nowMs: number,
645637): OrchestratorMcpThreadListItem {
646638 return {
647639 threadId: shell.id,
648 link: formatThreadLink({
649 environmentId: context.environmentId,
650 threadId: shell.id,
651 title: shell.title,
652 }),
653640 title: shell.title,
654641 createdBy: shell.createdBy,
655642 creationSource: shell.creationSource,
......@@ -661,7 +648,7 @@ function listItemFromShell(
661648 interactionMode: shell.interactionMode,
662649 linkedPullRequest: shell.linkedPullRequest ?? null,
663650 ...threadSettlement(shell),
664 ...threadSnooze(shell, context.nowMs),
651 ...threadSnooze(shell, nowMs),
665652 parentThreadId: shell.lineage.parentThreadId,
666653 relationshipToParent: shell.lineage.relationshipToParent,
667654 itemCount: shell.visibleItemCount,
......@@ -674,17 +661,12 @@ function threadDetail(
674661 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
675662 itemCount: number,
676663 shell: OrchestrationV2ThreadShell,
677 context: ThreadViewContext,
664 nowMs: number,
678665): OrchestratorMcpThreadDetail {
679666 const latest = ThreadManagementService.latestRun(projection);
680667 const active = ThreadManagementService.latestActiveRun(projection);
681668 return {
682669 threadId: projection.thread.id,
683 link: formatThreadLink({
684 environmentId: context.environmentId,
685 threadId: projection.thread.id,
686 title: projection.thread.title,
687 }),
688670 projectId: projection.thread.projectId,
689671 title: projection.thread.title,
690672 createdBy: projection.thread.createdBy,
......@@ -717,7 +699,7 @@ function threadDetail(
717699 archived: projection.thread.archivedAt !== null,
718700 ...threadSettlement(projection.thread),
719701 // From the shell, like the list, so read and list agree on snooze state.
720 ...threadSnooze(shell, context.nowMs),
702 ...threadSnooze(shell, nowMs),
721703 createdAt: DateTime.formatIso(projection.thread.createdAt),
722704 updatedAt: DateTime.formatIso(projection.thread.updatedAt),
723705 };
......@@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () {
22932275 return {
22942276 projectId,
22952277 currentThreadId: parent?.thread.id ?? null,
2296 threads: page.map((shell) =>
2297 listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }),
2298 ),
2278 threads: page.map((shell) => listItemFromShell(shell, nowMs)),
22992279 nextCursor,
23002280 total: filtered.length,
23012281 } satisfies OrchestratorMcpThreadListResult;
......@@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () {
23752355 }
23762356 }
23772357 return {
2378 thread: threadDetail(target, timeline.totalItems, shell, {
2379 environmentId: scope.environmentId,
2380 nowMs: yield* Clock.currentTimeMillis,
2381 }),
2358 thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis),
23822359 recentRuns: target.runs
23832360 .toSorted((left, right) => right.ordinal - left.ordinal)
23842361 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts-3
......@@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => {
22332233 });
22342234
22352235 expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null });
2236 expect(metadataRead.thread.link).toBe(
2237 `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`,
2238 );
22392236 yield* orchestrator.dispatch({
22402237 type: "thread.snooze",
22412238 commandId: CommandId.make("command:mcp-empty:snooze"),
apps/server/src/mcp/toolkits/orchestrator/tools.ts+2-2
......@@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {
178178
179179const ThreadListTool = Tool.make("t3_thread_list", {
180180 description:
181 "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. Each thread has a link: paste it when you mention the thread so the user can open it.",
181 "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. To link a thread for the user, write `[title](t3-thread://v1/<threadId>)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.",
182182 parameters: OrchestratorMcpThreadListInput,
183183 success: OrchestratorMcpThreadListResult,
184184 failure: OrchestratorMcpFailure,
......@@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {
192192
193193const ThreadReadTool = Tool.make("t3_thread_read", {
194194 description:
195 "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread has a link and snooze state: paste the link when you mention the thread so the user can open it.",
195 "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state. To link a thread for the user, write `[title](t3-thread://v1/<threadId>)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.",
196196 parameters: OrchestratorMcpThreadReadInput,
197197 success: OrchestratorMcpThreadReadResult,
198198 failure: OrchestratorMcpFailure,
apps/server/src/mcp/toolkits/project/handlers.test.ts+3-13
......@@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
6969 return Effect.succeed({
7070 threadId: input.threadId,
7171 projection: {
72 thread: { id: input.threadId, projectId, modelSelection, title: input.title },
72 thread: { id: input.threadId, projectId, modelSelection },
7373 runs: [],
7474 },
7575 resumed: false,
......@@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
9494 const result = yield* toolkit
9595 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })
9696 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
97 expect(result.at(-1)?.result).toMatchObject({
98 projectId,
99 modelSelection,
100 link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//),
101 });
97 expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });
10298 expect(launchedSender).toBe(sourceThreadId);
10399 }),
104100);
......@@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () =>
144140 return Effect.succeed({
145141 threadId: input.threadId,
146142 projection: {
147 thread: {
148 id: input.threadId,
149 projectId: input.projectId,
150 modelSelection,
151 title: input.title,
152 },
143 thread: { id: input.threadId, projectId: input.projectId, modelSelection },
153144 runs: [],
154145 },
155146 resumed: false,
......@@ -338,7 +329,6 @@ const clientLaunchHarness = (input: {
338329 id: launch.threadId,
339330 projectId: launch.projectId,
340331 modelSelection: launch.modelSelection,
341 title: launch.title,
342332 },
343333 runs: [],
344334 },
apps/server/src/mcp/toolkits/project/handlers.ts+1-7
......@@ -1,5 +1,4 @@
11import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2import { formatThreadLink } from "@t3tools/shared/threadLinks";
32import * as Effect from "effect/Effect";
43import * as FileSystem from "effect/FileSystem";
54import * as Option from "effect/Option";
......@@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
5857 (input, { runtimeMode, interactionMode }) =>
5958 Effect.gen(function* () {
6059 const context = yield* readCaller();
61 const { caller, scope } = context;
60 const { caller } = context;
6261 const commandId = yield* newCommandId();
6362 const threadId = ThreadId.make(commandId);
6463 const messageId = MessageId.make(commandId);
......@@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
147146 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
148147 return {
149148 threadId: thread.id,
150 link: formatThreadLink({
151 environmentId: scope.environmentId,
152 threadId: thread.id,
153 title: thread.title,
154 }),
155149 projectId: thread.projectId,
156150 modelSelection: thread.modelSelection,
157151 runId: run?.id ?? null,
apps/server/src/mcp/toolkits/project/tools.ts+1-3
......@@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
103103const ThreadLaunchTool = Tool.make("t3_thread_launch", {
104104 ...shared,
105105 description:
106 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
106 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/<threadId>)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
107107 parameters: Schema.Struct({
108108 projectId: Schema.optional(ProjectId),
109109 scratch: Schema.optional(
......@@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
132132 }),
133133 success: Schema.Struct({
134134 threadId: ThreadId,
135 /** Paste this whenever you mention the thread, so the user can click to open it. */
136 link: Schema.String,
137135 projectId: ProjectId,
138136 modelSelection: ModelSelection,
139137 runId: Schema.NullOr(RunId),
apps/server/src/provider/T3OrchestrationInstructions.ts+1
......@@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept
1111- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.
1212- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing.
1313- When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one.
14- To mention another thread to the user, link it as \`[title](t3-thread://v1/<threadId>)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.
1415
1516### Choose the workspace before starting a new thread
1617
apps/web/src/components/ChatMarkdown.tsx+8-7
......@@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
31673167 } = use(ChatMarkdownRendererContext);
31683168 const citation = href ? parseAssistantCitationHref(href) : null;
31693169 if (citation) return <AssistantCitationChip citation={citation} />;
3170 // A thread link opens the thread here, never a browser.
3171 const threadLink = href ? parseThreadLinkHref(href) : null;
3172 if (threadLink) {
3173 return (
3174 <MarkdownThreadLink {...threadLink}>
3175 <MarkdownLinkContext value>{children}</MarkdownLinkContext>
3176 </MarkdownThreadLink>
3170 // A thread link names a thread in this message's environment and opens it in the app.
3171 const linkedThreadId = href ? parseThreadLinkHref(href) : null;
3172 if (linkedThreadId) {
3173 const label = hastPlainTextDeep(node) || linkedThreadId;
3174 return environmentId ? (
3175 <MarkdownThreadLink environmentId={environmentId} threadId={linkedThreadId} label={label} />
3176 ) : (
3177 <span>{label}</span>
31773178 );
31783179 }
31793180 const contextReference = href ? parseComposerContextHref(href) : null;
apps/web/src/components/chat/MarkdownThreadLink.tsx+19-9
......@@ -1,31 +1,41 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
22import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks";
34import { Link } from "@tanstack/react-router";
45import { MessageSquareTextIcon } from "lucide-react";
5import type { ReactNode } from "react";
66
77import { useProject, useThreadShell } from "../../state/entities";
88import { ProjectFavicon } from "../ProjectFavicon";
99
1010/**
11 * A `t3-thread://` link in chat. It leads with the thread's project icon, like
12 * a web link leads with its favicon, so it reads as a thread link before you
13 * hover it. Opens the thread in the app.
11 * A `t3-thread://` link in chat. It shows the thread's current title, so a rename
12 * reaches every message that links to it, and leads with the thread's project icon
13 * the way a web link leads with its favicon. `label` is what the message wrote,
14 * shown only when this client cannot see the thread. Opens the thread in the app.
1415 */
1516export function MarkdownThreadLink(props: {
1617 readonly environmentId: EnvironmentId;
1718 readonly threadId: ThreadId;
18 readonly children: ReactNode;
19 readonly label: string;
1920}) {
20 const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId));
21 const decodedId = percentDecodedThreadLinkId(props.threadId);
22 const written = useThreadShell(scopeThreadRef(props.environmentId, props.threadId));
23 const decoded = useThreadShell(
24 written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null,
25 );
26 const thread = written ?? decoded;
27 const threadId = thread?.id ?? props.threadId;
2128 const project = useProject(
2229 thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId),
2330 );
31 const title = thread?.title.trim() || props.label;
2432 return (
2533 <Link
2634 to="/$environmentId/$threadId"
27 params={{ environmentId: props.environmentId, threadId: props.threadId }}
28 title={project?.title}
35 params={{ environmentId: props.environmentId, threadId }}
36 // Like an attached thread chip: archived threads are not in the index but still open.
37 title={thread === null ? "Thread no longer available" : project?.title}
38 data-markdown-copy={formatThreadLink(threadId, title)}
2939 >
3040 <span
3141 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
......@@ -37,7 +47,7 @@ export function MarkdownThreadLink(props: {
3747 <ProjectFavicon project={project} className="size-full" />
3848 )}
3949 </span>
40 {props.children}
50 {title}
4151 </Link>
4252 );
4353}
docs/orchestration-v2/orchestrator-mcp-server.md+6-3
......@@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`.
352352Actor and ingress are separate so agent-authored user-role messages remain
353353distinguishable from human-authored messages.
354354
355List, read, and launch results include `link`, a Markdown link of the form
356`[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the
357thread. List and read results also report `snoozed` and `snoozedUntil`, and
355Agents mention another thread as `[title](t3-thread://v1/<threadId>)`. The
356link carries only the id, which resolves in the environment of the message that
357holds it. Clients show the thread's current title rather than the label, so a
358rename never leaves a stale link.
359
360List and read results report `snoozed` and `snoozedUntil`, and
358361`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the
359362client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread
360363wakes early when it has a pending request, fails, or completes after the snooze.
packages/contracts/src/orchestratorMcp.ts-4
......@@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp
304304
305305export const OrchestratorMcpThreadListItem = Schema.Struct({
306306 threadId: ThreadId,
307 /** Paste this whenever you mention the thread, so the user can click to open it. */
308 link: Schema.String,
309307 title: Schema.String,
310308 createdBy: OrchestrationV2Actor,
311309 creationSource: OrchestrationV2CreationSource,
......@@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp
353351
354352export const OrchestratorMcpThreadDetail = Schema.Struct({
355353 threadId: ThreadId,
356 /** Paste this whenever you mention the thread, so the user can click to open it. */
357 link: Schema.String,
358354 projectId: ProjectId,
359355 title: Schema.String,
360356 createdBy: OrchestrationV2Actor,
packages/shared/src/threadLinks.test.ts+66-19
......@@ -1,28 +1,75 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts";
3import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts";
44
55describe("thread links", () => {
6 it("round-trips ids that contain URL characters", () => {
7 const link = formatThreadLink({
8 environmentId: "studio mac",
9 threadId: "mcp:(1)/2",
10 title: "Fix [the] build\nnow",
11 });
12 const href = /\]\((.+)\)$/.exec(link)![1]!;
13 expect(link.startsWith("[Fix the build now](")).toBe(true);
14 // A raw parenthesis would end the Markdown link early.
15 expect(href).not.toMatch(/[()]/);
16 expect(parseThreadLinkHref(href)).toEqual({
17 environmentId: "studio mac",
18 threadId: "mcp:(1)/2",
19 });
6 it("takes the thread id verbatim, percent escapes included", () => {
7 expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234");
8 expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe(
9 "thread:delegated-task:mcp%3A1",
10 );
2011 });
2112
22 it("rejects other links and malformed ones", () => {
13 it("rejects other links and an empty id", () => {
2314 expect(parseThreadLinkHref("https://t3.codes")).toBeNull();
24 expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull();
25 expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull();
26 expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull();
15 expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull();
16 expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull();
17 });
18
19 it("resolves a percent-encoded id when the id as written names no thread", () => {
20 const titles = new Map([
21 ["thread:project:1", "Decoded"],
22 ["provider%3A1", "Literal escape"],
23 ]);
24 expect(
25 relabelThreadLinks(
26 "[a](t3-thread://v1/thread%3Aproject%3A1) [b](t3-thread://v1/provider%3A1)",
27 (threadId) => titles.get(threadId),
28 ),
29 ).toBe(
30 "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)",
31 );
32 // A thread with an empty title still exists, so its link is not redirected.
33 const untitled = new Map([
34 ["a%3A1", ""],
35 ["a:1", "Other"],
36 ]);
37 expect(
38 relabelThreadLinks("[Kept](t3-thread://v1/a%3A1)", (threadId) => untitled.get(threadId)),
39 ).toBe("[Kept](t3-thread://v1/a%3A1)");
40 });
41
42 it("leaves links inside code spans and fences as written", () => {
43 const markdown = [
44 "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.",
45 "```md",
46 "[old](t3-thread://v1/t1)",
47 "```",
48 "After [old](t3-thread://v1/t1)",
49 ].join("\n");
50 expect(relabelThreadLinks(markdown, () => "New")).toBe(
51 [
52 "Live [New](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.",
53 "```md",
54 "[old](t3-thread://v1/t1)",
55 "```",
56 "After [New](t3-thread://v1/t1)",
57 ].join("\n"),
58 );
59 });
60
61 it("formats a label that would otherwise break the Markdown link", () => {
62 expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)");
63 expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)");
64 });
65
66 it("relabels links with the current title and leaves unknown threads alone", () => {
67 const titles = new Map([["renamed", "Fix [the] build\nnow"]]);
68 expect(
69 relabelThreadLinks(
70 "See [Old name](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).",
71 (threadId) => titles.get(threadId),
72 ),
73 ).toBe("See [Fix the build now](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).");
2774 });
2875});
packages/shared/src/threadLinks.ts+56-41
......@@ -1,58 +1,73 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import { ThreadId } from "@t3tools/contracts";
22import * as Option from "effect/Option";
33import * as Schema from "effect/Schema";
44
55/**
6 * In-app links to threads. Agents put them in Markdown as
7 * `[title](t3-thread://v1/<environmentId>/<threadId>)`, and clients open the
8 * thread instead of a browser. Thread tools return a ready `link` for each
9 * thread, so agents never build one by hand.
6 * Agents mention another thread in Markdown as `[title](t3-thread://v1/<threadId>)`. The id is
7 * the reference and resolves in the environment of the message that holds it. Titles change, so
8 * clients show the thread's current title; the label only stands in for a thread they cannot see.
109 */
1110export const THREAD_LINK_PROTOCOL = "t3-thread";
1211const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
13const LINK_LABEL_MAX_CHARS = 120;
12// Code comes first in the alternation so a link written inside a code span or fence is skipped.
13const THREAD_LINK_OUTSIDE_CODE =
14 /(?<fence>(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?<href>t3-thread:\/\/v1\/[^\s)]+)\)/g;
1415
15const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId);
1616const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
1717
18// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early.
19function encodeIdSegment(id: string): string {
20 return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29");
21}
22
23function formatThreadLinkHref(environmentId: string, threadId: string): string {
24 return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`;
25}
26
27/** A Markdown link to the thread, labeled with its title. */
28export function formatThreadLink(thread: {
29 readonly environmentId: string;
30 readonly threadId: string;
31 readonly title: string;
32}): string {
33 const label =
34 thread.title
35 .replace(/[[\]\\\r\n]/g, " ")
36 .replace(/\s+/g, " ")
37 .trim()
38 .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread";
39 return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`;
18/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */
19export function parseThreadLinkHref(href: string): ThreadId | null {
20 if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null;
21 return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length)));
4022}
4123
42export function parseThreadLinkHref(
43 href: string,
44): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null {
45 if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null;
46 const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/");
47 if (parts.length !== 2) return null;
24/**
25 * Agents often percent-encode the id anyway. When the id as written names no thread, clients try
26 * this decoded form. Null when decoding changes nothing or fails.
27 */
28export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null {
4829 try {
49 const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!));
50 const threadId = decodeThreadId(decodeURIComponent(parts[1]!));
51 return Option.isSome(environmentId) && Option.isSome(threadId)
52 ? { environmentId: environmentId.value, threadId: threadId.value }
53 : null;
30 const decoded = decodeURIComponent(threadId);
31 return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded));
5432 } catch {
55 // Malformed percent encoding.
5633 return null;
5734 }
5835}
36
37/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */
38export function formatThreadLink(threadId: string, label: string): string {
39 const cleaned = label
40 .replace(/[[\]\\\r\n]/g, " ")
41 .replace(/\s+/g, " ")
42 .trim();
43 return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`;
44}
45
46export function hasThreadLinks(markdown: string): boolean {
47 return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`);
48}
49
50/**
51 * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from.
52 * A link it returns nothing for keeps its label.
53 */
54export function relabelThreadLinks(
55 markdown: string,
56 title: (threadId: ThreadId) => string | undefined,
57): string {
58 if (!hasThreadLinks(markdown)) return markdown;
59 return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => {
60 const href = (args.at(-1) as { href?: string }).href;
61 if (href === undefined) return source;
62 const written = parseThreadLinkHref(href);
63 if (written === null) return source;
64 // The decoded id only stands in when the id as written names no thread.
65 const decoded = percentDecodedThreadLinkId(written);
66 const threadId =
67 title(written) === undefined && decoded !== null && title(decoded) !== undefined
68 ? decoded
69 : written;
70 const label = title(threadId)?.trim();
71 return label ? formatThreadLink(threadId, label) : source;
72 });
73}