diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 4143b2fc8a2808233231549edc7e0a6c705f33ec..8b491c0c77c15fe578a9a684e592ad66621cae1d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -5,6 +5,7 @@ import { normalizeMarkdownLinkDestination, parseMarkdownFileLink, } from "@t3tools/client-runtime/markdown-links"; +import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { videoMimeType } from "@t3tools/shared/video"; import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; @@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese return { kind: "link", - href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, + // A thread link keeps its href so a press reaches the feed, which opens the thread. + href: + /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null + ? normalized + : null, }; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 58191639331af4769fe360b00cb06561c789b095..4d81c6f07063e587ea9cdbe7f8ee09daba4046d2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -25,6 +25,7 @@ import { } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; +import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { parseComposerContextHref, collectComposerContextReferences, @@ -1242,16 +1243,20 @@ function useMarkdownStyles( ); } const linkHref = presentation.href; + // A thread link opens the thread in the app, through the feed's link handler. + const isThreadLink = parseThreadLinkHref(href) !== null; return ( { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined + isThreadLink + ? () => onLinkPress(href) + : linkHref + ? () => { + void tryOpenExternalUrl(linkHref, "markdown-link"); + } + : undefined } style={{ color: markdownLinkColor }} > @@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { + const threadLink = parseThreadLinkHref(href); + if (threadLink) { + navigation.navigate("Thread", { + environmentId: String(threadLink.environmentId), + threadId: String(threadLink.threadId), + }); + return; + } const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { const relativePath = resolveWorkspaceRelativeFilePath( diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 0a4357ee1240f5032e03e6f153201b396e35dfcd..62f0de0c1b95506f1107fd39cbcb799d43a7410e 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => { ]); }); + it("keeps a thread link's href so a press can open the thread", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "t3-thread://v1/env/thread-1", + children: [{ type: "text", content: "Fix the build" }], + }, + ], + }), + ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]); + }); + it("preserves the destination of a link with a code-formatted label", () => { expect( nativeMarkdownTextRuns({ diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 3d4059d70cefeea45f247204432a6c865b8e4b33..dccda1b495190fb27090c5e8019eb87c5ddd2923 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, CommandId, type RunId, isProviderAvailable, @@ -58,6 +59,8 @@ import { ThreadId, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -80,6 +83,7 @@ import { type DispatchModeRefusal, } from "../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -617,9 +621,35 @@ function threadSettlement( }; } -function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpThreadListItem { +function threadSnooze( + thread: Parameters[0], + nowMs: number, +): Pick { + const snoozed = isSnoozed(thread, nowMs); + return { + snoozed, + snoozedUntil: + snoozed && thread.snoozedUntil != null ? DateTime.formatIso(thread.snoozedUntil) : null, + }; +} + +/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */ +interface ThreadViewContext { + readonly environmentId: EnvironmentId; + readonly nowMs: number; +} + +function listItemFromShell( + shell: OrchestrationV2ThreadShell, + context: ThreadViewContext, +): OrchestratorMcpThreadListItem { return { threadId: shell.id, + link: formatThreadLink({ + environmentId: context.environmentId, + threadId: shell.id, + title: shell.title, + }), title: shell.title, createdBy: shell.createdBy, creationSource: shell.creationSource, @@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh interactionMode: shell.interactionMode, linkedPullRequest: shell.linkedPullRequest ?? null, ...threadSettlement(shell), + ...threadSnooze(shell, context.nowMs), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh function threadDetail( projection: Pick, itemCount: number, + shell: OrchestrationV2ThreadShell, + context: ThreadViewContext, ): OrchestratorMcpThreadDetail { const latest = ThreadManagementService.latestRun(projection); const active = ThreadManagementService.latestActiveRun(projection); return { threadId: projection.thread.id, + link: formatThreadLink({ + environmentId: context.environmentId, + threadId: projection.thread.id, + title: projection.thread.title, + }), projectId: projection.thread.projectId, title: projection.thread.title, createdBy: projection.thread.createdBy, @@ -678,6 +716,8 @@ function threadDetail( ).length, archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), + // From the shell, like the list, so read and list agree on snooze state. + ...threadSnooze(shell, context.nowMs), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; @@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () { "contextTransfers", ]) .pipe(Effect.mapError(threadManagementFailure)); - return { parent, target } as const; + return { parent, target, shell } as const; }); const loadProviders = providerRegistry.getProviders; @@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), ), ); + const nowMs = yield* Clock.currentTimeMillis; const statuses = input.statuses === undefined ? null : new Set(input.statuses); const titleContains = input.titleContains?.toLocaleLowerCase(); const filtered = projectThreads @@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () { (thread) => input.settled === undefined || threadSettlement(thread).settled === input.settled, ) + .filter( + (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, + ) .filter( (thread) => titleContains === undefined || @@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map(listItemFromShell), + threads: page.map((shell) => + listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), + ), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; }), readThread: (scope, input) => Effect.gen(function* () { - const { parent, target } = yield* loadReadableThread(scope, input.threadId); + const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId); const view = input.view ?? "messages"; const afterPosition = input.afterPosition ?? -1; const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; @@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () { } } return { - thread: threadDetail(target, timeline.totalItems), + thread: threadDetail(target, timeline.totalItems, shell, { + environmentId: scope.environmentId, + nowMs: yield* Clock.currentTimeMillis, + }), recentRuns: target.runs .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 4c3456aec830e7394111bfa3169eb04ec01c932c..673830dd66d3ced7dbc0b0c080f987bb2646fdb7 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2232,6 +2232,44 @@ describe("orchestrator MCP toolkit", () => { reason: "user", }); + expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); + expect(metadataRead.thread.link).toBe( + `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`, + ); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("command:mcp-empty:snooze"), + threadId: emptyThread.threadId, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + const snoozedListCall = yield* invoke("t3_thread_list", { snoozed: true, limit: 100 }); + const snoozedList = yield* decodeThreadListResult( + snoozedListCall.structuredContent, + ).pipe(Effect.orDie); + expect(snoozedList.threads.map((thread) => thread.threadId)).toEqual([ + emptyThread.threadId, + ]); + expect(snoozedList.threads[0]).toMatchObject({ + snoozed: true, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + const snoozedReadCall = yield* invoke("t3_thread_read", { + threadId: emptyThread.threadId, + }); + const snoozedRead = yield* decodeThreadReadResult( + snoozedReadCall.structuredContent, + ).pipe(Effect.orDie); + expect(snoozedRead.thread).toMatchObject({ + snoozed: true, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("command:mcp-empty:unsnooze"), + threadId: emptyThread.threadId, + reason: "user", + }); + const unlinkedCall = yield* invoke("t3_thread_update", { threadId: emptyThread.threadId, action: "unlink_pull_request", diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 2779281fe58324c708f7640dc03ca760aea727bf..256b08638ac0e2b2659c7b97b6cb55810ded39e5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "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.", + "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.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "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.", + "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.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index d657938ff20674647a5ae636b9abf023b732a5de..255829ff094c7d3b9df3275256eeb7c167f06402 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread", return Effect.succeed({ threadId: input.threadId, projection: { - thread: { id: input.threadId, projectId, modelSelection }, + thread: { id: input.threadId, projectId, modelSelection, title: input.title }, runs: [], }, resumed: false, @@ -94,7 +94,11 @@ it.effect("attributes a launched thread's first message to the calling thread", const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); + expect(result.at(-1)?.result).toMatchObject({ + projectId, + modelSelection, + link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//), + }); expect(launchedSender).toBe(sourceThreadId); }), ); @@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () => return Effect.succeed({ threadId: input.threadId, projection: { - thread: { id: input.threadId, projectId: input.projectId, modelSelection }, + thread: { + id: input.threadId, + projectId: input.projectId, + modelSelection, + title: input.title, + }, runs: [], }, resumed: false, @@ -329,6 +338,7 @@ const clientLaunchHarness = (input: { id: launch.threadId, projectId: launch.projectId, modelSelection: launch.modelSelection, + title: launch.title, }, runs: [], }, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 9a7d7024111ef2a864de00ad8a91f633a13b9d16..4f3b66e8dc704164b61c1fbfaeb7d30a7a0ffdee 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,4 +1,5 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller } = context; + const { caller, scope } = context; const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, + link: formatThreadLink({ + environmentId: scope.environmentId, + threadId: thread.id, + title: thread.title, + }), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index ab04bd5a195ba4256cab7a93ee22278419671172..bb81b8c83e0e42807ed827b030e645660a94b3ef 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - '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.', + '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.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -132,6 +132,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, + /** Paste this whenever you mention the thread, so the user can click to open it. */ + link: Schema.String, projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 6bc54e94e7382d4afcfca9b20553ae8eec8802db..4b0e451e0ae66ab9256866cd6741d9fa3dba50f1 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => { ), ); }); + +describe("isSnoozed", () => { + const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) }; + it("wakes for completed work after the snooze, but not an interrupted run", () => { + expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, status: "interrupted", latestRunCompletedAt: at(-DAY_MS) }), + NOW_MS, + ), + ).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, status: "completed", latestRunCompletedAt: at(-DAY_MS) }), + NOW_MS, + ), + ).toBe(false); + expect( + ThreadSettlementService.isSnoozed(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS), + ).toBe(false); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 620e1e498d7bd28624de311ad1b3224bd28a4fd1..5e3f7e795600656e0b52e1f7cc60515cd87e716c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -163,6 +163,39 @@ export function isAutoSettlementCandidate( return wokeOnError || wokeOnCompletion; } +/** + * Whether a thread is parked on its snooze: its wake time is in the future and + * it has not raised its hand with a pending request, a fresh failure, or work + * that completed after the snooze. Server twin of the client's + * `effectiveSnoozed`, so agents and the sidebar agree on what is snoozed. One + * difference: a failure counts as fresh when its run completed after the + * snooze, like `isAutoSettlementCandidate`. The client compares the shell's + * update time, so a rename can wake a failed thread there but not here. + */ +export function isSnoozed( + thread: Pick< + ProjectionStore.ProjectionSettlementCandidate, + "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest" + >, + nowMs: number, +): boolean { + const snoozedUntilMs = toMillis(thread.snoozedUntil); + if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false; + if (thread.pendingRuntimeRequest !== null) return false; + const snoozedAtMs = toMillis(thread.snoozedAt); + const completedAtMs = toMillis(thread.latestRunCompletedAt); + const wokeOnError = + thread.status === "failed" && + (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs)); + // Like the client, only a run that completed wakes it; an interrupt or cancel does not. + const wokeOnCompletion = + thread.status === "completed" && + snoozedAtMs !== null && + completedAtMs !== null && + completedAtMs > snoozedAtMs; + return !wokeOnError && !wokeOnCompletion; +} + export function resolveAutoSettlementAt(input: { readonly thread: ProjectionStore.ProjectionSettlementCandidate; readonly pullRequest: SettlementPullRequest | null; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0d0126edb8e167e44717c6d45bcf538978f39244..42a34d8b5d25c1653988f17049d4105f8c42b47f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; +import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import remarkGfm from "remark-gfm"; import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, protocols: { ...defaultSchema.protocols, - href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "t3-context"], + href: [ + ...(defaultSchema.protocols?.href ?? []), + "file", + "t3-citation", + "t3-context", + THREAD_LINK_PROTOCOL, + ], src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"], }, } satisfies Parameters[0]; @@ -2645,6 +2653,7 @@ function useChatMarkdownState({ const markdownUrlTransform = useCallback((href: string) => { if (parseAssistantCitationHref(href)) return href; if (parseComposerContextHref(href)) return href; + if (parseThreadLinkHref(href)) return href; if (isWindowsDrivePathHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; + // A thread link opens the thread here, never a browser. + const threadLink = href ? parseThreadLinkHref(href) : null; + if (threadLink) { + return ( + + {children} + + ); + } const contextReference = href ? parseComposerContextHref(href) : null; if (contextReference) { const label = hastPlainTextDeep(node) || contextReference.contextId; diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc3f5bdb0efbf0b30e999cffc76d4d072ba1521b --- /dev/null +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -0,0 +1,43 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { MessageSquareTextIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { useProject, useThreadShell } from "../../state/entities"; +import { ProjectFavicon } from "../ProjectFavicon"; + +/** + * A `t3-thread://` link in chat. It leads with the thread's project icon, like + * a web link leads with its favicon, so it reads as a thread link before you + * hover it. Opens the thread in the app. + */ +export function MarkdownThreadLink(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly children: ReactNode; +}) { + const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const project = useProject( + thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), + ); + return ( + + + {project === null ? ( + + ) : ( + + )} + + {props.children} + + ); +} diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index fb79ac46fa07b41f3ffeb63ab97fc2f13ade13ce..08c2f19bdabf067bbe6144c0001db636cf2c8301 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,6 +352,13 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. +List, read, and launch results include `link`, a Markdown link of the form +`[title](t3-thread://v1//)` that clients open as the +thread. List and read results also report `snoozed` and `snoozedUntil`, and +`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the +client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread +wakes early when it has a pending request, fails, or completes after the snooze. + ### `t3_thread_update` Updates metadata for the calling thread or any other thread in the environment. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 0a9179c2a8f4426513ec14e54e42f3207987e964..d13cf419f19f2e203b3e078398873ceec854ab4a 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -295,6 +295,7 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({ ), titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), settled: Schema.optional(Schema.Boolean), + snoozed: Schema.optional(Schema.Boolean), includeSubagents: Schema.optional(Schema.Boolean), cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), @@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, + /** Paste this whenever you mention the thread, so the user can click to open it. */ + link: Schema.String, title: Schema.String, createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, @@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), + snoozed: Schema.Boolean, + /** When a snoozed thread wakes; null when it is not snoozed. */ + snoozedUntil: Schema.NullOr(IsoDateTime), parentThreadId: Schema.NullOr(ThreadId), relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), itemCount: NonNegativeInt, @@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, + /** Paste this whenever you mention the thread, so the user can click to open it. */ + link: Schema.String, projectId: ProjectId, title: Schema.String, createdBy: OrchestrationV2Actor, @@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ archived: Schema.Boolean, settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), + snoozed: Schema.Boolean, + /** When a snoozed thread wakes; null when it is not snoozed. */ + snoozedUntil: Schema.NullOr(IsoDateTime), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 3732ebd5cfa67ae9d87459dc46bc480b44ee28e8..73f6698f9b705004b6bdd2afe7744e4a59778441 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -243,6 +243,10 @@ "types": "./src/assistantCitations.ts", "import": "./src/assistantCitations.ts" }, + "./threadLinks": { + "types": "./src/threadLinks.ts", + "import": "./src/threadLinks.ts" + }, "./composerContextReferences": { "types": "./src/composerContextReferences.ts", "import": "./src/composerContextReferences.ts" diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6f853e9827f2c1a8c040367d8d5b86b78dad494 --- /dev/null +++ b/packages/shared/src/threadLinks.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; + +describe("thread links", () => { + it("round-trips ids that contain URL characters", () => { + const link = formatThreadLink({ + environmentId: "studio mac", + threadId: "mcp:(1)/2", + title: "Fix [the] build\nnow", + }); + const href = /\]\((.+)\)$/.exec(link)![1]!; + expect(link.startsWith("[Fix the build now](")).toBe(true); + // A raw parenthesis would end the Markdown link early. + expect(href).not.toMatch(/[()]/); + expect(parseThreadLinkHref(href)).toEqual({ + environmentId: "studio mac", + threadId: "mcp:(1)/2", + }); + }); + + it("rejects other links and malformed ones", () => { + expect(parseThreadLinkHref("https://t3.codes")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull(); + }); +}); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..179350c1ee6bfd38c4e37fd44e72adb04e89dfae --- /dev/null +++ b/packages/shared/src/threadLinks.ts @@ -0,0 +1,58 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +/** + * In-app links to threads. Agents put them in Markdown as + * `[title](t3-thread://v1//)`, and clients open the + * thread instead of a browser. Thread tools return a ready `link` for each + * thread, so agents never build one by hand. + */ +export const THREAD_LINK_PROTOCOL = "t3-thread"; +const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; +const LINK_LABEL_MAX_CHARS = 120; + +const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); +const decodeThreadId = Schema.decodeUnknownOption(ThreadId); + +// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. +function encodeIdSegment(id: string): string { + return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); +} + +function formatThreadLinkHref(environmentId: string, threadId: string): string { + return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; +} + +/** A Markdown link to the thread, labeled with its title. */ +export function formatThreadLink(thread: { + readonly environmentId: string; + readonly threadId: string; + readonly title: string; +}): string { + const label = + thread.title + .replace(/[[\]\\\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; + return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; +} + +export function parseThreadLinkHref( + href: string, +): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { + if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; + const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/"); + if (parts.length !== 2) return null; + try { + const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!)); + const threadId = decodeThreadId(decodeURIComponent(parts[1]!)); + return Option.isSome(environmentId) && Option.isSome(threadId) + ? { environmentId: environmentId.value, threadId: threadId.value } + : null; + } catch { + // Malformed percent encoding. + return null; + } +}