| author | |
| committer | |
| log | 611132c171f3a821bd2e32f22261135cef6330ac |
| tree | 1610c8c6e8ac33556291a95f04472d972b948ee6 |
| parent | cd41c4ada0c70cc2eec95ecd7266f3dab010c58c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | normalizeMarkdownLinkDestination, | 5 | normalizeMarkdownLinkDestination, |
| 6 | parseMarkdownFileLink, | 6 | parseMarkdownFileLink, |
| 7 | } from "@t3tools/client-runtime/markdown-links"; | 7 | } from "@t3tools/client-runtime/markdown-links"; |
| 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | ||
| 8 | import { videoMimeType } from "@t3tools/shared/video"; | 9 | import { videoMimeType } from "@t3tools/shared/video"; |
| 9 | 10 | ||
| 10 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | 11 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; |
| ... | @@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese | ... | @@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese |
| 293 | 294 | ||
| 294 | return { | 295 | return { |
| 295 | kind: "link", | 296 | 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, | ||
| 297 | }; | 302 | }; |
| 298 | } | 303 | } |
| 299 | 304 |
apps/mobile/src/features/threads/ThreadFeed.tsx+18-5| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 26 | import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; | 26 | import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; |
| 27 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; | 27 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 28 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | ||
| 28 | import { | 29 | import { |
| 29 | parseComposerContextHref, | 30 | parseComposerContextHref, |
| 30 | collectComposerContextReferences, | 31 | collectComposerContextReferences, |
| ... | @@ -1242,16 +1243,20 @@ function useMarkdownStyles( | ... | @@ -1242,16 +1243,20 @@ function useMarkdownStyles( |
| 1242 | ); | 1243 | ); |
| 1243 | } | 1244 | } |
| 1244 | const linkHref = presentation.href; | 1245 | 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; | ||
| 1245 | return ( | 1248 | return ( |
| 1246 | <MarkdownLinkLabelContext.Provider value="other"> | 1249 | <MarkdownLinkLabelContext.Provider value="other"> |
| 1247 | <NativeText | 1250 | <NativeText |
| 1248 | className="underline" | 1251 | className="underline" |
| 1249 | onPress={ | 1252 | onPress={ |
| 1250 | linkHref | 1253 | isThreadLink |
| 1251 | ? () => { | 1254 | ? () => onLinkPress(href) |
| 1252 | void tryOpenExternalUrl(linkHref, "markdown-link"); | 1255 | : linkHref |
| 1253 | } | 1256 | ? () => { |
| 1254 | : undefined | 1257 | void tryOpenExternalUrl(linkHref, "markdown-link"); |
| 1258 | } | ||
| 1259 | : undefined | ||
| 1255 | } | 1260 | } |
| 1256 | style={{ color: markdownLinkColor }} | 1261 | style={{ color: markdownLinkColor }} |
| 1257 | > | 1262 | > |
| ... | @@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2271 | const userBubbleColor = theme["--color-user-bubble"]; | 2276 | const userBubbleColor = theme["--color-user-bubble"]; |
| 2272 | const onMarkdownLinkPress = useCallback( | 2277 | const onMarkdownLinkPress = useCallback( |
| 2273 | (href: string) => { | 2278 | (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 | } | ||
| 2274 | const presentation = resolveMarkdownLinkPresentation(href); | 2287 | const presentation = resolveMarkdownLinkPresentation(href); |
| 2275 | if (presentation.kind === "file") { | 2288 | if (presentation.kind === "file") { |
| 2276 | const relativePath = resolveWorkspaceRelativeFilePath( | 2289 | const relativePath = resolveWorkspaceRelativeFilePath( |
apps/mobile/src/lib/nativeMarkdownText.test.ts+15| ... | @@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => { | ... | @@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => { |
| 84 | ]); | 84 | ]); |
| 85 | }); | 85 | }); |
| 86 | 86 | ||
| 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 | |||
| 87 | it("preserves the destination of a link with a code-formatted label", () => { | 102 | it("preserves the destination of a link with a code-formatted label", () => { |
| 88 | expect( | 103 | expect( |
| 89 | nativeMarkdownTextRuns({ | 104 | nativeMarkdownTextRuns({ |
apps/server/src/mcp/OrchestratorMcpService.ts+54-5| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | ||
| 2 | CommandId, | 3 | CommandId, |
| 3 | type RunId, | 4 | type RunId, |
| 4 | isProviderAvailable, | 5 | isProviderAvailable, |
| ... | @@ -58,6 +59,8 @@ import { | ... | @@ -58,6 +59,8 @@ import { |
| 58 | ThreadId, | 59 | ThreadId, |
| 59 | } from "@t3tools/contracts"; | 60 | } from "@t3tools/contracts"; |
| 60 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; | 61 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; |
| 62 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | ||
| 63 | import * as Clock from "effect/Clock"; | ||
| 61 | import * as Context from "effect/Context"; | 64 | import * as Context from "effect/Context"; |
| 62 | import * as Crypto from "effect/Crypto"; | 65 | import * as Crypto from "effect/Crypto"; |
| 63 | import * as DateTime from "effect/DateTime"; | 66 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -80,6 +83,7 @@ import { | ... | @@ -80,6 +83,7 @@ import { |
| 80 | type DispatchModeRefusal, | 83 | type DispatchModeRefusal, |
| 81 | } from "../orchestration-v2/DispatchModeLimit.ts"; | 84 | } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 82 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 85 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 86 | import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; | ||
| 83 | import * as ProjectService from "../project/ProjectService.ts"; | 87 | import * as ProjectService from "../project/ProjectService.ts"; |
| 84 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 88 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 85 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 89 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| ... | @@ -617,9 +621,35 @@ function threadSettlement( | ... | @@ -617,9 +621,35 @@ function threadSettlement( |
| 617 | }; | 621 | }; |
| 618 | } | 622 | } |
| 619 | 623 | ||
| 620 | function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpThreadListItem { | 624 | function 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. */ | ||
| 637 | interface ThreadViewContext { | ||
| 638 | readonly environmentId: EnvironmentId; | ||
| 639 | readonly nowMs: number; | ||
| 640 | } | ||
| 641 | |||
| 642 | function listItemFromShell( | ||
| 643 | shell: OrchestrationV2ThreadShell, | ||
| 644 | context: ThreadViewContext, | ||
| 645 | ): OrchestratorMcpThreadListItem { | ||
| 621 | return { | 646 | return { |
| 622 | threadId: shell.id, | 647 | threadId: shell.id, |
| 648 | link: formatThreadLink({ | ||
| 649 | environmentId: context.environmentId, | ||
| 650 | threadId: shell.id, | ||
| 651 | title: shell.title, | ||
| 652 | }), | ||
| 623 | title: shell.title, | 653 | title: shell.title, |
| 624 | createdBy: shell.createdBy, | 654 | createdBy: shell.createdBy, |
| 625 | creationSource: shell.creationSource, | 655 | creationSource: shell.creationSource, |
| ... | @@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh | ... | @@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh |
| 631 | interactionMode: shell.interactionMode, | 661 | interactionMode: shell.interactionMode, |
| 632 | linkedPullRequest: shell.linkedPullRequest ?? null, | 662 | linkedPullRequest: shell.linkedPullRequest ?? null, |
| 633 | ...threadSettlement(shell), | 663 | ...threadSettlement(shell), |
| 664 | ...threadSnooze(shell, context.nowMs), | ||
| 634 | parentThreadId: shell.lineage.parentThreadId, | 665 | parentThreadId: shell.lineage.parentThreadId, |
| 635 | relationshipToParent: shell.lineage.relationshipToParent, | 666 | relationshipToParent: shell.lineage.relationshipToParent, |
| 636 | itemCount: shell.visibleItemCount, | 667 | itemCount: shell.visibleItemCount, |
| ... | @@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh | ... | @@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh |
| 642 | function threadDetail( | 673 | function threadDetail( |
| 643 | projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">, | 674 | projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">, |
| 644 | itemCount: number, | 675 | itemCount: number, |
| 676 | shell: OrchestrationV2ThreadShell, | ||
| 677 | context: ThreadViewContext, | ||
| 645 | ): OrchestratorMcpThreadDetail { | 678 | ): OrchestratorMcpThreadDetail { |
| 646 | const latest = ThreadManagementService.latestRun(projection); | 679 | const latest = ThreadManagementService.latestRun(projection); |
| 647 | const active = ThreadManagementService.latestActiveRun(projection); | 680 | const active = ThreadManagementService.latestActiveRun(projection); |
| 648 | return { | 681 | return { |
| 649 | threadId: projection.thread.id, | 682 | threadId: projection.thread.id, |
| 683 | link: formatThreadLink({ | ||
| 684 | environmentId: context.environmentId, | ||
| 685 | threadId: projection.thread.id, | ||
| 686 | title: projection.thread.title, | ||
| 687 | }), | ||
| 650 | projectId: projection.thread.projectId, | 688 | projectId: projection.thread.projectId, |
| 651 | title: projection.thread.title, | 689 | title: projection.thread.title, |
| 652 | createdBy: projection.thread.createdBy, | 690 | createdBy: projection.thread.createdBy, |
| ... | @@ -678,6 +716,8 @@ function threadDetail( | ... | @@ -678,6 +716,8 @@ function threadDetail( |
| 678 | ).length, | 716 | ).length, |
| 679 | archived: projection.thread.archivedAt !== null, | 717 | archived: projection.thread.archivedAt !== null, |
| 680 | ...threadSettlement(projection.thread), | 718 | ...threadSettlement(projection.thread), |
| 719 | // From the shell, like the list, so read and list agree on snooze state. | ||
| 720 | ...threadSnooze(shell, context.nowMs), | ||
| 681 | createdAt: DateTime.formatIso(projection.thread.createdAt), | 721 | createdAt: DateTime.formatIso(projection.thread.createdAt), |
| 682 | updatedAt: DateTime.formatIso(projection.thread.updatedAt), | 722 | updatedAt: DateTime.formatIso(projection.thread.updatedAt), |
| 683 | }; | 723 | }; |
| ... | @@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () { | ... | @@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () { |
| 1024 | "contextTransfers", | 1064 | "contextTransfers", |
| 1025 | ]) | 1065 | ]) |
| 1026 | .pipe(Effect.mapError(threadManagementFailure)); | 1066 | .pipe(Effect.mapError(threadManagementFailure)); |
| 1027 | return { parent, target } as const; | 1067 | return { parent, target, shell } as const; |
| 1028 | }); | 1068 | }); |
| 1029 | 1069 | ||
| 1030 | const loadProviders = providerRegistry.getProviders; | 1070 | const loadProviders = providerRegistry.getProviders; |
| ... | @@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () { | ... | @@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () { |
| 2226 | failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), | 2266 | failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), |
| 2227 | ), | 2267 | ), |
| 2228 | ); | 2268 | ); |
| 2269 | const nowMs = yield* Clock.currentTimeMillis; | ||
| 2229 | const statuses = input.statuses === undefined ? null : new Set(input.statuses); | 2270 | const statuses = input.statuses === undefined ? null : new Set(input.statuses); |
| 2230 | const titleContains = input.titleContains?.toLocaleLowerCase(); | 2271 | const titleContains = input.titleContains?.toLocaleLowerCase(); |
| 2231 | const filtered = projectThreads | 2272 | const filtered = projectThreads |
| ... | @@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () { | ... | @@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () { |
| 2237 | (thread) => | 2278 | (thread) => |
| 2238 | input.settled === undefined || threadSettlement(thread).settled === input.settled, | 2279 | input.settled === undefined || threadSettlement(thread).settled === input.settled, |
| 2239 | ) | 2280 | ) |
| 2281 | .filter( | ||
| 2282 | (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, | ||
| 2283 | ) | ||
| 2240 | .filter( | 2284 | .filter( |
| 2241 | (thread) => | 2285 | (thread) => |
| 2242 | titleContains === undefined || | 2286 | titleContains === undefined || |
| ... | @@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () { | ... | @@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () { |
| 2249 | return { | 2293 | return { |
| 2250 | projectId, | 2294 | projectId, |
| 2251 | currentThreadId: parent?.thread.id ?? null, | 2295 | currentThreadId: parent?.thread.id ?? null, |
| 2252 | threads: page.map(listItemFromShell), | 2296 | threads: page.map((shell) => |
| 2297 | listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), | ||
| 2298 | ), | ||
| 2253 | nextCursor, | 2299 | nextCursor, |
| 2254 | total: filtered.length, | 2300 | total: filtered.length, |
| 2255 | } satisfies OrchestratorMcpThreadListResult; | 2301 | } satisfies OrchestratorMcpThreadListResult; |
| 2256 | }), | 2302 | }), |
| 2257 | readThread: (scope, input) => | 2303 | readThread: (scope, input) => |
| 2258 | Effect.gen(function* () { | 2304 | Effect.gen(function* () { |
| 2259 | const { parent, target } = yield* loadReadableThread(scope, input.threadId); | 2305 | const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId); |
| 2260 | const view = input.view ?? "messages"; | 2306 | const view = input.view ?? "messages"; |
| 2261 | const afterPosition = input.afterPosition ?? -1; | 2307 | const afterPosition = input.afterPosition ?? -1; |
| 2262 | const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; | 2308 | const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; |
| ... | @@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () { | ... | @@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () { |
| 2329 | } | 2375 | } |
| 2330 | } | 2376 | } |
| 2331 | return { | 2377 | return { |
| 2332 | thread: threadDetail(target, timeline.totalItems), | 2378 | thread: threadDetail(target, timeline.totalItems, shell, { |
| 2379 | environmentId: scope.environmentId, | ||
| 2380 | nowMs: yield* Clock.currentTimeMillis, | ||
| 2381 | }), | ||
| 2333 | recentRuns: target.runs | 2382 | recentRuns: target.runs |
| 2334 | .toSorted((left, right) => right.ordinal - left.ordinal) | 2383 | .toSorted((left, right) => right.ordinal - left.ordinal) |
| 2335 | .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) | 2384 | .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", () => { | ... | @@ -2232,6 +2232,44 @@ describe("orchestrator MCP toolkit", () => { |
| 2232 | reason: "user", | 2232 | reason: "user", |
| 2233 | }); | 2233 | }); |
| 2234 | 2234 | ||
| 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 | |||
| 2235 | const unlinkedCall = yield* invoke("t3_thread_update", { | 2273 | const unlinkedCall = yield* invoke("t3_thread_update", { |
| 2236 | threadId: emptyThread.threadId, | 2274 | threadId: emptyThread.threadId, |
| 2237 | action: "unlink_pull_request", | 2275 | 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", { | ... | @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { |
| 178 | 178 | ||
| 179 | const ThreadListTool = Tool.make("t3_thread_list", { | 179 | const ThreadListTool = Tool.make("t3_thread_list", { |
| 180 | description: | 180 | 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.", |
| 182 | parameters: OrchestratorMcpThreadListInput, | 182 | parameters: OrchestratorMcpThreadListInput, |
| 183 | success: OrchestratorMcpThreadListResult, | 183 | success: OrchestratorMcpThreadListResult, |
| 184 | failure: OrchestratorMcpFailure, | 184 | failure: OrchestratorMcpFailure, |
| ... | @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { | ... | @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { |
| 192 | 192 | ||
| 193 | const ThreadReadTool = Tool.make("t3_thread_read", { | 193 | const ThreadReadTool = Tool.make("t3_thread_read", { |
| 194 | description: | 194 | 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.", |
| 196 | parameters: OrchestratorMcpThreadReadInput, | 196 | parameters: OrchestratorMcpThreadReadInput, |
| 197 | success: OrchestratorMcpThreadReadResult, | 197 | success: OrchestratorMcpThreadReadResult, |
| 198 | failure: OrchestratorMcpFailure, | 198 | 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", | ... | @@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread", |
| 69 | return Effect.succeed({ | 69 | return Effect.succeed({ |
| 70 | threadId: input.threadId, | 70 | threadId: input.threadId, |
| 71 | projection: { | 71 | projection: { |
| 72 | thread: { id: input.threadId, projectId, modelSelection }, | 72 | thread: { id: input.threadId, projectId, modelSelection, title: input.title }, |
| 73 | runs: [], | 73 | runs: [], |
| 74 | }, | 74 | }, |
| 75 | resumed: false, | 75 | resumed: false, |
| ... | @@ -94,7 +94,11 @@ it.effect("attributes a launched thread's first message to the calling thread", | ... | @@ -94,7 +94,11 @@ it.effect("attributes a launched thread's first message to the calling thread", |
| 94 | const result = yield* toolkit | 94 | const result = yield* toolkit |
| 95 | .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) | 95 | .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) |
| 96 | .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); | 96 | .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 | }); | ||
| 98 | expect(launchedSender).toBe(sourceThreadId); | 102 | expect(launchedSender).toBe(sourceThreadId); |
| 99 | }), | 103 | }), |
| 100 | ); | 104 | ); |
| ... | @@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () => | ... | @@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () => |
| 140 | return Effect.succeed({ | 144 | return Effect.succeed({ |
| 141 | threadId: input.threadId, | 145 | threadId: input.threadId, |
| 142 | projection: { | 146 | 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 | }, | ||
| 144 | runs: [], | 153 | runs: [], |
| 145 | }, | 154 | }, |
| 146 | resumed: false, | 155 | resumed: false, |
| ... | @@ -329,6 +338,7 @@ const clientLaunchHarness = (input: { | ... | @@ -329,6 +338,7 @@ const clientLaunchHarness = (input: { |
| 329 | id: launch.threadId, | 338 | id: launch.threadId, |
| 330 | projectId: launch.projectId, | 339 | projectId: launch.projectId, |
| 331 | modelSelection: launch.modelSelection, | 340 | modelSelection: launch.modelSelection, |
| 341 | title: launch.title, | ||
| 332 | }, | 342 | }, |
| 333 | runs: [], | 343 | runs: [], |
| 334 | }, | 344 | }, |
apps/server/src/mcp/toolkits/project/handlers.ts+7-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; | 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; |
| 2 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| ... | @@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { | ... | @@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 57 | (input, { runtimeMode, interactionMode }) => | 58 | (input, { runtimeMode, interactionMode }) => |
| 58 | Effect.gen(function* () { | 59 | Effect.gen(function* () { |
| 59 | const context = yield* readCaller(); | 60 | const context = yield* readCaller(); |
| 60 | const { caller } = context; | 61 | const { caller, scope } = context; |
| 61 | const commandId = yield* newCommandId(); | 62 | const commandId = yield* newCommandId(); |
| 62 | const threadId = ThreadId.make(commandId); | 63 | const threadId = ThreadId.make(commandId); |
| 63 | const messageId = MessageId.make(commandId); | 64 | const messageId = MessageId.make(commandId); |
| ... | @@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { | ... | @@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 146 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); | 147 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); |
| 147 | return { | 148 | return { |
| 148 | threadId: thread.id, | 149 | threadId: thread.id, |
| 150 | link: formatThreadLink({ | ||
| 151 | environmentId: scope.environmentId, | ||
| 152 | threadId: thread.id, | ||
| 153 | title: thread.title, | ||
| 154 | }), | ||
| 149 | projectId: thread.projectId, | 155 | projectId: thread.projectId, |
| 150 | modelSelection: thread.modelSelection, | 156 | modelSelection: thread.modelSelection, |
| 151 | runId: run?.id ?? null, | 157 | 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", { | ... | @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { |
| 103 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { | 103 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 104 | ...shared, | 104 | ...shared, |
| 105 | description: | 105 | 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.', |
| 107 | parameters: Schema.Struct({ | 107 | parameters: Schema.Struct({ |
| 108 | projectId: Schema.optional(ProjectId), | 108 | projectId: Schema.optional(ProjectId), |
| 109 | scratch: Schema.optional( | 109 | scratch: Schema.optional( |
| ... | @@ -132,6 +132,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { | ... | @@ -132,6 +132,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 132 | }), | 132 | }), |
| 133 | success: Schema.Struct({ | 133 | success: Schema.Struct({ |
| 134 | threadId: ThreadId, | 134 | threadId: ThreadId, |
| 135 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | ||
| 136 | link: Schema.String, | ||
| 135 | projectId: ProjectId, | 137 | projectId: ProjectId, |
| 136 | modelSelection: ModelSelection, | 138 | modelSelection: ModelSelection, |
| 137 | runId: Schema.NullOr(RunId), | 139 | runId: Schema.NullOr(RunId), |
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+22| ... | @@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => { | ... | @@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => { |
| 1252 | ), | 1252 | ), |
| 1253 | ); | 1253 | ); |
| 1254 | }); | 1254 | }); |
| 1255 | |||
| 1256 | describe("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( | ... | @@ -163,6 +163,39 @@ export function isAutoSettlementCandidate( |
| 163 | return wokeOnError || wokeOnCompletion; | 163 | return wokeOnError || wokeOnCompletion; |
| 164 | } | 164 | } |
| 165 | 165 | ||
| 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 | */ | ||
| 175 | export 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 | |||
| 166 | export function resolveAutoSettlementAt(input: { | 199 | export function resolveAutoSettlementAt(input: { |
| 167 | readonly thread: ProjectionStore.ProjectionSettlementCandidate; | 200 | readonly thread: ProjectionStore.ProjectionSettlementCandidate; |
| 168 | readonly pullRequest: SettlementPullRequest | null; | 201 | readonly pullRequest: SettlementPullRequest | null; |
apps/web/src/components/ChatMarkdown.tsx+19-1| ... | @@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | ... | @@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; |
| 88 | import remarkBreaks from "remark-breaks"; | 88 | import remarkBreaks from "remark-breaks"; |
| 89 | import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; | 89 | import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; |
| 90 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; | 90 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; |
| 91 | import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; | ||
| 91 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | 92 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 93 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; | ||
| 92 | import remarkGfm from "remark-gfm"; | 94 | import remarkGfm from "remark-gfm"; |
| 93 | import type { Processor } from "unified"; | 95 | import type { Processor } from "unified"; |
| 94 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 96 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| ... | @@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | ... | @@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 538 | }, | 540 | }, |
| 539 | protocols: { | 541 | protocols: { |
| 540 | ...defaultSchema.protocols, | 542 | ...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 | ], | ||
| 542 | src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"], | 550 | src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"], |
| 543 | }, | 551 | }, |
| 544 | } satisfies Parameters<typeof rehypeSanitize>[0]; | 552 | } satisfies Parameters<typeof rehypeSanitize>[0]; |
| ... | @@ -2645,6 +2653,7 @@ function useChatMarkdownState({ | ... | @@ -2645,6 +2653,7 @@ function useChatMarkdownState({ |
| 2645 | const markdownUrlTransform = useCallback((href: string) => { | 2653 | const markdownUrlTransform = useCallback((href: string) => { |
| 2646 | if (parseAssistantCitationHref(href)) return href; | 2654 | if (parseAssistantCitationHref(href)) return href; |
| 2647 | if (parseComposerContextHref(href)) return href; | 2655 | if (parseComposerContextHref(href)) return href; |
| 2656 | if (parseThreadLinkHref(href)) return href; | ||
| 2648 | if (isWindowsDrivePathHref(href)) return href; | 2657 | if (isWindowsDrivePathHref(href)) return href; |
| 2649 | return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); | 2658 | return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); |
| 2650 | }, []); | 2659 | }, []); |
| ... | @@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3107 | } = use(ChatMarkdownRendererContext); | 3116 | } = use(ChatMarkdownRendererContext); |
| 3108 | const citation = href ? parseAssistantCitationHref(href) : null; | 3117 | const citation = href ? parseAssistantCitationHref(href) : null; |
| 3109 | if (citation) return <AssistantCitationChip citation={citation} />; | 3118 | 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 | } | ||
| 3110 | const contextReference = href ? parseComposerContextHref(href) : null; | 3128 | const contextReference = href ? parseComposerContextHref(href) : null; |
| 3111 | if (contextReference) { | 3129 | if (contextReference) { |
| 3112 | const label = hastPlainTextDeep(node) || contextReference.contextId; | 3130 | const label = hastPlainTextDeep(node) || contextReference.contextId; |
apps/web/src/components/chat/MarkdownThreadLink.tsx created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { Link } from "@tanstack/react-router"; | ||
| 4 | import { MessageSquareTextIcon } from "lucide-react"; | ||
| 5 | import type { ReactNode } from "react"; | ||
| 6 | |||
| 7 | import { useProject, useThreadShell } from "../../state/entities"; | ||
| 8 | import { 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 | */ | ||
| 15 | export 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"`. | ... | @@ -352,6 +352,13 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. |
| 352 | Actor and ingress are separate so agent-authored user-role messages remain | 352 | Actor and ingress are separate so agent-authored user-role messages remain |
| 353 | distinguishable from human-authored messages. | 353 | distinguishable from human-authored messages. |
| 354 | 354 | ||
| 355 | List, read, and launch results include `link`, a Markdown link of the form | ||
| 356 | `[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the | ||
| 357 | thread. List and read results also report `snoozed` and `snoozedUntil`, and | ||
| 358 | `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the | ||
| 359 | client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread | ||
| 360 | wakes early when it has a pending request, fails, or completes after the snooze. | ||
| 361 | |||
| 355 | ### `t3_thread_update` | 362 | ### `t3_thread_update` |
| 356 | 363 | ||
| 357 | Updates metadata for the calling thread or any other thread in the environment. | 364 | Updates 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({ | ... | @@ -295,6 +295,7 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({ |
| 295 | ), | 295 | ), |
| 296 | titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), | 296 | titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), |
| 297 | settled: Schema.optional(Schema.Boolean), | 297 | settled: Schema.optional(Schema.Boolean), |
| 298 | snoozed: Schema.optional(Schema.Boolean), | ||
| 298 | includeSubagents: Schema.optional(Schema.Boolean), | 299 | includeSubagents: Schema.optional(Schema.Boolean), |
| 299 | cursor: Schema.optional(NonNegativeInt), | 300 | cursor: Schema.optional(NonNegativeInt), |
| 300 | limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), | 301 | limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), |
| ... | @@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp | ... | @@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp |
| 303 | 304 | ||
| 304 | export const OrchestratorMcpThreadListItem = Schema.Struct({ | 305 | export const OrchestratorMcpThreadListItem = Schema.Struct({ |
| 305 | threadId: ThreadId, | 306 | threadId: ThreadId, |
| 307 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | ||
| 308 | link: Schema.String, | ||
| 306 | title: Schema.String, | 309 | title: Schema.String, |
| 307 | createdBy: OrchestrationV2Actor, | 310 | createdBy: OrchestrationV2Actor, |
| 308 | creationSource: OrchestrationV2CreationSource, | 311 | creationSource: OrchestrationV2CreationSource, |
| ... | @@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ | ... | @@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ |
| 315 | linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | 318 | linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), |
| 316 | settled: Schema.Boolean, | 319 | settled: Schema.Boolean, |
| 317 | settledAt: Schema.NullOr(IsoDateTime), | 320 | 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), | ||
| 318 | parentThreadId: Schema.NullOr(ThreadId), | 324 | parentThreadId: Schema.NullOr(ThreadId), |
| 319 | relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), | 325 | relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), |
| 320 | itemCount: NonNegativeInt, | 326 | itemCount: NonNegativeInt, |
| ... | @@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp | ... | @@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp |
| 347 | 353 | ||
| 348 | export const OrchestratorMcpThreadDetail = Schema.Struct({ | 354 | export const OrchestratorMcpThreadDetail = Schema.Struct({ |
| 349 | threadId: ThreadId, | 355 | threadId: ThreadId, |
| 356 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | ||
| 357 | link: Schema.String, | ||
| 350 | projectId: ProjectId, | 358 | projectId: ProjectId, |
| 351 | title: Schema.String, | 359 | title: Schema.String, |
| 352 | createdBy: OrchestrationV2Actor, | 360 | createdBy: OrchestrationV2Actor, |
| ... | @@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ | ... | @@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ |
| 370 | archived: Schema.Boolean, | 378 | archived: Schema.Boolean, |
| 371 | settled: Schema.Boolean, | 379 | settled: Schema.Boolean, |
| 372 | settledAt: Schema.NullOr(IsoDateTime), | 380 | 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), | ||
| 373 | createdAt: IsoDateTime, | 384 | createdAt: IsoDateTime, |
| 374 | updatedAt: IsoDateTime, | 385 | updatedAt: IsoDateTime, |
| 375 | }); | 386 | }); |
packages/shared/package.json+4| ... | @@ -243,6 +243,10 @@ | ... | @@ -243,6 +243,10 @@ |
| 243 | "types": "./src/assistantCitations.ts", | 243 | "types": "./src/assistantCitations.ts", |
| 244 | "import": "./src/assistantCitations.ts" | 244 | "import": "./src/assistantCitations.ts" |
| 245 | }, | 245 | }, |
| 246 | "./threadLinks": { | ||
| 247 | "types": "./src/threadLinks.ts", | ||
| 248 | "import": "./src/threadLinks.ts" | ||
| 249 | }, | ||
| 246 | "./composerContextReferences": { | 250 | "./composerContextReferences": { |
| 247 | "types": "./src/composerContextReferences.ts", | 251 | "types": "./src/composerContextReferences.ts", |
| 248 | "import": "./src/composerContextReferences.ts" | 252 | "import": "./src/composerContextReferences.ts" |
packages/shared/src/threadLinks.test.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * 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 | */ | ||
| 11 | export const THREAD_LINK_PROTOCOL = "t3-thread"; | ||
| 12 | const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; | ||
| 13 | const LINK_LABEL_MAX_CHARS = 120; | ||
| 14 | |||
| 15 | const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); | ||
| 16 | const decodeThreadId = Schema.decodeUnknownOption(ThreadId); | ||
| 17 | |||
| 18 | // encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. | ||
| 19 | function encodeIdSegment(id: string): string { | ||
| 20 | return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); | ||
| 21 | } | ||
| 22 | |||
| 23 | function 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. */ | ||
| 28 | export 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 | |||
| 42 | export 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 | } | ||