authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 04:33:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 04:33:44-07:00
log611132c171f3a821bd2e32f22261135cef6330ac
tree1610c8c6e8ac33556291a95f04472d972b948ee6
parentcd41c4ada0c70cc2eec95ecd7266f3dab010c58c
signature Signed by PGP key B5690EEEBB952194

feat(server,web,mobile): agents see snooze state and link to threads (#16782)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

18 files changed, 381 insertions(+), 19 deletions(-)

apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+6-1
......@@ -5,6 +5,7 @@ import {
55 normalizeMarkdownLinkDestination,
66 parseMarkdownFileLink,
77} from "@t3tools/client-runtime/markdown-links";
8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
89import { videoMimeType } from "@t3tools/shared/video";
910
1011import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
......@@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
293294
294295 return {
295296 kind: "link",
296 href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
297 // A thread link keeps its href so a press reaches the feed, which opens the thread.
298 href:
299 /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null
300 ? normalized
301 : null,
297302 };
298303}
299304
apps/mobile/src/features/threads/ThreadFeed.tsx+18-5
......@@ -25,6 +25,7 @@ import {
2525} from "@t3tools/contracts";
2626import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
2727import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
28import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
2829import {
2930 parseComposerContextHref,
3031 collectComposerContextReferences,
......@@ -1242,16 +1243,20 @@ function useMarkdownStyles(
12421243 );
12431244 }
12441245 const linkHref = presentation.href;
1246 // A thread link opens the thread in the app, through the feed's link handler.
1247 const isThreadLink = parseThreadLinkHref(href) !== null;
12451248 return (
12461249 <MarkdownLinkLabelContext.Provider value="other">
12471250 <NativeText
12481251 className="underline"
12491252 onPress={
1250 linkHref
1251 ? () => {
1252 void tryOpenExternalUrl(linkHref, "markdown-link");
1253 }
1254 : undefined
1253 isThreadLink
1254 ? () => onLinkPress(href)
1255 : linkHref
1256 ? () => {
1257 void tryOpenExternalUrl(linkHref, "markdown-link");
1258 }
1259 : undefined
12551260 }
12561261 style={{ color: markdownLinkColor }}
12571262 >
......@@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22712276 const userBubbleColor = theme["--color-user-bubble"];
22722277 const onMarkdownLinkPress = useCallback(
22732278 (href: string) => {
2279 const threadLink = parseThreadLinkHref(href);
2280 if (threadLink) {
2281 navigation.navigate("Thread", {
2282 environmentId: String(threadLink.environmentId),
2283 threadId: String(threadLink.threadId),
2284 });
2285 return;
2286 }
22742287 const presentation = resolveMarkdownLinkPresentation(href);
22752288 if (presentation.kind === "file") {
22762289 const relativePath = resolveWorkspaceRelativeFilePath(
apps/mobile/src/lib/nativeMarkdownText.test.ts+15
......@@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => {
8484 ]);
8585 });
8686
87 it("keeps a thread link's href so a press can open the thread", () => {
88 expect(
89 nativeMarkdownTextRuns({
90 type: "paragraph",
91 children: [
92 {
93 type: "link",
94 href: "t3-thread://v1/env/thread-1",
95 children: [{ type: "text", content: "Fix the build" }],
96 },
97 ],
98 }),
99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]);
100 });
101
87102 it("preserves the destination of a link with a code-formatted label", () => {
88103 expect(
89104 nativeMarkdownTextRuns({
apps/server/src/mcp/OrchestratorMcpService.ts+54-5
......@@ -1,4 +1,5 @@
11import {
2 type EnvironmentId,
23 CommandId,
34 type RunId,
45 isProviderAvailable,
......@@ -58,6 +59,8 @@ import {
5859 ThreadId,
5960} from "@t3tools/contracts";
6061import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
62import { formatThreadLink } from "@t3tools/shared/threadLinks";
63import * as Clock from "effect/Clock";
6164import * as Context from "effect/Context";
6265import * as Crypto from "effect/Crypto";
6366import * as DateTime from "effect/DateTime";
......@@ -80,6 +83,7 @@ import {
8083 type DispatchModeRefusal,
8184} from "../orchestration-v2/DispatchModeLimit.ts";
8285import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
86import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";
8387import * as ProjectService from "../project/ProjectService.ts";
8488import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
8589import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
......@@ -617,9 +621,35 @@ function threadSettlement(
617621 };
618622}
619623
620function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpThreadListItem {
624function threadSnooze(
625 thread: Parameters<typeof isSnoozed>[0],
626 nowMs: number,
627): Pick<OrchestratorMcpThreadListItem, "snoozed" | "snoozedUntil"> {
628 const snoozed = isSnoozed(thread, nowMs);
629 return {
630 snoozed,
631 snoozedUntil:
632 snoozed && thread.snoozedUntil != null ? DateTime.formatIso(thread.snoozedUntil) : null,
633 };
634}
635
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
642function listItemFromShell(
643 shell: OrchestrationV2ThreadShell,
644 context: ThreadViewContext,
645): OrchestratorMcpThreadListItem {
621646 return {
622647 threadId: shell.id,
648 link: formatThreadLink({
649 environmentId: context.environmentId,
650 threadId: shell.id,
651 title: shell.title,
652 }),
623653 title: shell.title,
624654 createdBy: shell.createdBy,
625655 creationSource: shell.creationSource,
......@@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh
631661 interactionMode: shell.interactionMode,
632662 linkedPullRequest: shell.linkedPullRequest ?? null,
633663 ...threadSettlement(shell),
664 ...threadSnooze(shell, context.nowMs),
634665 parentThreadId: shell.lineage.parentThreadId,
635666 relationshipToParent: shell.lineage.relationshipToParent,
636667 itemCount: shell.visibleItemCount,
......@@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh
642673function threadDetail(
643674 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
644675 itemCount: number,
676 shell: OrchestrationV2ThreadShell,
677 context: ThreadViewContext,
645678): OrchestratorMcpThreadDetail {
646679 const latest = ThreadManagementService.latestRun(projection);
647680 const active = ThreadManagementService.latestActiveRun(projection);
648681 return {
649682 threadId: projection.thread.id,
683 link: formatThreadLink({
684 environmentId: context.environmentId,
685 threadId: projection.thread.id,
686 title: projection.thread.title,
687 }),
650688 projectId: projection.thread.projectId,
651689 title: projection.thread.title,
652690 createdBy: projection.thread.createdBy,
......@@ -678,6 +716,8 @@ function threadDetail(
678716 ).length,
679717 archived: projection.thread.archivedAt !== null,
680718 ...threadSettlement(projection.thread),
719 // From the shell, like the list, so read and list agree on snooze state.
720 ...threadSnooze(shell, context.nowMs),
681721 createdAt: DateTime.formatIso(projection.thread.createdAt),
682722 updatedAt: DateTime.formatIso(projection.thread.updatedAt),
683723 };
......@@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () {
10241064 "contextTransfers",
10251065 ])
10261066 .pipe(Effect.mapError(threadManagementFailure));
1027 return { parent, target } as const;
1067 return { parent, target, shell } as const;
10281068 });
10291069
10301070 const loadProviders = providerRegistry.getProviders;
......@@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () {
22262266 failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`),
22272267 ),
22282268 );
2269 const nowMs = yield* Clock.currentTimeMillis;
22292270 const statuses = input.statuses === undefined ? null : new Set(input.statuses);
22302271 const titleContains = input.titleContains?.toLocaleLowerCase();
22312272 const filtered = projectThreads
......@@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () {
22372278 (thread) =>
22382279 input.settled === undefined || threadSettlement(thread).settled === input.settled,
22392280 )
2281 .filter(
2282 (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed,
2283 )
22402284 .filter(
22412285 (thread) =>
22422286 titleContains === undefined ||
......@@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () {
22492293 return {
22502294 projectId,
22512295 currentThreadId: parent?.thread.id ?? null,
2252 threads: page.map(listItemFromShell),
2296 threads: page.map((shell) =>
2297 listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }),
2298 ),
22532299 nextCursor,
22542300 total: filtered.length,
22552301 } satisfies OrchestratorMcpThreadListResult;
22562302 }),
22572303 readThread: (scope, input) =>
22582304 Effect.gen(function* () {
2259 const { parent, target } = yield* loadReadableThread(scope, input.threadId);
2305 const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId);
22602306 const view = input.view ?? "messages";
22612307 const afterPosition = input.afterPosition ?? -1;
22622308 const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT;
......@@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () {
23292375 }
23302376 }
23312377 return {
2332 thread: threadDetail(target, timeline.totalItems),
2378 thread: threadDetail(target, timeline.totalItems, shell, {
2379 environmentId: scope.environmentId,
2380 nowMs: yield* Clock.currentTimeMillis,
2381 }),
23332382 recentRuns: target.runs
23342383 .toSorted((left, right) => right.ordinal - left.ordinal)
23352384 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+38
......@@ -2232,6 +2232,44 @@ describe("orchestrator MCP toolkit", () => {
22322232 reason: "user",
22332233 });
22342234
2235 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 );
2239 yield* orchestrator.dispatch({
2240 type: "thread.snooze",
2241 commandId: CommandId.make("command:mcp-empty:snooze"),
2242 threadId: emptyThread.threadId,
2243 snoozedUntil: "2099-01-01T00:00:00.000Z",
2244 });
2245 const snoozedListCall = yield* invoke("t3_thread_list", { snoozed: true, limit: 100 });
2246 const snoozedList = yield* decodeThreadListResult(
2247 snoozedListCall.structuredContent,
2248 ).pipe(Effect.orDie);
2249 expect(snoozedList.threads.map((thread) => thread.threadId)).toEqual([
2250 emptyThread.threadId,
2251 ]);
2252 expect(snoozedList.threads[0]).toMatchObject({
2253 snoozed: true,
2254 snoozedUntil: "2099-01-01T00:00:00.000Z",
2255 });
2256 const snoozedReadCall = yield* invoke("t3_thread_read", {
2257 threadId: emptyThread.threadId,
2258 });
2259 const snoozedRead = yield* decodeThreadReadResult(
2260 snoozedReadCall.structuredContent,
2261 ).pipe(Effect.orDie);
2262 expect(snoozedRead.thread).toMatchObject({
2263 snoozed: true,
2264 snoozedUntil: "2099-01-01T00:00:00.000Z",
2265 });
2266 yield* orchestrator.dispatch({
2267 type: "thread.unsnooze",
2268 commandId: CommandId.make("command:mcp-empty:unsnooze"),
2269 threadId: emptyThread.threadId,
2270 reason: "user",
2271 });
2272
22352273 const unlinkedCall = yield* invoke("t3_thread_update", {
22362274 threadId: emptyThread.threadId,
22372275 action: "unlink_pull_request",
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) and paginate with the returned cursor.",
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.",
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.",
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.",
196196 parameters: OrchestratorMcpThreadReadInput,
197197 success: OrchestratorMcpThreadReadResult,
198198 failure: OrchestratorMcpFailure,
apps/server/src/mcp/toolkits/project/handlers.test.ts+13-3
......@@ -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 },
72 thread: { id: input.threadId, projectId, modelSelection, title: input.title },
7373 runs: [],
7474 },
7575 resumed: false,
......@@ -94,7 +94,11 @@ 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({ projectId, modelSelection });
97 expect(result.at(-1)?.result).toMatchObject({
98 projectId,
99 modelSelection,
100 link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//),
101 });
98102 expect(launchedSender).toBe(sourceThreadId);
99103 }),
100104);
......@@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () =>
140144 return Effect.succeed({
141145 threadId: input.threadId,
142146 projection: {
143 thread: { id: input.threadId, projectId: input.projectId, modelSelection },
147 thread: {
148 id: input.threadId,
149 projectId: input.projectId,
150 modelSelection,
151 title: input.title,
152 },
144153 runs: [],
145154 },
146155 resumed: false,
......@@ -329,6 +338,7 @@ const clientLaunchHarness = (input: {
329338 id: launch.threadId,
330339 projectId: launch.projectId,
331340 modelSelection: launch.modelSelection,
341 title: launch.title,
332342 },
333343 runs: [],
334344 },
apps/server/src/mcp/toolkits/project/handlers.ts+7-1
......@@ -1,4 +1,5 @@
11import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2import { formatThreadLink } from "@t3tools/shared/threadLinks";
23import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
45import * as Option from "effect/Option";
......@@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
5758 (input, { runtimeMode, interactionMode }) =>
5859 Effect.gen(function* () {
5960 const context = yield* readCaller();
60 const { caller } = context;
61 const { caller, scope } = context;
6162 const commandId = yield* newCommandId();
6263 const threadId = ThreadId.make(commandId);
6364 const messageId = MessageId.make(commandId);
......@@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
146147 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
147148 return {
148149 threadId: thread.id,
150 link: formatThreadLink({
151 environmentId: scope.environmentId,
152 threadId: thread.id,
153 title: thread.title,
154 }),
149155 projectId: thread.projectId,
150156 modelSelection: thread.modelSelection,
151157 runId: run?.id ?? null,
apps/server/src/mcp/toolkits/project/tools.ts+3-1
......@@ -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. 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. 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.',
107107 parameters: Schema.Struct({
108108 projectId: Schema.optional(ProjectId),
109109 scratch: Schema.optional(
......@@ -132,6 +132,8 @@ 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,
135137 projectId: ProjectId,
136138 modelSelection: ModelSelection,
137139 runId: Schema.NullOr(RunId),
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+22
......@@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
12521252 ),
12531253 );
12541254});
1255
1256describe("isSnoozed", () => {
1257 const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) };
1258 it("wakes for completed work after the snooze, but not an interrupted run", () => {
1259 expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true);
1260 expect(
1261 ThreadSettlementService.isSnoozed(
1262 shell({ ...snoozed, status: "interrupted", latestRunCompletedAt: at(-DAY_MS) }),
1263 NOW_MS,
1264 ),
1265 ).toBe(true);
1266 expect(
1267 ThreadSettlementService.isSnoozed(
1268 shell({ ...snoozed, status: "completed", latestRunCompletedAt: at(-DAY_MS) }),
1269 NOW_MS,
1270 ),
1271 ).toBe(false);
1272 expect(
1273 ThreadSettlementService.isSnoozed(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS),
1274 ).toBe(false);
1275 });
1276});
apps/server/src/orchestration-v2/ThreadSettlementService.ts+33
......@@ -163,6 +163,39 @@ export function isAutoSettlementCandidate(
163163 return wokeOnError || wokeOnCompletion;
164164}
165165
166/**
167 * Whether a thread is parked on its snooze: its wake time is in the future and
168 * it has not raised its hand with a pending request, a fresh failure, or work
169 * that completed after the snooze. Server twin of the client's
170 * `effectiveSnoozed`, so agents and the sidebar agree on what is snoozed. One
171 * difference: a failure counts as fresh when its run completed after the
172 * snooze, like `isAutoSettlementCandidate`. The client compares the shell's
173 * update time, so a rename can wake a failed thread there but not here.
174 */
175export function isSnoozed(
176 thread: Pick<
177 ProjectionStore.ProjectionSettlementCandidate,
178 "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest"
179 >,
180 nowMs: number,
181): boolean {
182 const snoozedUntilMs = toMillis(thread.snoozedUntil);
183 if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false;
184 if (thread.pendingRuntimeRequest !== null) return false;
185 const snoozedAtMs = toMillis(thread.snoozedAt);
186 const completedAtMs = toMillis(thread.latestRunCompletedAt);
187 const wokeOnError =
188 thread.status === "failed" &&
189 (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs));
190 // Like the client, only a run that completed wakes it; an interrupt or cancel does not.
191 const wokeOnCompletion =
192 thread.status === "completed" &&
193 snoozedAtMs !== null &&
194 completedAtMs !== null &&
195 completedAtMs > snoozedAtMs;
196 return !wokeOnError && !wokeOnCompletion;
197}
198
166199export function resolveAutoSettlementAt(input: {
167200 readonly thread: ProjectionStore.ProjectionSettlementCandidate;
168201 readonly pullRequest: SettlementPullRequest | null;
apps/web/src/components/ChatMarkdown.tsx+19-1
......@@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
8888import remarkBreaks from "remark-breaks";
8989import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
9090import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
91import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks";
9192import { AssistantCitationChip } from "./chat/AssistantCitationChip";
93import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
9294import remarkGfm from "remark-gfm";
9395import type { Processor } from "unified";
9496import { isWindowsAbsolutePath } from "@t3tools/shared/path";
......@@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
538540 },
539541 protocols: {
540542 ...defaultSchema.protocols,
541 href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "t3-context"],
543 href: [
544 ...(defaultSchema.protocols?.href ?? []),
545 "file",
546 "t3-citation",
547 "t3-context",
548 THREAD_LINK_PROTOCOL,
549 ],
542550 src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
543551 },
544552} satisfies Parameters<typeof rehypeSanitize>[0];
......@@ -2645,6 +2653,7 @@ function useChatMarkdownState({
26452653 const markdownUrlTransform = useCallback((href: string) => {
26462654 if (parseAssistantCitationHref(href)) return href;
26472655 if (parseComposerContextHref(href)) return href;
2656 if (parseThreadLinkHref(href)) return href;
26482657 if (isWindowsDrivePathHref(href)) return href;
26492658 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
26502659 }, []);
......@@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = {
31073116 } = use(ChatMarkdownRendererContext);
31083117 const citation = href ? parseAssistantCitationHref(href) : null;
31093118 if (citation) return <AssistantCitationChip citation={citation} />;
3119 // A thread link opens the thread here, never a browser.
3120 const threadLink = href ? parseThreadLinkHref(href) : null;
3121 if (threadLink) {
3122 return (
3123 <MarkdownThreadLink {...threadLink}>
3124 <MarkdownLinkContext value>{children}</MarkdownLinkContext>
3125 </MarkdownThreadLink>
3126 );
3127 }
31103128 const contextReference = href ? parseComposerContextHref(href) : null;
31113129 if (contextReference) {
31123130 const label = hastPlainTextDeep(node) || contextReference.contextId;
apps/web/src/components/chat/MarkdownThreadLink.tsx created+43
......@@ -0,0 +1,43 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { Link } from "@tanstack/react-router";
4import { MessageSquareTextIcon } from "lucide-react";
5import type { ReactNode } from "react";
6
7import { useProject, useThreadShell } from "../../state/entities";
8import { ProjectFavicon } from "../ProjectFavicon";
9
10/**
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.
14 */
15export function MarkdownThreadLink(props: {
16 readonly environmentId: EnvironmentId;
17 readonly threadId: ThreadId;
18 readonly children: ReactNode;
19}) {
20 const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId));
21 const project = useProject(
22 thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId),
23 );
24 return (
25 <Link
26 to="/$environmentId/$threadId"
27 params={{ environmentId: props.environmentId, threadId: props.threadId }}
28 title={project?.title}
29 >
30 <span
31 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
32 aria-hidden
33 >
34 {project === null ? (
35 <MessageSquareTextIcon className="block size-full shrink-0" />
36 ) : (
37 <ProjectFavicon project={project} className="size-full" />
38 )}
39 </span>
40 {props.children}
41 </Link>
42 );
43}
docs/orchestration-v2/orchestrator-mcp-server.md+7
......@@ -352,6 +352,13 @@ 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
358`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the
359client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread
360wakes early when it has a pending request, fails, or completes after the snooze.
361
355362### `t3_thread_update`
356363
357364Updates metadata for the calling thread or any other thread in the environment.
packages/contracts/src/orchestratorMcp.ts+11
......@@ -295,6 +295,7 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({
295295 ),
296296 titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))),
297297 settled: Schema.optional(Schema.Boolean),
298 snoozed: Schema.optional(Schema.Boolean),
298299 includeSubagents: Schema.optional(Schema.Boolean),
299300 cursor: Schema.optional(NonNegativeInt),
300301 limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))),
......@@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp
303304
304305export const OrchestratorMcpThreadListItem = Schema.Struct({
305306 threadId: ThreadId,
307 /** Paste this whenever you mention the thread, so the user can click to open it. */
308 link: Schema.String,
306309 title: Schema.String,
307310 createdBy: OrchestrationV2Actor,
308311 creationSource: OrchestrationV2CreationSource,
......@@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({
315318 linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
316319 settled: Schema.Boolean,
317320 settledAt: Schema.NullOr(IsoDateTime),
321 snoozed: Schema.Boolean,
322 /** When a snoozed thread wakes; null when it is not snoozed. */
323 snoozedUntil: Schema.NullOr(IsoDateTime),
318324 parentThreadId: Schema.NullOr(ThreadId),
319325 relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])),
320326 itemCount: NonNegativeInt,
......@@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp
347353
348354export const OrchestratorMcpThreadDetail = Schema.Struct({
349355 threadId: ThreadId,
356 /** Paste this whenever you mention the thread, so the user can click to open it. */
357 link: Schema.String,
350358 projectId: ProjectId,
351359 title: Schema.String,
352360 createdBy: OrchestrationV2Actor,
......@@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({
370378 archived: Schema.Boolean,
371379 settled: Schema.Boolean,
372380 settledAt: Schema.NullOr(IsoDateTime),
381 snoozed: Schema.Boolean,
382 /** When a snoozed thread wakes; null when it is not snoozed. */
383 snoozedUntil: Schema.NullOr(IsoDateTime),
373384 createdAt: IsoDateTime,
374385 updatedAt: IsoDateTime,
375386});
packages/shared/package.json+4
......@@ -243,6 +243,10 @@
243243 "types": "./src/assistantCitations.ts",
244244 "import": "./src/assistantCitations.ts"
245245 },
246 "./threadLinks": {
247 "types": "./src/threadLinks.ts",
248 "import": "./src/threadLinks.ts"
249 },
246250 "./composerContextReferences": {
247251 "types": "./src/composerContextReferences.ts",
248252 "import": "./src/composerContextReferences.ts"
packages/shared/src/threadLinks.test.ts created+28
......@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts";
4
5describe("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 });
20 });
21
22 it("rejects other links and malformed ones", () => {
23 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();
27 });
28});
packages/shared/src/threadLinks.ts created+58
......@@ -0,0 +1,58 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5/**
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.
10 */
11export const THREAD_LINK_PROTOCOL = "t3-thread";
12const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
13const LINK_LABEL_MAX_CHARS = 120;
14
15const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId);
16const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
17
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)})`;
40}
41
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;
48 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;
54 } catch {
55 // Malformed percent encoding.
56 return null;
57 }
58}